Как сделать свою первую игру в жанре Roguelike
() translation by (you can also view the original English article)
Игры в жанре roguelike, такие, как Dungeons of Dredmor, Spelunky, The Binding of Isaac и FTL, в последнее время стали очень популярны, они охватывают широкую аудиторию и получили признание критиков. Этот жанр давно любим хардкорными игроками в определенной крошечной нише, а различные комбинации элементов жанра roguelike теперь добавляют многим играм глубины и реиграбельности.



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

Примечание. Хотя в этом руководстве и используются JavaScript, HTML и Phaser, вы можете использовать эти принципы для реализации на любом другом языке и движке.
Подготовка
Для этого руководства вам понадобится текстовый редактор и браузер. Я использую Notepad++ и Google Chrome за его обширные инструменты для разработчиков, но рабочий процесс будет практически таким же, как с любым текстовым редактором и браузером, который вы выберете.
Затем вы должны скачать исходники и начать с папки init
: она содержит файлы Phaser, HTML и JS, необходимые для нашей игры. Наш код мы будем писать в пустом файле rl.js
.
Файл index.html
просто загружает Phaser и наш вышеупомянутый файл с кодом игры:
1 |
<!DOCTYPE html>
|
2 |
<head>
|
3 |
<title>roguelike tutorial</title> |
4 |
<script src="phaser.min.js"></script> |
5 |
<script src="rl.js"></script> |
6 |
</head>
|
7 |
</html>
|
Инициализация и определения
Сейчас для нашей roguelike игры мы будем использовать ASCII графику — впоследствии ее можно заменить bitmap-графикой, но сейчас проще взять ASCII.
Давайте определим некоторые константы для размера шрифта, размеров нашей карты (то есть уровня) и количества персонажей:
1 |
// font size
|
2 |
var FONT = 32; |
3 |
|
4 |
// map dimensions
|
5 |
var ROWS = 10; |
6 |
var COLS = 15; |
7 |
|
8 |
// number of actors per level, including player
|
9 |
var ACTORS = 10; |
Также инициализируем Phaser и слушатели сигналов с клавиатуры, так как мы создаём пошаговую игру и хотим создавать действие после каждого нажатия клавиши:
1 |
// initialize phaser, call create() once done
|
2 |
var game = new Phaser.Game(COLS * FONT * 0.6, ROWS * FONT, Phaser.AUTO, null, { |
3 |
create: create |
4 |
});
|
5 |
|
6 |
function create() { |
7 |
// init keyboard commands
|
8 |
game.input.keyboard.addCallbacks(null, null, onKeyUp); |
9 |
}
|
10 |
|
11 |
function onKeyUp(event) { |
12 |
switch (event.keyCode) { |
13 |
case Keyboard.LEFT: |
14 |
|
15 |
case Keyboard.RIGHT: |
16 |
|
17 |
case Keyboard.UP: |
18 |
|
19 |
case Keyboard.DOWN: |
20 |
|
21 |
}
|
22 |
}
|
Так как ширина стандартных моноширинных шрифтов равна 60% от высоты, мы зададим размер поля как 0.6 * размер шрифта * количество столбцов
. Мы также говорим Phaser, что он должен вызвать нашу функцию create()
сразу после завершения инициализации, когда инициализируется и управление с клавиатуры.
Можете взглянуть на нашу игру здесь — правда, там пока и смотреть не на что!
Карта
Клеточная карта представляет собой игровую зону: дискретный двумерный массив
клеток, представленных символами ASCII, которые могут изображать либо
стену (#
: блокирует перемещение), либо пол (.
: не блокирует перемещение):
1 |
// the structure of the map
|
2 |
var map; |
Давайте будем использовать простейшую форму процедурной генерации карт: каждая клетка принимает одно из двух значений случайным образом:
1 |
function initMap() { |
2 |
// create a new random map
|
3 |
map = []; |
4 |
for (var y = 0; y < ROWS; y++) { |
5 |
var newRow = []; |
6 |
for (var x = 0; x < COLS; x++) { |
7 |
if (Math.random() > 0.8) |
8 |
newRow.push('#'); |
9 |
else
|
10 |
newRow.push('.'); |
11 |
}
|
12 |
map.push(newRow); |
13 |
}
|
14 |
}
|
Таким образом мы получим карту, где 80% ячеек являются стенами, а остальные - полами.
Мы инициализируем новую карту в функции create()
сразу после запуска прослушивателей событий клавиатуры:
1 |
function create() { |
2 |
// init keyboard commands
|
3 |
game.input.keyboard.addCallbacks(null, null, onKeyUp); |
4 |
|
5 |
// initialize map
|
6 |
initMap(); |
7 |
}
|
Здесь вы можете посмотреть демо, хотя, опять же, смотреть пока нечего, потому, что мы всё равно не отрисовали нашу карту.
Экран
Настало время вывести нашу карту на созданный экран! Наш экран будет представлять собой 2D-массив текстовых элементов, каждый из которых содержит один символ:
1 |
// the ascii display, as a 2d array of characters
|
2 |
var asciidisplay; |
Прорисовка карты заполнит содержимое экрана значениями карты, так как оба являются простыми символами ASCII:
1 |
function drawMap() { |
2 |
for (var y = 0; y < ROWS; y++) |
3 |
for (var x = 0; x < COLS; x++) |
4 |
asciidisplay[y][x].content = map[y][x]; |
5 |
}
|
Тем не менее, перед отрисовкой карты экран нужно инициализировать. Вернёмся к нашей функции create()
:
1 |
function create() { |
2 |
// init keyboard commands
|
3 |
game.input.keyboard.addCallbacks(null, null, onKeyUp); |
4 |
|
5 |
// initialize map
|
6 |
initMap(); |
7 |
|
8 |
// initialize screen
|
9 |
asciidisplay = []; |
10 |
for (var y = 0; y < ROWS; y++) { |
11 |
var newRow = []; |
12 |
asciidisplay.push(newRow); |
13 |
for (var x = 0; x < COLS; x++) |
14 |
newRow.push( initCell('', x, y) ); |
15 |
}
|
16 |
drawMap(); |
17 |
}
|
18 |
|
19 |
function initCell(chr, x, y) { |
20 |
// add a single cell in a given position to the ascii display
|
21 |
var style = { font: FONT + "px monospace", fill:"#fff"}; |
22 |
return game.add.text(FONT*0.6*x, FONT*y, chr, style); |
23 |
}
|
Теперь при запуске проекта вы должны увидеть случайно сгенерированную карту.

