Программирование интерактивных сценариев. Часть 1

Оглавление

Введение ^

Поговорим о создании интерактивных сценариев для компьютерных игр – под этим я понимаю:

  • игровые заставки (cutscenes);
  • анимации движения (motion tween);
  • стратегии поведения персонажей (AI);
  • сценарии взаимодействия игрока с пользовательским интерфейсом (например, определение команды "give A to B" состоит из выбора глагола и двух объектов);
  • обучающие последовательности (tutorials);
  • распознавание паттернов поведения игрока (например, игрок пытается сделать что-то неправильное – мы хотим распознать его намерения и дать подсказку).

Все эти задачи мы решим в рамках единого, концептуально простого подхода, который представлен в этой статье. Подход отвечает следующим пожеланиям:

  • Локальность. Чтобы сценарий было удобно читать, редактировать и тестировать, описание сценария должно быть локализованным, а не "размазанным" по различным подсистемам игрового движка (таким как обработка пользовательского ввода, игровая логика, анимация, искусственный интеллект и т.д.)
  • Неинтрузивность. Игровые объекты и подсистемы игрового движка ничего не знают о системе сценариев, зато сценарии ими управляют.
  • Расширяемость. Возможность добавлять новые строительные блоки сценария, обогащая "алфавит поведения".
  • Отложенные вычисления. Создание (декларация) и выполнение сценария могут быть разделены во времени.
  • Динамическое создание. Сценарий может быть создан "на лету", с вариацией деталей. В самом общем виде, динамический вывод сценариев как предложений произвольной формальной грамматики.
  • Параллельные вычисления. Выполнение сценария происходит асинхронно; возможно одновременное выполнение многих сценариев.
  • Лаконичность. Синтаксис определения сценария желательно сделать лаконичным и легковесным, попутно скрыв из интерфейса лишние технические детали. Это облегчит работу сценаристов.

Примеры кода написаны на JavaScript, который я часто использую для быстрого прототипирования.

Система сценариев ^

В каждой игре можно обнаружить базовые строительные элементы, "атомы поведения", из которых строится весь игровой процесс. Например:

  • персонаж идет;
  • персонаж говорит;
  • персонаж получает предмет в свой инвентарь;
  • включается музыка;
  • спец-эффект "вспышка";
  • NPC переходит в новое состояние;
  • управление выключается (началась заставка);
  • управление возвращается к игроку (закончилась заставка);
  • и т.д.

Типичная игровая заставка (cutscene) сочетает такие события и процессы, но важно заметить, что они относятся к разным подсистемам игры. При наивном подходе описание заставки расползается по разным подсистемам. Отследить логику одного сценария, а также добавить новый в таком спагетти-коде крайне проблематично.

Вот идея получше: система сценариев – это надстройка над всеми игровыми подсистемами, которая ими управляет (через их интерфейсы). Все модули игры ничего не знают о системе сценариев, таким образом, достигается полностью неинтрузивный подход.

Исходный код ^

Для тех читателей, кто предпочитает видеть исходный код сразу и целиком – простой пример. Персонаж ходит, говорит и собирает инвентарь – всё это сделано в виде сценариев.

Взять файл scenario_system.js

Базовый класс Action ^

Сценарий состоит из действий (процессов, задач), которые совершаются последовательно или одновременно. Все действия наследуются от класса Action:


ag.OK = 1;
ag.DONE = 2;

ag.lastActionId = 0;

ag.Action = Class.extend({

	id: null,

	ctor: function()					// constructor
	{
		this.id = ++ag.lastActionId;	// auto-generate id
	},

	dtor: function()					// destructor
	{
	},

	run: function()
	{
		return ag.DONE;
	},

	getId: function()
	{
		return this.id;
	}
});

Метод run() вызывается на каждом витке игрового цикла и возвращает одно из следующих значений:

  • OK. Выполнение задачи не завершено, на следующем тике системы надо снова вызвать run().
  • DONE. Задача выполнена, больше не надо вызывать run().

Некоторые действия имеют продолжительность – например, WalkTo, Say или DramaticHandWringing. Другие выполняются моментально, за один вызов run() – такие как AddToInventory.

Управляющие структуры Sequence и Parallel ^

Последовательность действий – это, в свою очередь, тоже действие (паттерн Composite).

Sequence содержит в себе массив действий и на каждом тике вызывает метод run() первого из них, – до тех пор, пока не получит результат DONE.

Затем Sequence переходит к следующему вложенному действию. Когда завершится последнее из них, Sequence, в свою очередь, вернет результат DONE.


