Оглавление
- Часть 1.
- Часть 2.
Драйверы ^
Персонаж не может произносить две разные реплики одновременно, если только это не двухголовый Зафод Библброкс. Но даже сей почтенный джентельмен не может бежать сразу в двух противоположных направлениях.
Драйвер – это действие (см. класс Action), которое монопольно управляет игровым объектом в том или ином аспекте его поведения.
Допустим, поведение персонажа смоделировано с помощью конечного автомата. Тогда состояние персонажа – это драйвер, поскольку конечный автомат не может находиться одновременно в двух разных состояниях.
Так, благодаря концепции драйвера, систему сценариев удобно использовать для программирования стратегий поведения (AI).
Драйвер может управлять не только игровым объектом, но и целой подсистемой игрового движка (например, проверять физические столкновения или обрабатывать пользовательский ввод). Поэтому лучше абстрагироваться от деталей и сказать, что драйвер управляет некоторым контекстом.
DriverManager ^
Когда начинается действие "персонаж A идет к цели T", надо проверить, не идет ли персонаж уже куда-либо, и если да – заменить действие.
Создадим класс DriverManager, который будет хранить знание о драйверах – троицу (contextId, activityId, actionId), где
- contextId – id управляемого игрового объекта или любой другой управляемый контекст;
- activityId – аспект, которым управляет драйвер ("move", "say" и т.д.);
- actionId – id драйвера.
ag.DriverManager = {
GLOBAL_CONTEXT: -1, // default context
drivers: {},
_preprocessContext: function(ctx)
{
return ctx ? ctx : this.GLOBAL_CONTEXT;
},
/**
* register driver (actionId) for specific activity (activityId)
* on specific context (ctx)
* if there is a registered driver already, remove it first
*/
setSafe: function(ctx, activityId, actionId)
{
ctx = this._preprocessContext(ctx);
if (!this.drivers[ctx])
this.drivers[ctx] = {};
var prevActionId = this.drivers[ctx][activityId];
if (prevActionId && prevActionId !== actionId)
{
var res = ag.ScenarioManager.remove(prevActionId);
if (!res)
return false;
}
this.drivers[ctx][activityId] = actionId;
return true;
},
/**
* register driver (actionId) for specific activity (activityId)
* on specific context (ctx)
* only if there is no registered driver yet
*/
setOnce: function(ctx, activityId, actionId)
{
ctx = this._preprocessContext(ctx);
if (!this.drivers[ctx])
this.drivers[ctx] = {};
var prevActionId = this.drivers[ctx][activityId];
if (prevActionId === actionId)
{
return false;
}
this.drivers[ctx][activityId] = actionId;
return true;
},
/**
* remove current action (actionId) for specific activity (activityId)
* on specific context (ctx)
* with check for action id
*/
removeSafe: function(ctx, activityId, actionId)
{
ctx = this._preprocessContext(ctx);
if (!this.drivers[ctx])
return false;
var prevActionId = this.drivers[ctx][activityId];
if (prevActionId && prevActionId===actionId)
{
delete this.drivers[ctx][activityId];
return true;
}
return false;
},
removeAll: function(ctx)
{
ctx = this._preprocessContext(ctx);
delete this.drivers[ctx];
},
getAll: function(ctx)
{
ctx = this._preprocessContext(ctx);
return this.drivers[ctx];
},
get: function(ctx, activityId)
{
ctx = this._preprocessContext(ctx);
if (!this.drivers[ctx])
return null;
return this.drivers[ctx][activityId];
}
};
Обратите внимание: если contextId для драйвера не указан, то используется глобальный контекст.
Добавляем новый метод в ScenarioManager:
ag.ScenarioManager = {
// ...
removeDriver: function(ctx, activityId)
{
var actionId = ag.DriverManager.get(ctx, activityId);
if (actionId && this.remove(actionId))
{
ag.DriverManager.remove(ctx, activityId);
return true;
}
return false;
}
};
Клиентский код никогда напрямую не работает с DriverManager. Использование DriverManager – деталь реализации класса ScenarioManager и классов драйверов.
Пример драйвера: Say ^
В качестве примера – драйвер Say, который показывает на экране реплику персонажа:
ag.textSpeed = 70; // ms per symbol
ag.Say = ag.Action.extend({
actorId: null, // actor's id (the one who says)
str: null, // text to say
until: null, // timestamp (ms) until text must be shown
node: null, // visual representation of text
ctor: function(actorId, str)
{
this._super();
this.actorId = actorId;
this.str = str;
},
run: function()
{
var now = ag.getCurrentTimeInMs();
if (this.until === null) // first run, init everything
{
var wait = this.str.length * ag.textSpeed;
this.until = now + wait;
// create node – visual representation of text
// ...
// register driver
if (!ag.DriverManager.setSafe(this.actorId, "say", this.getId()))
return ag.DONE;
return ag.OK;
}
if (now > this.until)
{
return ag.DONE;
}
this.updateTextPosition();
return ag.OK;
},
updateTextPosition: function()
{
// ...
},
dtor: function()
{
if (this.node)
this.node.remove();
// remove driver
ag.DriverManager.removeSafe(this.actorId, "say", this.getId());
this._super();
},
getConfig: function()
{
return {type:"Say", id:this.id, ctor:[this.actorId, this.str]};
}
});
Пример драйвера: DriverWrapper ^
Управляющие структуры (Sequence, Parallel и т.д.), конечно, не являются драйверами. Они могут содержать в себе любые действия, в том числе и драйверы, которые управляют разными контекстами.
Иногда, впрочем, требуется гарантировать единственность выполнения какого-либо сценария, привязав его к определенному контексту. Для этого можно использовать действие DriverWrapper – оно просто обрамляет любое действие, регистрируя его как драйвер.
ag.DriverWrapper = ag.Action.extend({
ctx: null,
activityId: null,
action: null,
first: true,
ctor: function(ctx, activityId, action)
{
this._super();
this.ctx = ctx;
this.activityId = activityId;
this.action = ag.ScenarioManager.toAction(action);
},
run: function()
{
if (this.first)
{
if (!ag.DriverManager.setSafe(this.ctx, this.activityId, this.getId()))
return ag.DONE;
this.first = true;
}
return this.action.run();
},
dtor: function()
{
ag.DriverManager.removeSafe(this.ctx, this.activityId, this.getId());
this.action.dtor();
this._super();
},
getConfig: function()
{
return {type:"DriverWrapper", id:this.id, ctor:[this.ctx, this.activityId, this.action.getConfig()]};
}
});
Запрос на замену драйвера ^
Заменяемый драйвер может быть не готов тут же завершиться и передать управление новому драйверу. Например, персонаж отстреливается, чтобы спасти свою жизнь, а в этот момент поступает предложение перевести его в idle-состояние "любоваться бабочками".
Было бы хорошо дать новому и заменяемому драйверам возможность "поговорить", чтобы они решили между собой, кто приоритетнее, и в каком порядке один передаст управление другому. Для этого DriverManager должен хранить не actionId, а сам action. Замена драйвера принимает следующий вид:
ag.AGREE = 1;
ag.DENY = 2;
ag.Action = Class.extend({
// ...
onReplaceRequest: function(action)
{
return ag.AGREE; // default response
}
});
ag.Say = ag.Action.extend({
// ...
onReplaceRequest: function(action)
{
if (this.str === action.str)
return ag.DENY; // saying the same line, do not "restart" action
else
return ag.AGREE;
}
});
ag.DriverManager = {
// ...
setSafe: function(ctx, activityId, action)
{
ctx = this._preprocessContext(ctx);
if (!this.drivers[ctx])
this.drivers[ctx] = {};
var prevAction = this.drivers[ctx][activityId];
if (prevAction && prevAction.getId() !== action.getId())
{
var replaceRes = prevAction.onReplaceRequest(action);
if (replaceRes !== ag.AGREE)
return false;
var removeRes = ag.ScenarioManager.remove(prevAction.getId());
if (!removeRes)
return false;
}
this.drivers[ctx][activityId] = action;
return true;
}
};
Сценарии взаимодействия игрока с пользовательским интерфейсом ^
Во многих играх команда игрока определяется шаг за шагом с помощью пользовательского интерфейса. То есть является предложением, синтаксической структурой. Например, определение команды "Give A to B" состоит из последовательного выбора глагола и двух игровых объектов. "Move unit C to (x,y)" – выбор глагола, объекта и координат.
Базовый класс Command может быть реализован как стек аргументов. Какие аргументы надо добавить на стек, сколько их придет и в каком порядке – всего этого класс Command не знает. Это детали реализации его подклассов. Например, CommandGive знает, что должны быть выбраны два игровых объекта. CommandMove знает, что потребуются один объект и координаты.
Команда не должна строить саму себя. Ей и без того хватает хлопот. Необходим некий command builder – линейный конвейер (для заполнения стека команды) или, в общем случае, конечный автомат. Его типичные состояния: "выбрать игровой объект", "выбрать координаты", "сообщить команде о том, что ее строительство завершено". Другими словами, command builder состоит из типичных строительных блоков, стало быть, сам command builder должен быть кем-то построен.
Структура команды – деталь ее реализации. Поэтому только команда знает, в каком порядке и какие строительные блоки должны быть добавлены в command builder. Именно команда строит свой персональный command builder... который затем построит эту команду.
// Command builder is built by a command, because command needs to be built by it. :)
CommandBuilderStep – базовый класс для упомянутых выше строительных блоков в command builder. Подклассы CommandBuilderStep не знают, команду какого именно типа они строят. Всё, что им надо знать – это общий интерфейс Command.
Command builder определяет сценарий взаимодействия игрока с пользовательским интерфейсом. Конечно, CommandBuilderStep можно реализовать независимо от системы сценариев, как отдельную иерархию классов. Но мне нравится идея, что в сценарии можно встраивать интерактивные элементы. Мы видим подобное в кинематографических играх Fahrenheit (2005), Heavy Rain (2010) и Walking Dead (2012). Подклассы CommandBuilderStep могут делать что-то на каждом тике системы, задействовав метод CommandBuilderStep.run(). И у нас уже есть готовый ScenarioManager, который отвечает за выполнение сценариев. По этим причинам CommandBuilderStep наследует от класса Action.
CommandBuilderStep – это драйвер. Его особенность в том, что он управляет не игровым объектом, а пользовательским интерфейсом. Для этого случая в DriverManager предусмотрен глобальный контекст (GLOBAL_CONTEXT). Будем считать, что CommandBuilderStep управляет глобальным контекстом.
В качестве иллюстрации всего сказанного – класс CommandMove:
ag.CommandMove = ag.Command.extend({
actorId: null,
pos: null,
getVerb: function() { return "walk"; },
getObject: function(i) { return this.actorId; },
getPosition: function(i) { return this.pos; },
addObject: function(actorId) { this.actorId = actorId; },
addPosition: function(pos) { this.pos = pos; },
getObjectCount: function() { return 1; },
getPositionCount: function() { return 1; },
createBuilder: function()
{
return new ag.CommandBuilderSequence(
new ag.CommandBuilderSelectActor(this),
new ag.CommandBuilderSelectPosition(this),
new ag.CommandPerform(this)
);
},
perform: function()
{
this._super();
ag.ScenarioManager.add(
ag.walkTo(this.actorId, this.pos)
);
},
getConfig: function()
{
return {type:"CommandMove", data:{actorId:this.actorId, pos:this.pos}};
}
});
Алгоритм построения команды:
- В методе CommandMove.createBuilder() создается сценарий построения этой команды (command builder).
- CommandBuilderSelectActor ожидает, когда игрок выберет персонажа, затем вызывает CommandMove.addObject(objectId).
- CommandBuilderSelectPosition ожидает, когда игрок выберет место назначения, затем вызывает CommandMove.addPosition(pos).
- CommandPerform вызывает CommandMove.perform().
- CommandMove.perform() запускает сценарий – движение персонажа.
Логический вывод сценария ^
Итак, игрок сформулировал команду. Какой сценарий в результате запустить? Обычно это зависит от выбранных игроком объектов и координат. Требуется multiple dispatch в том или ином виде. Возможен логический вывод сценария на основании набора правил RuleList. Старый-добрый Prolog:
Run(Scenario1) :- CommandGive("bandit", "life").
Run(Scenario2) :- CommandGive("bandit", "wallet").
Run(Scenario3) :- CommandGive("bandit", "uppercut").
% ...
Рассмотрим реализацию RuleList:
ag.Rule = Class.extend({
command: null,
onPerformFunc: null,
ctor: function(command, onPerformFunc)
{
this.command = command;
this.onPerformFunc = onPerformFunc;
},
match: function(command)
{
return this.command.compare(command);
},
perform: function(command)
{
return this.onPerformFunc(command);
}
});
ag.RuleList = Class.extend({
rules: null,
ctor: function()
{
this.rules = [];
},
addRule: function(command, onPerformFunc)
{
this.rules.push(new ag.Rule(command, onPerformFunc));
},
match: function(command)
{
if (!command)
return null;
var rules = this.rules;
for (var i = 0; i < rules.length; ++i)
{
var rule = rules[i];
if (rule.match(command))
return rule;
}
return null;
}
};
ag.CommandGive = ag.Command.extend({
// ...
perform: function()
{
this._super();
var ruleList = ag.getRuleList();
var rule = ruleList.match(this);
if (rule)
rule.perform(this);
}
});
Правила удобно создавать с помощью абстрактных команд ("Give A to Anybody", "Give Anything to B"), предусмотрев в методе Command.compare(command) сравнение со значением ANY.
Распознавание паттернов поведения игрока ^
Мы рассмотрели, как игрок определяет одну команду и взаимодействует с пользовательским интерфейсом на уровне отдельных кликов. Так, фраза за фразой, клик за кликом, игрок строит историю игрового мира. Сменим масштаб. Теперь мы хотим распознать не отдельную фразу, а целый рассказ. Отследим выполнение определенной цепочки команд, паттерн поведения. Для чего это может быть полезно?
- Игрок прошел обучение (tutorial) – необходимо отследить выполнение всех шагов и наградить игрока.
- Игрок пытается сделать что-то неправильное – мы хотим угадать его намерения и дать подсказку.
Есть два подхода, как это можно сделать:
- Каждой последовательности, которая может быть выведена игроком, соответствует свой сценарий. Все эти сценарии работают одновременно и независимо друг от друга.
- Все возможные последовательности образуют единую грамматику поведения игрока, и ей соответствует один-единственный сценарий (синтаксический анализатор), управляемый множеством правил вывода. Каждая команда игрока рассматривается как терминальный символ данной формальной грамматики. За распознавание отдельного символа отвечает актуальное действие (Action) в сценарии. Разбор нетерминальных символов может быть реализован с помощью техники REPLACE_IMPL, описанной в первой части этой статьи. Подробнее о математической теории формальных систем - см. здесь.
Послесловие ^
Выразительная мощь системы сценариев позволяет решить многие типичные задачи программирования игр – в рамках единого, концептуально простого подхода.
Добавляя новые строительные блоки – действия и драйверы – можно создавать игровые заставки (cutscenes) и анимации движения (motion tween), моделировать стратегии поведения персонажей (AI), отслеживать сценарии взаимодействия игрока с пользовательским интерфейсом (command builders), а также распознавать паттерны поведения игрока (behaviour patterns), чтобы награждать его за успешное решение задач или давать подсказки, если он делает что-то неправильно. Все эти строительные блоки – атомы вашей игры – удобно тестировать по отдельности. Вместе же они образуют богатый "алфавит поведения".
Система сценариев является надстройкой над всеми подсистемами игрового движка, которые могут о ней вообще не знать, в то время как она дирижирует всем в игре происходящим.