Написать пост

Пишем свою игру в жанре Roguelike

Аватар Иван Бирюков

Обложка поста Пишем свою игру в жанре Roguelike

Рассказывает Ido Yehieli 

Игры в жанре roguelike, такие как Dungeons of Dredmor, Spelunky, The Binding of Isaac и FTL, в последнее время стали очень популярны, а различные комбинации элементов этого жанра теперь добавляют многим играм глубины и реиграбельности.

Пишем свою игру в жанре Roguelike 1

Следуя инструкциям этого руководства, вы создадите традиционный «рогалик», используя игровой движок Phaser на JS+HTML5. Кстати, недавно мы публиковали обзор таких движков. В результате вы получите полнофункциональную игру в жанре «roguelike», запускаемую в браузере. (Под рогаликом мы подразумеваем одиночный рандомизированный пошаговый dungeon-crawler с одной жизнью.)

Пишем свою игру в жанре Roguelike 2

Примечание: хотя в этом руководстве и используются JavaScript, HTML и Phaser, вы можете использовать эти принципы для реализации на любом другом языке и движке.

Подготовка

Вам понадобятся текстовый редактор и браузер. Я использую Notepad++ и Google Chrome, но это не принципиально.

Затем вы должны скачать исходники и начать с папки init: она содержит файлы Phaser, HTML и JS, необходимые для нашей игры. Наш код мы будем писать в пустом файле rl.js.

Файл index.html file просто загружает Phaser и вышеупомянутый файл с кодом игры:

			<!DOCTYPE html>
<head>
    <title>roguelike tutorial</title>
    <script src="phaser.min.js"></script>
    <script src="rl.js"></script>
</head>
</html>
		

Инициализация и определения

Сейчас для нашей игры мы будем использовать символы ASCII — впоследствии их можно заменить bitmap-графикой, но сейчас проще взять ASCII.

Давайте зададим несколько констант для размера шрифта, размера карты и количества персонажей:

			// font size
var FONT = 32;

// map dimensions
var ROWS = 10;
var COLS = 15;

// number of actors per level, including player
var ACTORS = 10;
		

Также инициализируем Phaser и слушатели сигналов с клавиатуры, так как мы создаём пошаговую игру и хотим создавать действие после каждого нажатия клавиши:

			// initialize phaser, call create() once done
var game = new Phaser.Game(COLS * FONT * 0.6, ROWS * FONT, Phaser.AUTO, null, {
    create: create
});

function create() {
    // init keyboard commands
    game.input.keyboard.addCallbacks(null, null, onKeyUp);
}

function onKeyUp(event) {
    switch (event.keyCode) {
        case Keyboard.LEFT:

        case Keyboard.RIGHT:

        case Keyboard.UP:

        case Keyboard.DOWN:

    }
}
		

Так как ширина стандартных моноширинных шрифтов равна 60% от высоты, мы зададим размер поля как 0.6 * размер шрифта * количество столбцов. Мы также говорим Phaser, что он должен вызвать нашу функцию create() сразу после завершения инициализации, когда инициализируется и управление с клавиатуры.

Можете взглянуть на нашу игру — правда, там пока и смотреть не на что:)

Карта