ag.Sequence = ag.Action.extend({

	actions: null,

	ctor: function()
	{
		this._super();
		this.actions = ag.argumentsToArray(arguments);
	},

	dtor: function()
	{
		if (this.actions)
			for (var i = this.actions.length-1; i>=0; --i)
				this.actions[i].dtor();
		this._super();
	},

	run: function()
	{
		if (!this.actions || this.actions.length === 0)
			return ag.DONE;

		var action = this.actions[0];
		var res = action.run();
		if (res === ag.DONE)
		{
			action.dtor();
			this.actions.shift();
			if (this.actions.length === 0)
				return ag.DONE;
		}

		return ag.OK;
	}
});

ag.argumentsToArray = function(args)
{
	if (args.length===1 && ag.isArray(args[0]))
	{
		// return the only argument of type Array as it is
		return args[0];
	}
	else
	{
		// convert object with arguments to array
		return Array.prototype.slice.call(args);
	}
};

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


ag.Parallel = ag.Action.extend({

	actions: null,

	ctor: function()
	{
		this._super();
		this.actions = ag.argumentsToArray(arguments);
	},

	dtor: function()
	{
		if (this.actions)
			for (var i = this.actions.length-1; i>=0; --i)
				this.actions[i].dtor();
		this._super();
	},

	run: function()
	{
		if (!this.actions)
			return ag.DONE;

		for (var i = this.actions.length-1; i>=0; --i)
		{
			var action = this.actions[i];
			var res = action.run();
			if (res === ag.DONE)				// sub action is finished
			{
				action.dtor();
				this.actions.splice(i,1);		// loop backwards, can remove element
			}
		}

		return (this.actions.length === 0) ? ag.DONE : ag.OK;
	}
});

Условные операторы If и DynamicIf ^

Добавим оператор ветвления:


ag.If = ag.Action.extend({

	_true: null,
	_false: null,
	_result: null,

	ctor: function(_true, _false)
	{
		this._super();
		this._true = _true;
		this._false = _false;
	},

	dtor: function()
	{
		if (this._true)
			this._true.dtor();
		if (this._false)
			this._false.dtor();
	},

	_if: function() { return true; },	// override

	run: function()
	{
		if (this._result === null)
		{
			this._result = this._if();
		}

		if (this._result)
		{
			return this._true ? this._true.run() : ag.DONE;
		}
		else
		{
			return this._false ? this._false.run() : ag.DONE;
		}
	}
});
Функция _if будет переопределяться в классах-наследниках. Она не передается в конструктор If.ctor() в качестве аргумента, потому что это усложнило бы сериализацию экземпляров класса (см. далее раздел "Сериализация сценариев").

Добавим "динамический условный переход", который проверяет условие при каждом вызове метода run():


ag.DynamicIf = ag.Action.extend({

	_true: null,
	_false: null,

	ctor: function(_true, _false)
	{
		this._super();
		this._true = _true;
		this._false = _false;
	},

	dtor: function()
	{
		if (this._true)
			this._true.dtor();
		if (this._false)
			this._false.dtor();
	},

	_if: function() { return true; },	// override

	run: function()
	{
		if (this._if())
		{
			return this._true ? this._true.run() : ag.DONE;
		}
		else
		{
			return this._false ? this._false.run() : ag.DONE;
		}
	}
});

Sequence, Parallel, If, DynamicIf – это управляющие структуры, то есть действия, которые определяют поток выполнения сценария. На практике их вполне достаточно, чтобы создавать сложные сценарии. При желании можно добавить и циклы, и switch.

Запуск сценариев: ScenarioManager ^

Кто владеет всеми работающими сценариями и отвечает за их выполнение? Через какой интерфейс происходит запуск новых сценариев, а также замена и удаление действующих? Возможно два подхода:

  • Разделенный. Каждый игровой объект хранит в себе свой собственный сценарий, который локально управляет этим объектом.
  • Централизованный. Существует единственная точка системы, которая владеет всеми действующими сценариями.

Назову преимущества второго подхода:

  • Неинтрузивность (non-intrusive) и уменьшение связанности. Разделенный подход "замыкает" систему сценариев на игровые объекты. Лучше, если игровые объекты ничего не знают о системе сценариев, зато сценарии управляют объектами (а может быть, и не только ими).
  • Множественность. В разделенном подходе один сценарий управляет одним игровым объектом (1:1) или отдельными его компонентами (1:M – много сценариев на объект). Однако сценарий может управлять многими объектами (M:N), и семантически некорректно хранить его в одном объекте. Таким образом, между игровыми объектами и сценариями могут быть отношения 1:1, 1:M или M:N, и мы не хотим накладывать никаких концептуальных ограничений на этот счет. Централизованный подход разделяет объекты и сценарии, решая этот вопрос.
  • Менеджмент памяти. У всех сценариев – один общий владелец. Это упрощает замену и удаление действующих сценариев.
  • Отладка. Все "параллельные" процессы запускаются из одного цикла, а не раскиданы по разным подсистемам.
  • Инкапсуляция. Как реализовано параллельное выполнение сценариев – деталь реализации одного класса.