Персонажи
Теперь займёмся персонажами: наш игровой персонаж и враги, которых он должен победить. Каждый персонаж будет объектом с тремя полями: координаты x
и y
и хитпоинты hp
.
Мы будем хранить всех персонажей в массиве actorList
(его первый элемент — игрок). Мы также будем хранить ассоциативный массив с позициями персонажей в качестве ключей для быстрого поиска; это поможет нам, когда мы займёмся перемещением и сражением.
1 |
// a list of all actors; 0 is the player
|
2 |
var player; |
3 |
var actorList; |
4 |
var livingEnemies; |
5 |
|
6 |
// points to each actor in its position, for quick searching
|
7 |
var actorMap; |
Мы создаём всех персонажей и рандомно размещаем их на свободных ячейках карты:
1 |
function randomInt(max) { |
2 |
return Math.floor(Math.random() * max); |
3 |
}
|
4 |
|
5 |
function initActors() { |
6 |
// create actors at random locations
|
7 |
actorList = []; |
8 |
actorMap = {}; |
9 |
for (var e=0; e<ACTORS; e++) { |
10 |
// create new actor
|
11 |
var actor = { x:0, y:0, hp:e == 0?3:1 }; |
12 |
do { |
13 |
// pick a random position that is both a floor and not occupied
|
14 |
actor.y=randomInt(ROWS); |
15 |
actor.x=randomInt(COLS); |
16 |
} while ( map[actor.y][actor.x] == '#' || actorMap[actor.y + "_" + actor.x] != null ); |
17 |
|
18 |
// add references to the actor to the actors list & map
|
19 |
actorMap[actor.y + "_" + actor.x]= actor; |
20 |
actorList.push(actor); |
21 |
}
|
22 |
|
23 |
// the player is the first actor in the list
|
24 |
player = actorList[0]; |
25 |
livingEnemies = ACTORS-1; |
26 |
}
|
Настало время показать персонажей! Мы изобразим всех врагов буквой e
, а игрока — количеством его хитпоинтов:
1 |
function drawActors() { |
2 |
for (var a in actorList) { |
3 |
if (actorList[a].hp > 0) |
4 |
asciidisplay[actorList[a].y][actorList[a].x].content = a == 0?''+player.hp:'e'; |
5 |
}
|
6 |
}
|
Возьмём только что написанные функции и передадим их в функцию create()
:
1 |
function create() { |
2 |
...
|
3 |
// initialize actors
|
4 |
initActors(); |
5 |
...
|
6 |
drawActors(); |
7 |
}
|
Теперь мы можем увидеть размещённых на поле противников и игрока!

