Advertisement
  1. Game Development
  2. Roguelike

Как сделать свою первую игру в жанре Roguelike

Scroll to top
Read Time: 11 min

() translation by (you can also view the original English article)

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

Wayfarer a 3D roguelike currently in developmentWayfarer a 3D roguelike currently in developmentWayfarer a 3D roguelike currently in development
3D-рогалик Warfarer, который в настоящее время находятся в разработке.

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

Click to play the game
Нажмите, чтобы сыграть.

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

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

Click to view the game so far
Нажмите для просмотра результата.

Персонажи

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

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

Click to view the game so far
Нажмите для просмотра результата.

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

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

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
}

Вкратце:

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

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

Click to view the game so far
Нажмите для просмотра результата.

Базовый искусственный интеллект

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

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

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

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

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


Резюме

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

Вот некоторые фичи, которые вы можете добавить в свою игру:

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

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

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Game Development tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.