Роль такого центра будет выполнять ScenarioManager:


ag.ScenarioManager = {

	actions: {},

	add: function(action)
	{
		if (!action)
			return null;

		this.actions[action.getId()] = action;

		return action.getId();
	},

	run: function()
	{
		for (var i in this.actions)
		{
			if (!this.actions.hasOwnProperty(i)) continue;

			var action = this.actions[i];
			if (!action)
				continue;

			var res = action.run();
			if (res === ag.DONE)	// sub action is finished
			{
				action.dtor();
				delete this.actions[ action.getId() ];
			}
		}
	},

	remove: function(actionId)
	{
		var action = this.actions[actionId];
		if (!action)
			return false;

		action.dtor();
		delete this.actions[actionId];

		return true;
	}
};
Концептуально ScenarioManager – класс-синглетон, но для простоты примера я реализовал его в виде объекта в JSON-нотации.

По сути, ScenarioManager – тот же Parallel, но с возможностью динамически добавлять, заменять и удалять в нем вложенные действия. Кроме того, ScenarioManager – синглетон.

В Parallel может быть удалено только то вложенное действие, на которое сейчас указывает итератор, поэтому используется индексный массив. В ScenarioManager в любой момент может быть удалено любое вложенное действие по его id, поэтому используется ассоциативный массив, а также цикл for-in в методе ScenarioManager.run().
Стандарт ECMAScript 5.1, раздел 12.6.4 ("The for-in Statement"): "Properties of the object being enumerated may be deleted during enumeration. If a property that has not yet been visited during enumeration is deleted, then it will not be visited".

Пример: Диалоги персонажей ^

Рассмотрим создание игровых диалогов. Диалог (Dialogue) состоит из множества состояний (DialogueState), в каждом из которых игрок может выбрать одну из предложенных реплик (DialogueOption). При выборе реплики запускается сценарий, например:


// ...
dialogueOption.onSelect(function(){
	ag.ScenarioManager.add(
		new ag.Sequence(
			new ag.Say("hamlet", "Дай взгляну."),
			new ag.WalkTo("hamlet", xy(250,300) ),
			new ag.AddToInventory("hamlet", "skull"),
			new ag.Say("hamlet", "Бедный Йорик! - Я знал его, Горацио."),
			new ag.Say("hamlet", "Это был человек бесконечного остроумия, неистощимый на выдумки."),
			new ag.Say("hamlet", "Он тысячу раз таскал меня на спине."),
			// ...
			new ag.DiaEnd()	// end of dialogue here
		)
	);
});

Для создания диалогов пригодятся следующие действия:

  • DiaState(dialogueId, stateId). Перевести диалог dialogueId в состояние stateId.
  • DiaShowOption(dialogueId, optionId). Сделать доступной для выбора реплику optionId в диалоге dialogueId.
  • DiaHideOption(dialogueId, optionId). Скрыть реплику optionId в диалоге dialogueId.
  • DiaEnd. Завершить диалог.

Дружелюбный синтаксис ^

Хорошо бы в декларации сценария "спрятать" навязчивый оператор new, чтобы получился лаконичный, легковесный синтаксис.


ag.parallel = function() { return new ag.Parallel(ag.argumentsToArray(arguments)); };
ag.sequence = function() { return new ag.Sequence(ag.argumentsToArray(arguments)); };
ag.say = function(actorId, str) { return new ag.Say(actorId, str); };
ag.diaEnd = function() { return new ag.DiaEnd(); };
// ...

Теперь создавать сценарии – одно удовольствие:


dialogueOption.onSelect(function(){
	ag.ScenarioManager.add(
		ag.sequence(
			ag.say("hamlet", "Дай взгляну."),
			ag.walkTo("hamlet", xy(250,300) ),
			ag.addToInventory("hamlet", "skull"),
			ag.say("hamlet", "Бедный Йорик! - Я знал его, Горацио."),
			ag.say("hamlet", "Это был человек бесконечного остроумия, неистощимый на выдумки."),
			ag.say("hamlet", "Он тысячу раз таскал меня на спине."),
			// ...
			ag.diaEnd()	// end of dialogue here
		)
	);
});

