Теперь, когда мой рогалик, написанный на Dart, имеет открытый исходный код, мне хотелось бы поговорить о том, на что было потрачено большое количество времени. На самом деле я вложил даже слишком много усилий в некоторые элементы этой игры и может когда-нибудь напишу и о них. Однако сейчас я хотел бы начать с того места, где стартует любая игра: главный цикл.
Я немного помешан на архитектуре кода в играх. Этот рогалик - мой домашний проект, поэтому я полностью отдался своим желаниям. Представьте себе человека, мечтавшего всю жизнь стать проектировщиком железных дорог. Судьба привела его к работе бухгалтером или кем-то подобным, а он все равно мастерит модель железной дороги в своем подвале. Я прям как этот человек, только относительно архитектуры ПО. И рогаликов.
Я рефакторил и переписывал игровой цикл несчетное количество раз (здесь можно было бы пошутить про итерации), и я не хочу протаскивать вас через всю его историю. Вместо этого лучше остановимся на его текущем состоянии, выстраивая и разбирая по кусочку суть этого цикла.
У меня есть несколько высокоуровневых целей:
-
Игровой движок и пользовательский интерфейс должны быть строго разграничены. Я метался между пиксель арт UI стилем и более олдскульным ASCII, и для меня важно, чтобы движок поддерживал оба способа (Текущая версия Dart [15 июля 2014] полностью основана на ASCII, хотя, что забавно, использует canvas API.) Это значит, что движок не должен зависеть от того, как игра предстает перед игроком. Подобно бизнес приложению, я хочу полное разделение логики и внешнего вида.
-
Все существа должны обрабатываться одинаково. Обычно движки работают с неким
actor
, смесьюMonster
и подконтрольного игрокуHero
. Я же хотел бы минимизировать разницу между аватаром игрока и простым монстром и сделать так, чтобы тот обрабатывался как любая другая игровая сущность.
Задача игрового цикла
Hauberg - пошаговая игра, как и многие другие рогалики. Игровые сущности делают ходы по одному за раз и, когда наступает ход игрока, впадают в спячку, ожидая действий с его стороны.
В основе движка лежит игровой цикл. Он пробегает по всем существам и говорит им сделать ход. Это выглядит как-то так:
void gameLoop() {
while (stillPlaying) {
for (var actor in actors) {
actor.update();
}
}
}
Однако движок отграничен от интерфейса, поэтому он действует независимо от него. Есть основной класс - Game
. UI контролирует экземпляр этого класса и говорит ему сделать один "шаг" в игровом процессе, после чего возвращает себе контроль.
Это означает, что Game
должен запоминать крайнее действовавшее существо, чтобы иметь возможность продолжить с того места, на котором в прошлый раз остановился. Что-то вроде такого:
class Game {
final actors = <Actor>[];
int _currentActor = 0;
void process() {
actors[_currentActor].update();
_currentActor = (_currentActor + 1) % actors.length;
}
}
Проницательный читатель, вроде вас, наверняка подумает о том, что это звучит как идеальное место для генератора. Действительно, в предыдущих версиях своей игры, написанных на C#, я использовал именно их. Когда в Dart появятся генераторы, возможно я перейду на них, но сейчас придется реализовывать это самостоятельно.
Действия как способ описания игровой логики
Теперь у нас есть зачатки игрового цикла. Когда приходит время, у очередного существа вызывается метод update()
и оно что-то делает. Монстр может выбрать направление движения, а последствия этого выбора должны быть нормально обработаны. Он может двинуться на клетку другого существа, что вызовет атаку, а может и в стену или дверь.
Как можно понять, даже такое простое действие, как "сделать шаг", требует приличного количества логики, но вы можете заметить, что она одинакова как для монстров, так и для героев. Смелые воины, как и любые другое существа, могут стопориться о стены, и было бы хорошо сделать код для этого одинаковым для всех.
Очевидным кажется просто написать код для перемещения в базовом классе Actor
. Но если мы сделаем это для всего - ближней, дальней атаки, логики инвентаря, магии и т.п. - у нас получится класс Actor
, содержащий просто-напросто всю игру. Нереально дерзко.
Вместо этого мы применим классический архитектурный прием. Мы отделим решение выполнить действие от его непосредственного выполнения. Другими словами, применим паттерн проектирования "Команда". В Hauberg команды называются действиями.
Игровой цикл собирает с существ действия, а затем говорит им выполнить самих себя, что-то вроде:
void process() {
var action = actors[_currentActor].getAction();
action.perform();
_currentActor = (_currentActor + 1) % actors.length;
}
Здесь есть различные классы, каждый со своим предназначением, представляющие абсолютно все, что может делать существо в этом мире. Среди них WalkAction
, OpenDoorAction
, EatAction
и т.п.
Такая архитектура освобождает класс Actor
от кода, описывающего поведение. Что еще лучше, это отделяет все существа друг от друга. Если вы добавляете или меняете возможности существа, вы можете просто добавить самостоятельный небольшой класс - Action
. Это воспринимается как система без зависимостей, в которую можно легко добавлять новую логику или изменять старую. (На сегодняшний день [15 июля 2014 г.] в игре 19 различных действий, и я подумываю добавить еще немного.)
Это также, конечно, помогает нам обращаться с монстрами и героями одинаково. Поскольку все классы Action
работают с экземплярами Actor
, они могут быть использованы как для монстров, так и для героев. (Тут есть немного исключений, так как герои могут то, чего не могут монстры. Сейчас монстры лишены инвентаря, так что все действия, относящиеся к работе с ним, просто неприменимы к самовольным существам.)
Действия с разной скоростью
Мы уже получили простой работающий игровой цикл, однако наша игра как-то слишком пошаговая. Каждый монстр или герой обрабатываются с одинаковой скоростью. Вы делаете шаг, они отвечают тем же - похоже на на древнюю игру с роботами. Вы как не можете быть опереженным, так и опережать. Поглядите сами:
Чтобы исправить это, нужно дать возможность существам двигаться с разными скоростями.
Разумеется, это "скорость" в рамках пошагового мира, не движение быстрее или медленнее в привычном понимании. Она лишь значит, что "более быстрые" существа ходят чаще других. Если вы в два раза быстрее зеленого слайма, значит на один его ход приходится два ваших.
Эта механика необходима в рогаликах, и уже полно примеров как реализовать ее. Я использую ту же систему, что и Angband, потому что она прекрасна.
Она работает как-то так: каждое существо обладает уровнем энергии. Когда игровой цикл доходит до существа, он дает ему некоторое количество энергии. Когда она достигает установленной границы, это означает, что существо может сделать ход и совершить действие. Если такого не происходит, игровой цикл просто переходит к следующему существу. Таким образом, накопление энергии для совершения хода может занять несколько итераций главного цикла. (на самом деле это верно для всех, кроме самых быстрых.)
Когда существо совершает действие, оно сжигает энергию и возвращается в состояние ожидания момента насыщения ей. Сейчас все действия потребляют одинаковое количество энергии и это значит, что любое из них требует одинакового количества "времени" для выполнения, но все еще остается возможность сделать так, чтобы, например, лучник стрелял быстрее, чем размахивал мечом.
Скорость в этой схеме выводится простой логикой: более быстрые существа получают больше энергии за ход. Это означает, что они достигают границы энергии за меньшее количество итераций игрового цикла, и вследствие этого двигаются быстрее. Это настолько просто, насколько кажется.
Самое интересное в этом то, что, используя накопление энергии за несколько ходов, вы можете делать существ, которые двигаются относительно других с произвольным дробным множителем. Вы могли бы создать существо, двигающееся пять раз за каждые семь ходов другого. (Конечно, вы увидите уже непосредственно в игре, что время от времени первое существо делает два хода подряд. Это просто следует из того, что в среднем отношение должно получаться 7/5)
Хватит слов, пора посмотреть на это в действии:
Символ >
указывает на существо, которое сейчас выполняет действие. Монстры все так же ждут вашего хода. После него вы можете видеть, как игровой цикл проходит по существам, наполняя их небольшим количеством энергии. Когда ее полоска достигает правого края, существо делает ход и та сбрасывается.
Самое замечательное в этой системе то, что она применима ко всем существам. Некоторые игровые движки в главном цикле обновляют игрока отдельно от монстров, но это сильно усложняет реализацию механики скорости. Когда герой обрабатывается так же, как и любое другое существо, возможность сделать его быстрее или медленнее получается сама собой.
Одна проблема с героями
В большинстве случаев работа с героем, как с любым другим существом, работает хорошо. Но тут есть одна вещь, делающая их уникальными - ими управляет игрок. Рассмотренный ранее игровой цикл отлично работает до тех пор, пока существа генерируют действия сами. Это так только для ботов, а вот герой не может читать ваши мысли через компьютер и действовать на их основе. Ему нужен пользовательский ввод.
У нас уже есть два ограничения, делающие эту задачу непростой:
-
Из-за того, что мы хотим полное разделение движка и интерфейса, главный цикл не может напрямую обращаться к коду обработки инпутов.
-
Поскольку игра запускается в браузере, она не может блокировать ожидание пользовательского ввода. Браузер не работает по таким правилам. Вы должны всегда возвращаться к циклу событий для получения сигналов об очередном вводе.
Когда игровой цикл доходит до героя и пытается получить действие, он не может просто остановить игру и ждать нажатия кнопки. Вместо этого мы позволим интерфейсу внедрять инпуты прямо в игру. Код обработки событий может создавать действия из ничего и нагло впихивать их в движок, что то типа такого:
void handleInput(Keyboard keyboard) {
switch (keyboard.lastPressed) {
case KeyCode.G:
game.hero.setNextAction(new PickUpAction())
break;
case KeyCode.I: walk(Direction.NW); break;
case KeyCode.O: walk(Direction.N); break;
case KeyCode.P: walk(Direction.NE); break;
case KeyCode.K: walk(Direction.W); break;
case KeyCode.L: walk(Direction.NONE); break;
case KeyCode.SEMICOLON: walk(Direction.E); break;
case KeyCode.COMMA: walk(Direction.SW); break;
case KeyCode.PERIOD: walk(Direction.S); break;
case KeyCode.SLASH: walk(Direction.SE); break;
}
}
void walk(Direction dir) {
game.hero.setNextAction(new WalkAction(dir));
}
Вызов setNextAction()
записывает переданное действие в соответствующую переменную героя. Когда главный цикл спрашивает героя, что тот хочет сделать, он просто выплевывает циклу это действие обратно:
class Hero extends Actor {
Action _nextAction;
void setNextAction(Action action) {
_nextAction = action;
}
Action getAction() {
var action = _nextAction;
// Only perform it once. [выполняется только один раз]
_nextAction = null;
return action;
}
// Other heroic stuff... [прочие героические штуки]
}
(В реальной игре на самом деле существует определенный уровень для обработки многоступенчатого поведения, такого как бег, но здесь мы это пропустим.)
Это освобождает движок от необходимости обращаться к интерфейсу, и позволяет ему передавать действия во время простаивания. Есть только одна проблема, возникающая, когда движок требует с героя действия, а UI еще не передал его.
Чтобы это учесть, цикл может просто проверять, нет может ли существо выдать действие. Когда это происходит, он отключается и возвращает управление обратно интерфейсу.
void process() {
var action = actors[_currentActor].getAction();
// Don't advance past the actor if it didn't take a turn.
// [не пропускает существо, если то не сделало ход]
if (action == null) return;
action.perform();
_currentActor = (_currentActor + 1) % actors.length;
}
Если интерфейс говорит движку идти дальше, но еще не передал действие герою, тот просто ничего не делает и возвращает управление обратно UI. Обратите внимание, что setNextAction()
может быть вызван в любое время. Такой способ отлично сочетается с системой скорости, притом UI не нужно ничего контролировать. Он просто передает действие героя в движок и говорит ему идти дальше. Движок же заботится о том, чтобы очередь двигалась только в тогда, когда это возможно.
Как вариант, о чем я сейчас подумал, это если у вас есть несколько героев, управляемых разными игроками, то такой способ сработает и тут. Инпуты могут прилетать к движку по сети, но ему все равно. Четко сработано.
С учетом визуальных эффектов взаимодействие с браузерным циклом событий будет немного сложнее, но, пожалуй, я оставлю это на другой раз. А сейчас давайте посмотрим на ранее не освещенные места движка.
Люди имеют свойство ошибаться
Мы зашли уже достаточно далеко. Мы добились того, что цикл делает то, что он должен делать, так что уже можно подумать о том, чтобы сделать игру немного более удобной. Под удобством здесь подразумевается возможность ошибаться. Игроки ошибаются, и игра должна учитывать это.
Например, пускай игрок пытается сделать шаг в стену. Сейчас это создаст действие движения и, когда оно будет обработано, игрок останется на месте, но ход уже будет пропущен. Если герой пытается убежать от мерзкой твари, такая ошибка может стоить ему жизни. Многие игры забили на это, но я не хочу, чтобы подобное наказывалось. Рогалики и без этого безжалостны.
Демки до сих пор работали именно так. Попробуйте вернуться и побегать в стены [см. оригинал]. Видите, как смертельные монстры приближаются к вам, пока вы бьетесь о стены? Это как раз то, что нужно исправить. Когда игрок пытается совершить невозможное действие, мы должны быть уверены, что это не потратит его ход.
Валидация действий в интерфейсе - один из способов сделать это. Во время обработки инпутов мы проверяем тайл, на который игрок хочет переместиться и убеждаемся, что это пол. Иначе интерфейс выдает ошибку и не посылает действие в игру. С точки зрения движка, он получает только проверенные, корректные действия.
Но создание подобной валидации это весьма непростая задача. Может именно сейчас герой выпил зелье и способен ходить сквозь стены. Может тайл занят чем-то, но герой может через это протиснуться при наличии лопаты.
То что я сейчас описал - это игровые механики, и они должны относиться к движку. В частности, многие из них предстают в виде действий. В них мы поместим решение и этой проблемы. Когда действие выполняется, мы дадим ему возможность возвращать значение, представляющее его результат (успех/провал). Если действие не выполнилось, игровой цикл решит не передавать ход следующему существу. Как-то так:
oid process() {
var action = actors[_currentActor].getAction();
if (action == null) return;
var success = action.perform();
// Don't advance if the action failed. [не продвигает очередь в случае неудачи]
if (!success) return;
_currentActor = (_currentActor + 1) % actors.length;
}
Это делает движок более устойчивым: вы можете дать ему любое действие и быть уверенными, что он обработает его корректно. Также такой метод собирает в кучу весь код для нужной механики, включая валидацию. Слава инкапсуляции!
А теперь, попробуйте побиться о стены снова:
[ Как было ]
Делай не что сказано, а что нужно
Успех/неудача подразумевают, что действия могут быть только верными или нет, хотя лучше было бы иногда решать, что игрок имел в виду на самом деле. Например, если вы попробуете переместиться на тайл с дверью, логично было бы предположить, что вы пытаетесь ее открыть именно таким образом, вместо использования отдельной кнопки. Точно так же, если вы идете на тайл с монстром, было бы правильно начать сражение.
Я знаю, это звучит очевидно, но вы удивитесь, узнав, сколько рогаликов упускают этот момент. Одна из моих целей - это делать игру более удобной, так что я забочусь об этом. Еще я придумал достаточно простое решение для этого.
Когда действие проверяет самого себя, оно может просто сразу провалится, но так же может предложить альтернативное действие. Оно буквально говорит: "не, на самом деле ты имел в виду это".
Учитывая, что у Action
метод perform()
может возвращать успех, неудачу или другое действие, нам нужно сделать небольшой класс-обертку для этого:
class ActionResult {
static const success = ActionResult(true);
static const failure = ActionResult(false);
/// An alternate [Action] that should be performed instead of
/// the one that failed.
final Action alternative;
/// `true` if the [Action] was successful and energy should
/// be consumed.
final bool succeeded;
const ActionResult(this.succeeded)
: alternative = null;
const ActionResult.alternate(this.alternative)
: succeeded = true;
}
Когда действие выполнится, оно может вернуть ActionResult.success
чтобы показать, что все нормально, ActionResult.failure
, чтобы показать, что ничего делать не нужно, или ActionResult
с .alternate
переменной, которая будет ссылаться на новое действие, которое нужно выполнить вместо текущего.
А игровой цикл просто смотрит на возвращаемое значение:
void process() {
var action = actors[_currentActor].getAction();
if (action == null) return;
while (true) {
var result = action.perform();
if (!result.succeeded) return;
if (result.alternate == null) break;
action = result.alternate;
}
_currentActor = (_currentActor + 1) % actors.length;
}
Мы сделали это в цикле, потому что альтернативное действие само может возвращать другое действие, так что мы должны идти до того момента, пока не наткнемся на успех или неудачу. Это оказывается весьма удобной фичей сразу в нескольких местах:
-
Когда вы используете предмет, действие "использовать предмет" смотрит, что этот предмет делает (пускает фаербол, телепортирует и т.п.) и возвращает соответствующее действие в качестве альтернативного. Когда вы "используете" носимый предмет, взамен получите действие "надеть предмет".
-
Если существо "двигается" без направления, значит оно отдыхает и возвращает действие "отдохнуть".
-
Если существо двигается в дверь, значит возвращается действие "открыть дверь".
-
Если существо двигается в другое, значит вы получаете действие "атаковать в ближнем бою".
Последние три особенно хороши, потому что они применимы и к монстрам. Искусственный интеллект монстров может не проверять, что там на тайле такое. Вместо этого он просто ведет монстра куда надо и система действий сама открывает двери или атакует героя, когда тот настиг его. Если монстр не понимает, куда идти, он автоматически отдыхает.
Поверьте мне, все, что вы можете сделать для упрощения кода ботов - это хорошая идея. Ладно, хорош болтовни, давайте посмотрим на это в действии:
Конец... или нет?
Вот дрянь, я уже скормил вашему вниманию три тысячи слов, но еще осталось столько крутого для обсуждения! Я, должно быть, разделю это на две части.
То, что имеем на данный момент, - это достаточно полноценный (естественно, на мой взгляд) игровой цикл. Определение поведения в действиях позволяет нам обрабатывать подконтрольных игроку персонажей и монстров совершенно по одним и тем же правилам. У нас есть гибкая система скорости, и все это работает отдельно от пользовательского интерфейса.
Если вы делаете относительно простой рогалик, этого наверняка должно хватить.
Однако, так как рогалики в ASCII стиле не славятся своей зрелищностью, я хочу сделать что-нибудь более стильное, чем то, что я тут сейчас описывал. Я хочу, чтобы игрок видел траекторию стрелы, пересекающей комнату перед тем как вонзиться в мясистое лицо тролля. Я хочу, чтобы огненные шары вспыхивали в кольце смерти. И когда огонь коснется горящих предметов, лежащих на полу, они должны вызвать пожар.
Игровой цикл в моей игре может подобное. Когда я найду время для создания демок и написания большого количества текста, я покажу вам, как это сделано. Ну а пока, код игры тут, код демок тут.
Если хотите попробовать игру, это можно сделать здесь.
Автор: Никита