Клеточная карта отражает нашу игровую зону: дискретный двумерный массив клеток, представленных символами ASCII, которые могут изображать либо стену (#: блокирует перемещение), либо пол (.: не блокирует перемещение):

			// the structure of the map
var map;
		

Давайте будем использовать простейшую форму процедурной генерации карт: каждая клетка принимает одно из двух значений случайным образом:

			function initMap() {
    // create a new random map
    map = [];
    for (var y = 0; y < ROWS; y++) {
        var newRow = [];
        for (var x = 0; x < COLS; x++) {
            if (Math.random() > 0.8)
                newRow.push('#');
            else
                newRow.push('.');
        }
        map.push(newRow);
    }
}
		

Таким образом мы получим карту, примерно на 20% занятую стенами.

Мы инициализируем новую карту в функции create() сразу после запуска слушателей клавиатуры:

			function create() {
    // init keyboard commands
    game.input.keyboard.addCallbacks(null, null, onKeyUp);

    // initialize map
    initMap();
}
		

Можете посмотреть, что получилось — пока что мы всё равно не отрисовали наши карту.

Экран

Настало время вывести нашу карту на созданный экран:

			// the ascii display, as a 2d array of characters
var asciidisplay;

function drawMap() {
    for (var y = 0; y < ROWS; y++)
        for (var x = 0; x < COLS; x++)
            asciidisplay[y][x].content = map[y][x];
}
		

Тем не менее, перед отрисовкой карты экран нужно инициализировать. Вернёмся к функции create():

			function create() {
    // init keyboard commands
    game.input.keyboard.addCallbacks(null, null, onKeyUp);

    // initialize map
    initMap();

    // initialize screen
    asciidisplay = [];
    for (var y = 0; y < ROWS; y++) {
        var newRow = [];
        asciidisplay.push(newRow);
        for (var x = 0; x < COLS; x++)
            newRow.push( initCell('', x, y) );
    }
    drawMap();
}

function initCell(chr, x, y) {
    // add a single cell in a given position to the ascii display
    var style = { font: FONT + "px monospace", fill:"#fff"};
    return game.add.text(FONT*0.6*x, FONT*y, chr, style);
}
		

Теперь при запуске проекта вы должны видеть случайную карту.

Пишем свою игру в жанре Roguelike 3

Персонажи

Теперь займёмся персонажами: нашим игроком и его врагами. Каждый персонаж будет объектом с тремя полями: координаты x и y и хитпоинты hp.

Мы будем хранить всех персонажей в массиве actorList (его первый элемент — игрок). Мы также будем хранить ассоциативный массив с позициями персонажей в качестве ключей для быстрого поиска; это поможет нам, когда мы займёмся перемещением и боёвкой.

			// a list of all actors; 0 is the player
var player;
var actorList;
var livingEnemies;

// points to each actor in its position, for quick searching
var actorMap;
		

Мы создаём всех персонажей и рандомно размещаем их на свободных ячейках карты:

			function randomInt(max) {
    return Math.floor(Math.random() * max);
}
function initActors() {
    // create actors at random locations
    actorList = [];
    actorMap = {};
    for (var e=0; e<ACTORS; e++) {
        // create new actor
        var actor = { x:0, y:0, hp:e == 0?3:1 };
        do {
            // pick a random position that is both a floor and not occupied
            actor.y=randomInt(ROWS);
            actor.x=randomInt(COLS);
        } while ( map[actor.y][actor.x] == '#' || actorMap[actor.y + "_" + actor.x] != null );

        // add references to the actor to the actors list & map
        actorMap[actor.y + "_" + actor.x]= actor;
        actorList.push(actor);
    }

    // the player is the first actor in the list
    player = actorList[0];
    livingEnemies = ACTORS-1;
}
		

Настало время показать персонажей! Мы изобразим всех врагов буквой e, а игрока — количеством его хитпоинтов:

			function drawActors() {
    for (var a in actorList) {
        if (actorList[a].hp > 0)
            asciidisplay[actorList[a].y][actorList[a].x].content = a == 0?''+player.hp:'e';
    }
}
		

Возьмём только что написанные функции и передадим их в create():

			function create() {
    ...
    // initialize actors
    initActors();
    ...
    drawActors();
}
		

Теперь мы можем увидеть размещённых на поле противников и игрока!

Пишем свою игру в жанре Roguelike 4

Блокирующие и неблокирующие клетки

Нам нужно убедиться, что персонажи не выходят за пределы уровня и не проходят сквозь стены, поэтому добавим простую проверку:

			function canGo(actor,dir) {
    return actor.x+dir.x >= 0 &&
    actor.x+dir.x <= COLS - 1 &&
    actor.y+dir.y >= 0 &&
    actor.y+dir.y <= ROWS - 1 &&
    map[actor.y+dir.y][actor.x +dir.x] == '.';
}
		

Перемещение и сражение

Наконец-то мы дошли до движухи! Так как в классических рогаликах персонажи атакуют друг друга при столкновении, мы обработаем это в функции moveTo(), которая принимает персонажа и направление (направление задаётся разностью координат x и y текущей и желаемой клеток):

			function moveTo(actor, dir) {
    // check if actor can move in the given direction
    if (!canGo(actor,dir))
        return false;

    // moves actor to the new location
    var newKey = (actor.y + dir.y) +'_' + (actor.x + dir.x);
    // if the destination tile has an actor in it
    if (actorMap[newKey] != null) {
        //decrement hitpoints of the actor at the destination tile
        var victim = actorMap[newKey];
        victim.hp--;

        // if it's dead remove its reference
        if (victim.hp == 0) {
            actorMap[newKey]= null;
            actorList[actorList.indexOf(victim)]=null;
            if(victim!=player) {
                livingEnemies--;
                if (livingEnemies == 0) {
                    // victory message
                    var victory = game.add.text(game.world.centerX, game.world.centerY, 'Victory!\nCtrl+r to restart', { fill : '#2e2', align: "center" } );
                    victory.anchor.setTo(0.5,0.5);
                }
            }
        }
    } else {
        // remove reference to the actor's old position
        actorMap[actor.y + '_' + actor.x]= null;

        // update position
        actor.y+=dir.y;
        actor.x+=dir.x;

        // add reference to the actor's new position
        actorMap[actor.y + '_' + actor.x]=actor;
    }
    return true;
}
		

Вкратце:

  1. Мы убеждаемся, что персонаж может переместиться в эту клетку.
  2. Если в ней есть другой персонаж, мы атакуем его (и убиваем, если счётчик его хитпоинтов достигает нуля).
  3. Если клетка пуста, мы перемещаемся в неё.

Заметим также, что мы выводим простое сообщение о победе после смерти последнего врага и возвращаем false или true в зависимости от того, валидно ли желаемое перемещение.

Теперь вернемся к функции onKeyUp() и изменим её так, чтобы при каждом нажатии клавиши мы стирали предыдущие положения персонажей (отрисовывая поверх них карту), перемещали игрока и снова отрисовывали персонажей:

			function onKeyUp(event) {
    // draw map to overwrite previous actors positions
    drawMap();

    // act on player input
    var acted = false;
    switch (event.keyCode) {
        case Phaser.Keyboard.LEFT:
            acted = moveTo(player, {x:-1, y:0});
            break;

        case Phaser.Keyboard.RIGHT:
            acted = moveTo(player,{x:1, y:0});
            break;

        case Phaser.Keyboard.UP:
            acted = moveTo(player, {x:0, y:-1});
            break;

        case Phaser.Keyboard.DOWN:
            acted = moveTo(player, {x:0, y:1});
            break;
    }

    // draw actors in new positions
    drawActors();
}
		

Скоро мы введем переменную acted, чтобы узнать, должны ли после перемещения игрока действовать враги.

Пишем свою игру в жанре Roguelike 5

Простой ИИ

После того, как мы закончили с реализацией игрока, займёмся врагами. Напишем простой алгоритм поиска пути, по которому враг будут двигаться к игроку, если расстояние между ними не превышает шести шагов, а в противном случае будет перемещаться случайно. О различных алгоритмах поиска мы уже коротко рассказывали в нашей недавней статье.

Заметим, что противнику неважно, кого атаковать: таким образом, при правильном размещении противники будут уничтожать друг друга, пытаясь догнать игрока. Прям как в классическом Doom!

			function aiAct(actor) {
    var directions = [ { x: -1, y:0 }, { x:1, y:0 }, { x:0, y: -1 }, { x:0, y:1 } ];
    var dx = player.x - actor.x;
    var dy = player.y - actor.y;

    // if player is far away, walk randomly
    if (Math.abs(dx) + Math.abs(dy) > 6)
        // try to walk in random directions until you succeed once
        while (!moveTo(actor, directions[randomInt(directions.length)])) { };

    // otherwise walk towards player
    if (Math.abs(dx) > Math.abs(dy)) {
        if (dx < 0) {
            // left
            moveTo(actor, directions[0]);
        } else {
            // right
            moveTo(actor, directions[1]);
        }
    } else {
        if (dy < 0) {
            // up
            moveTo(actor, directions[2]);
        } else {
            // down
            moveTo(actor, directions[3]);
        }
    }
    if (player.hp < 1) {
        // game over message
        var gameOver = game.add.text(game.world.centerX, game.world.centerY, 'Game Over\nCtrl+r to restart', { fill : '#e22', align: "center" } );
        gameOver.anchor.setTo(0.5,0.5);
    }
}
		

Также мы добавили сообщение, которое выводится на экран при смерти игрока.

Теперь нам осталось сделать так, чтобы враги перемещались с каждым ходом игрока. Дополним функцию onKeyUp():

			function onKeyUp(event) {
    ...
    // enemies act every time the player does
    if (acted)
        for (var enemy in actorList) {
            // skip the player
            if(enemy==0)
                continue;

            var e = actorList[enemy];
            if (e != null)
                aiAct(e);
        }

    // draw actors in new positions
    drawActors();
}
		
Пишем свою игру в жанре Roguelike 6

Бонус: версия на Haxe

Изначально я писал это руководство на Haxe, кроссплатформенном языке, компилирующемся в JavaScript (и не только). Эту версию мы можете найти в папке haxe в исходниках.

Сперва вам потребуется установить компилятор haxe, после чего скомпилировать написанный в любом текстовом редакторе код, вызвав haxe build.hxml и дважды кликнув по файлу build.hxml. Я также добавил проект FlashDevelop, если вы предпочитаете пользоваться удобной IDE: просто откройте rl.hxproj и нажмите F5 для запуска.

Заключение

Вот и всё! Мы закончили создание простой roguelike-игры со случайной генерацией карты, движением, боёвкой, ИИ и условиями победы/поражения.

Вот некоторые фичи, который вы могли бы добавить:

  • несколько уровней;
  • бонусы;
  • инвентарь;
  • аптечки;
  • снаряжение.

Наслаждайтесь!

Следите за новыми постами
Следите за новыми постами по любимым темам
32К открытий32К показов