Программирование интерактивных сценариев. Часть 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 управляет глобальным контекстом.

Для реализации подклассов CommandBuilderStep удобно иметь систему уведомлений о событиях (паттерн Observer) в том или ином виде (signal/slot, listeners и т.д.), чтобы уведомлять подклассы 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), чтобы награждать его за успешное решение задач или давать подсказки, если он делает что-то неправильно. Все эти строительные блоки – атомы вашей игры – удобно тестировать по отдельности. Вместе же они образуют богатый "алфавит поведения".

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


2014, 27 Марта
Начало
2009-2024 © Павел Гуданец