Блокирующие и неблокирующие клетки
Нам нужно убедиться, что персонажи не выходят за пределы уровня и не проходят сквозь стены, поэтому добавим простую проверку:
1 |
function canGo(actor,dir) { |
2 |
return actor.x+dir.x >= 0 && |
3 |
actor.x+dir.x <= COLS - 1 && |
4 |
actor.y+dir.y >= 0 && |
5 |
actor.y+dir.y <= ROWS - 1 && |
6 |
map[actor.y+dir.y][actor.x +dir.x] == '.'; |
7 |
}
|
Перемещение и сражение
Наконец, мы пришли к определенному взаимодействию: движению и бою! Так как в классических рогаликах персонажи атакуют друг друга при столкновении, мы обработаем это в функции moveTo()
, которая принимает персонажа и направление (направление задаётся разностью координат x
и y
текущей и желаемой клеток):
1 |
function moveTo(actor, dir) { |
2 |
// check if actor can move in the given direction
|
3 |
if (!canGo(actor,dir)) |
4 |
return false; |
5 |
|
6 |
// moves actor to the new location
|
7 |
var newKey = (actor.y + dir.y) +'_' + (actor.x + dir.x); |
8 |
// if the destination tile has an actor in it
|
9 |
if (actorMap[newKey] != null) { |
10 |
//decrement hitpoints of the actor at the destination tile
|
11 |
var victim = actorMap[newKey]; |
12 |
victim.hp--; |
13 |
|
14 |
// if it's dead remove its reference
|
15 |
if (victim.hp == 0) { |
16 |
actorMap[newKey]= null; |
17 |
actorList[actorList.indexOf(victim)]=null; |
18 |
if(victim!=player) { |
19 |
livingEnemies--; |
20 |
if (livingEnemies == 0) { |
21 |
// victory message
|
22 |
var victory = game.add.text(game.world.centerX, game.world.centerY, 'Victory!\nCtrl+r to restart', { fill : '#2e2', align: "center" } ); |
23 |
victory.anchor.setTo(0.5,0.5); |
24 |
}
|
25 |
}
|
26 |
}
|
27 |
} else { |
28 |
// remove reference to the actor's old position
|
29 |
actorMap[actor.y + '_' + actor.x]= null; |
30 |
|
31 |
// update position
|
32 |
actor.y+=dir.y; |
33 |
actor.x+=dir.x; |
34 |
|
35 |
// add reference to the actor's new position
|
36 |
actorMap[actor.y + '_' + actor.x]=actor; |
37 |
}
|
38 |
return true; |
39 |
}
|
Вкратце:
- Мы убеждаемся, что персонаж может переместиться в эту клетку.
- Если в ней есть другой персонаж, мы атакуем его (и убиваем, если счётчик его хитпоинтов HP достигает нуля).
- Если клетка пуста, мы перемещаемся в неё.
Заметим также, что мы выводим простое сообщение о победе после смерти последнего врага и возвращаем false
или true
в зависимости от того, валидно ли желаемое перемещение.
Теперь вернемся к функции onKeyUp()
и изменим её так, чтобы
при каждом нажатии клавиши мы стирали предыдущие положения персонажей
(отрисовывая поверх них карту), перемещали игрока и снова отрисовывали
персонажей:
1 |
function onKeyUp(event) { |
2 |
// draw map to overwrite previous actors positions
|
3 |
drawMap(); |
4 |
|
5 |
// act on player input
|
6 |
var acted = false; |
7 |
switch (event.keyCode) { |
8 |
case Phaser.Keyboard.LEFT: |
9 |
acted = moveTo(player, {x:-1, y:0}); |
10 |
break; |
11 |
|
12 |
case Phaser.Keyboard.RIGHT: |
13 |
acted = moveTo(player,{x:1, y:0}); |
14 |
break; |
15 |
|
16 |
case Phaser.Keyboard.UP: |
17 |
acted = moveTo(player, {x:0, y:-1}); |
18 |
break; |
19 |
|
20 |
case Phaser.Keyboard.DOWN: |
21 |
acted = moveTo(player, {x:0, y:1}); |
22 |
break; |
23 |
}
|
24 |
|
25 |
// draw actors in new positions
|
26 |
drawActors(); |
27 |
}
|
Скоро мы введем переменную acted
, чтобы узнать, должны ли после перемещения игрока действовать враги.

