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

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


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

3D-рогалик Warfarer

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

Click to play the game

Нажмите, чтобы сыграть.

Примечание: хотя в этом руководстве и используются 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);
}

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

Click to view the game so far

Нажмите для просмотра результата.

Персонажи

Теперь займёмся персонажами: нашим игроком и его врагами. Каждый персонаж будет объектом с тремя полями: координаты 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();
}

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

Click to view the game so far

Нажмите для просмотра результата.

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

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

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, чтобы узнать, должны ли после перемещения игрока действовать враги.

Click to view the game so far

Нажмите для просмотра результата.

Простой ИИ

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

Заметим, что противнику неважно, кого атаковать: таким образом, при правильном размещении противники будут уничтожать друг друга, пытаясь догнать игрока. Прям как в классическом 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();
}
Click to view the game so far

Нажмите для просмотра результата.

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

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

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

Заключение

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

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

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

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

Перевод статьи «How to Make Your First Roguelike»