Итак, оператор new исчез. Также обратите внимание, что мы минимизировали виды используемых скобок: (), {}, [], <>. В декларации сценария используется только (). Скрыв технические детали, мы облегчили работу сценаристов: они не запутаются в разных "частях речи" используемого языка программирования – вот-де конструктор new (), вот вызов функции (), вот массив [] и т.д. Создание структуры сценария на императивном языке программирования "мимикрирует" под декларативный стиль. Я считаю это важным принципом при проектировании интерфейса, с которым, скорее всего, будут работать не только программисты.

Сериализация сценариев ^

Наверное, кто-то из читателей помнит: в классических адвенчурах LucasArts можно было сохранить игру в любой момент, даже во время заставки, а в играх Sierra On-Line – нельзя, надо было дождаться, когда заставка завершится. Давайте добавим в систему сценариев удобную возможность сохранения, как у LucasArts. Для этого потребуется сериализация/десериализация сценариев. Воплотим ее в такой манере:


ag.Action = Class.extend({
	// ...
	getConfig: function()
	{
		return {type:"Action", id:this.id, ctor:[]};
	}
});

ag.Say = ag.Action.extend({
	// ...
	getConfig: function()
	{
		return {type:"Say", id:this.id, ctor:[this.actorId, this.str]};
	}
});

ag.Sequence = ag.Action.extend({
	// ...
	getConfig: function()
	{
		var actions = [];
		for (var i = 0, l = this.actions.length; i < l; ++i)
			actions.push(this.actions[i].getConfig());

		return {type:"Sequence", id:this.id, ctor:actions};
	}
});
Для удобства сериализации/десериализации желательно, чтобы объект не содержал переменных типа Function, как в случае с классами If и IfDynamic. Там функция, вычисляющая условие перехода, не хранится в объекте, а переопределяется в классе-наследнике.

Добавим в ScenarioManager методы десериализации:


ag.ScenarioManager = {
	// ...

	_createInstance: function(constructor, args)
	{
		function F()
		{
			return constructor.apply(this, args);
		}
		F.prototype = constructor.prototype;
		return new F();
	},

	createByConfig: function(conf)
	{
		var Type = agd[ conf.type ];
		if (!Type)
		{
			return null;
		}

		var action = this._createInstance(Type, conf.ctor);

		action.id = conf.id;
		
		// validate action.id generator, see ag.Action.ctor() for more information
		if (ag.actionId < action.id)
			ag.actionId = action.id;

		return action;
	},

	toAction: function(action)
	{
		if (ag.isFunction(action.run))
			return action;						// it is an action already
		else
			return this.createByConf(action);	// create action by config
	},
};

Осталось переписать конструкторы классов, которые получают в качестве аргументов вложенные действия (Sequence, Parallel, If, DynamicIf):


ag.If = ag.Action.extend({

	ctor: function(_true, _false)
	{
		this._super();
		this._true = ag.ScenarioManager.toAction(_true);
		this._false = ag.ScenarioManager.toAction(_false);
	}

	// ...
});

ag.Sequence = ag.Action.extend({

	ctor: function()
	{
		this._super();
		this.actions = ag.argumentsToArray(arguments);

		for (var i = 0, l = this.actions.length; i < l; ++i)
			this.actions[i] = ag.ScenarioManager.toAction(this.actions[i]);
	}
	
	// ...
});

Результат метода run() ^

Помимо простых значений OK и DONE иногда удобно возвращать из метода Action.run() более хитроумные значения:

  • TERMINATE. Если вложенное действие вернуло этот результат, то Sequence и Parallel также должны немедленно завершиться, даже если в них остались другие незавершенные вложенные действия.
  • REPLACE_IMPL. Если вложенное действие вернуло этот результат, то это действие должно быть заменено.

Рассмотрим обработку результата REPLACE_IMPL на примере класса Parallel:


	for (var i = this.actions.length-1; i>=0; --i)
	{
		var action = this.actions[i];
		var res = action.run();
		if (res === ag.REPLACE_IMPL)
		{
			this.actions[i] = action.replaceImplementation();
		}
		// ...
	}

REPLACE_IMPL – весьма мощный механизм, хитрый способ динамически менять части сценария "изнутри".

Например, это удобно, если действие – состояние конечного автомата. Вернув результат REPLACE_IMPL, состояние сообщает, что оно завершено. Логика выбора следующего состояния локализована в методе Action.replaceImplementation().

Другой случай: контекстно-зависимые действия. Например, действие WalkTo не умеет ходить по определенному виду ландшафта. Зато оно может поручить это своему специализированному подклассу WalkByWater. Человек, который строит сценарий, может даже не знать, что есть разные имплементации WalkTo. Более того, эти имплементации могут еще не существовать во время создания сценария.

Часть 2

Самое интересное – впереди! Продолжение статьи читайте здесь.


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