Базовый искусственный интеллект
После того, как мы закончили с реализацией игрока, займёмся врагами. Напишем простой алгоритм поиска пути, по которому враг будут двигаться к игроку, если расстояние между ними не превышает шести шагов. (Если игрок находится дальше, враг ходит случайно).
Заметим, что противнику неважно, кого атаковать: таким образом, при правильном размещении противники будут уничтожать друг друга, пытаясь догнать игрока. Прям как в классическом Doom!
1 |
function aiAct(actor) { |
2 |
var directions = [ { x: -1, y:0 }, { x:1, y:0 }, { x:0, y: -1 }, { x:0, y:1 } ]; |
3 |
var dx = player.x - actor.x; |
4 |
var dy = player.y - actor.y; |
5 |
|
6 |
// if player is far away, walk randomly
|
7 |
if (Math.abs(dx) + Math.abs(dy) > 6) |
8 |
// try to walk in random directions until you succeed once
|
9 |
while (!moveTo(actor, directions[randomInt(directions.length)])) { }; |
10 |
|
11 |
// otherwise walk towards player
|
12 |
if (Math.abs(dx) > Math.abs(dy)) { |
13 |
if (dx < 0) { |
14 |
// left
|
15 |
moveTo(actor, directions[0]); |
16 |
} else { |
17 |
// right
|
18 |
moveTo(actor, directions[1]); |
19 |
}
|
20 |
} else { |
21 |
if (dy < 0) { |
22 |
// up
|
23 |
moveTo(actor, directions[2]); |
24 |
} else { |
25 |
// down
|
26 |
moveTo(actor, directions[3]); |
27 |
}
|
28 |
}
|
29 |
if (player.hp < 1) { |
30 |
// game over message
|
31 |
var gameOver = game.add.text(game.world.centerX, game.world.centerY, 'Game Over\nCtrl+r to restart', { fill : '#e22', align: "center" } ); |
32 |
gameOver.anchor.setTo(0.5,0.5); |
33 |
}
|
34 |
}
|
Также мы добавили сообщение, которое выводится на экран если один из противников убивает игрока.
Теперь нам осталось сделать так, чтобы враги перемещались с каждым ходом игрока. Дополним функцию onKeyUp()
:
1 |
function onKeyUp(event) { |
2 |
...
|
3 |
// enemies act every time the player does
|
4 |
if (acted) |
5 |
for (var enemy in actorList) { |
6 |
// skip the player
|
7 |
if(enemy==0) |
8 |
continue; |
9 |
|
10 |
var e = actorList[enemy]; |
11 |
if (e != null) |
12 |
aiAct(e); |
13 |
}
|
14 |
|
15 |
// draw actors in new positions
|
16 |
drawActors(); |
17 |
}
|

Бонус: версия на Haxe
Изначально я писал это руководство на Haxe, кроссплатформенном языке, компилирующемся в JavaScript (и не только). Хотя я перевел версию выше вручную, чтобы убедиться, что мы получим идиосинкразированный JavaScript. Если вы предпочитаете haxe
, а не JavaScript, тогда вы можете найти ее в папке haxe в исходниках.
Сперва вам потребуется установить компилятор haxe, после чего скомпилировать написанный в любом текстовом редакторе код, вызвав haxe build.hxml
и дважды кликнув по файлу build.hxml
. Я также добавил проект FlashDevelop, если вы предпочитаете пользоваться удобной IDE: просто откройте rl.hxproj
и нажмите F5 для запуска.
Резюме
Вот и всё! Мы закончили создание простой roguelike-игры со случайной генерацией карты, движением, боем, ИИ и условиями победы/поражения.
Вот некоторые фичи, которые вы можете добавить в свою игру:
- несколько уровней;
- бонусы;
- инвентарь;
- аптечки;
- снаряжение.
Наслаждайтесь!