Использование Roslyn для редактирования игрового контента

в 11:39, , рубрики: .net, C#, code analysis, Gamedev, roslyn, tools programming, разработка игр

Болтовня ничего не стоит. Покажите мне код.
— Linus Torvalds

Всем привет! Я работаю программистом в маленькой (но гордой) gamedev-конторе. В последние несколько лет фирма выпускает casual игры под мобилки в жанре match3. Пишем мы на C# (что не может не радовать) и не используем Unity (ну почти). Так сложилось, что основная зона моей ответственности — gameplay и UI. А ещё я без ума от C#’а и его экосистемы. Сегодня хочу рассказать, как у меня получилось применить инструмент анализа и модификации кода Roslyn для редактирования игрового контента. Кому интересно — прошу под кат.

Примечание. Разделы, в которых разбирается способ реализации и приводятся примеры кода отмечены с помощью [технический раздел] в заголовках. Если нет желания погружаться в детали на таком уровне, просто пропускайте их. На общем понимании идеи это не скажется.

Предыстория. Одним Match3 сыт не будешь

Во многих играх жанра match3 присутствует отдельная сущность, которая представляет собой игровой мир. Мы называем её картой. Функциональность у карты может быть разной: от очень простой (показатель прогресса по игре) до полноценной сцены действия (со своей внутренней историей, сюжетом, персонажами и т.д.).

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

Архитектура сюжета [технический раздел]

Мы делим весь сюжет на отдельные промежутки — квесты. Квест — это набор этапов (состояний), каждое из которых имеет свою логику. Посмотрим на код Stub-квеста:

enum StubQuestState
{
	Initial,
}

class Stub : SectorComponent<StubQuestState> {}

class StubQuest : SectorBehaviour<Stub, StubQuestState>
{
	[State(StubQuestState.Initial)]
	private IEnumerator<object> InitialState()
	{
		yield break;
	}
}

Итак, мы видим:

  • enum StubQuestState — состояния квеста;
  • class Stub — компонент квеста, хранит состояние и опциональные данные;
  • class StubQuest — логика квеста. Каждому состоянию квеста должен соответствовать метод, возвращающий итератор (метод связывается через атрибут State и определяет действия, выполняющиеся непосредственно в этом состоянии).

Также у каждого квеста есть условие активации — когда он должен быть запущен (в коде представлено как Func и имеет название ReachedCondition).

О названии
Надо признать, что название выбрано не очень удачно — это не каноническая последовательность “поговорил с нпс” — “набил 10 фрагов” — “сдал, получил ништяк”. Через квесты реализовано довольно много разных вещей: открытие функциональности при продвижении по сюжету, выдача наград, присущих каждой игровой локации и т.д. Это правильнее было бы назвать “Behaviour”’ом (наподобие Unity), но решено было оставить привычное название.

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

var fixSubmarineQuest = new FixSubmarineQuest {
	ReachedCondition = () => plot.Levels.Completed > 3,
};
var removeSeaweedQuest = new RemoveSeaweedQuest {				
ReachedCondition = () => fixSubmarineQuest.Component.IsFinished,
};
var fixGateStatuesQuest = new FixGateStatuesQuest {
ReachedCondition = () => removeSeaweedQuest.Component.IsFinished
&& fixSubmarineQuest.Component.IsFinished,
};

Визуализируем связь квестов:

image

Как мы видим, квесты образовывают несвязный ациклический направленный граф.
Разберем логику состояния квеста. Каждое состояние — это по своей сути последовательность действий, которые необходимо выполнить в этом состоянии. Действия могут быть абсолютно разные: активация анимации, перемещение камеры по сцене, запуск игрового уровня, запуск и управление cut-сценой и т.п. Пример:

yield return WaitForMapScreenOnTop();

MapScreen.HideUi();
yield return MapScreen.ScrollToMapNodeTask(
	MapScreen.Map._Sector02._RadioTower.It,
	zooming: MapZooming.Minimal,
	screenPivot: new Vector2(0.5f, 0.4f)
);

yield return MapScreen.Map._Sector02._RadioTower.RunAnimationFixTower();
yield return MapScreen.Map._Sector02._RadioTower.RunAnimationCommunicate();

var dialog = new CutScene(CharacterEmotion.Playfulness);
dialog.AddBubble("Bla-bla-bla");
yield return Task.WaitWhile(() => !dialog.IsNearlyHidden);

MapScreen.ShowUiWithDelay();

SetState(FixCommunicationCrystalQuestState.Final);

В данном примере мы:

  1. Ждём, пока верхним диалогом не окажется карта.
  2. Скрываем UI и перемещаем камеру.
  3. Последовательно запускаем сюжетные анимации.
  4. Показываем cut-сцену со счастливым персонажем, говорящим “Bla-bla-bla”.
  5. Показываем UI и переходим в следующее состояние.

Roslyn и контент

Как известно, gamedev — довольно экстремальная область разработки ПО. Мало того, что игра — это “солянка” из систем и компонентов, так ещё взаимосвязи между ними меняются с молниеносной скоростью (а иногда даже и по принципу маятника: уберите, верните, уберите наполовину…). На этапе активной разработки сюжет постоянно претерпевает изменения. Это затрагивает все уровни описанной архитектуры: и отношения между квестами (их порядок), и разбиение квестов по секторам, и непосредственно логику квеста (состояния и действия). Мне, честно сказать, надоело писать всё руками (или делать Ctrl+C, Ctrl+V, а потом переименовывать и т.д.), поэтому сначала я написал небольшое приложение на WinForms для генерации StubQuest’а с заданным именем и состояниями. А потом вспомнил про Roslyn и решил создать инструмент для редактирования сюжета.

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

  1. С использованием Roslyn (в частности, Microsoft.CodeAnalysis.CSharp) анализируется код игры и строится логическая модель.
  2. Логическая модель отображается в диаграмму.
  3. Изменения диаграммы отображаются в модель, а затем (опять же через Roslyn, но уже с помощью API для генерации и изменения кода) обратно в кодовую базу.

Схематично это можно выразить так:

image

Логическое представление достаточно простое само по себе — набор классов/структур, представляющих игровые сущности, различные данные, которыми можно их описать/дополнить, и связи между этими сущностями. Благодаря логической модели с лёгкостью реализуется проверка корректности поведения (например, наличие циклов в графе или забывчивость разработчиков показать UI после cut-сцены). Поговорим об анализе кода и построении логической структуры подробнее.

О решении редактировании кода вне Visual Studio не программистами

Скептики могут спросить: а зачем всё это? Как можно давать редактировать код не программистам?
Отвечаю: очень просто. Инструмент даёт возможность редактировать исключительно строго определённые части кода, а также осуществляет проверку на корректность. Используя его, нельзя сломать сборку проекта.
А что с “постоянно и быстро меняющимися требованиями”? Всё тоже просто: новые фичи добавляются по мере необходимости. Как только концепт реализован, утверждён и встречается в разных местах сюжета, инструмент дорабатывается.

Анализ кода и построение модели [технический раздел].

Что такое код? Для начала это просто текст. Затем, на этапе синтаксического анализа, из него получается AST (Abstract Syntax Tree). После семантического анализа появляется таблица символов. Используя Roslyn, получить требуемые структуры данных не составляет труда (пример взят отсюда):

var tree = CSharpSyntaxTree.ParseText(@"
	public class MyClass 
	{
		int MyMethod() { return 0; }
	}"
);
var Mscorlib = MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
var compilation = CSharpCompilation.Create(
"MyCompilation",
	syntaxTrees: new[] { tree },
references: new[] { Mscorlib }
);
//Note that we must specify the tree for which we want the model.
//Each tree has its own semantic model
var model = compilation.GetSemanticModel(tree);

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

image

public void LoadSectorCode(string dirPath)
{
	var sectorFile = Directory.EnumerateFiles(dirPath)
		.FirstOrDefault(p => new FileInfo(p).Name.StartsWith("Sector"));
	if (sectorFile == null) {
		throw new SectorFileNotFoundException();
	}

	code.ReadFromFile(Path.Combine(dirPath, sectorFile), CodeBulkType.Sector);

	var questsDir = Path.Combine(dirPath, "Quests");
	if (Directory.Exists(questsDir)) {
		var files = Directory.EnumerateFiles(questsDir);
		foreach (var filePath in files) {
			code.ReadFromFile(filePath, CodeBulkType.Quest);
		}
	}
}

Каждому файлу в кодобазе сопоставляется тип (enum CodeBulkType): квест, сектор, файл конфигурации и т.д. Зная тип, можно проводить более высокоуровневый анализ. Например, считывать данные квестов:

class QuestReader : CodeReader
{
	public override CodeBulkType[] AcceptedTypes => new[] { CodeBulkType.Quest,  };

	public override void Read(CodeBulk codeBulk, Code code, ref Flow flow)
	{
		var quest = FromCodeTransformer.ReadQuest(codeBulk.Tree);
		//...
		sector.Quests.Add(quest);
		//...
}
}
Немного об архитектуре данных и чтении

С изменениями в проекте меняются и инструменты его разработки, поэтому обязательными требованиями к архитектуре были расширяемость и гибкость. Я вижу логическую модель как репозиторий: данные могут добавляться, изменяться, удаляться. Расширяемость данных:

public interface IData {}

public class DataStorage
{
	public List<IData> Records = new List<IData>();
	//….
}

// где-то в сущности квеста, сектора, квестового действия…
public DataStorage Data { get; } = new DataStorage();

Чтение:

interface ICodeReader
{
	CodeBulkType[] AcceptedTypes { get; }

	void Read(CodeBulk codeBulk, Code code, ref Flow flow);
}

abstract class CodeReader : ICodeReader
{
	public abstract CodeBulkType[] AcceptedTypes { get; }

	public abstract void Read(CodeBulk codeBulk, Code code, ref Flow flow);
}

Нужно считать что-то? Заведи класс, отнаследуйся от CodeReader/имплементируй ICodeReader. Нужно предоставить доп. данные для анализа? Отлично, наследуйся от IData и считывай их откуда необходимо и добавляй куда нужно.

Для анализа и редактирования синтаксического дерева активно используется паттерн Visitor, либо же LINQ. Последний довольно неудобен, подходит для очень простого анализа. В коде проекта я активно использовал классы SyntaxVisitor, SyntaxWalker и SyntaxRewriter. Use-case первых двух — анализ синтаксического дерева. Например, квест может быть как добавлен в сюжет, так и просто лежать себе спокойненько, никого не трогать. Для определения этой характеристики нужно посмотреть, вызывается где-либо конструктор квеста. С Roslyn сделать это достаточно просто: отнаследоваться от SyntaxWalker и “посетить” все ObjectCreationExpression:

// SyntaxWalker - обёртка CSharpSyntaxWalker’a с доп. функциями 
private class QuestInitializationFinder :  SyntaxWalker
{
	//...
	public override void VisitObjectCreationExpression(ObjectCreationExpressionSyntax node)
	{
		base.VisitObjectCreationExpression(node);

		var model = Code.Compilation.GetSemanticModel(node.SyntaxTree);
		if (model.GetTypeInfo(node).Type == questTypeToFind) {
			//….
		}
	}
	//...
}

Отображение построенной модели.

Итак, модель мы построили. Осталось её отобразить, а затем научиться редактировать.

Отображение модели было реализовано с помощью открытой библиотеки NShape. Данный проект оказался самым простым в освоении и обладающим богатым функционалом (хотя иногда хочется больше возможностей для расширения…). Для расположения вершин графа квестов использовался алгоритм Sugiyama из библиотеки Microsoft Automatic Graph Layout, а ещё пришлось написать несколько эвристик.

Приведу пример квестов первого сектора:

image

Выглядит намного более user-friendly, чем код инициализации, не так ли? (Это ещё очень простой пример, где мало квестов и инициализируются они в линейном порядке.) А вот отображение логики квеста (режим “для программиста” — показать код; в режиме “для всех остальных” показывается словесное описание действия):

image

Редактирование модели [технический раздел].

Библиотека NShape предназначена для создания и редактирования диаграмм. Отобразив логическую структуру в виде диаграммы, у нас появилась возможность её (структуры) наглядного редактирования. Главное — правильно проинтерпретировать изменения и запретить недопустимые.

Трансляция действий над диаграммой в логические происходит в специальных обёртках над инструментами библиотеки NShape (писать этот код было тем ещё удовольствием). Преобразуются только действия, несущие некий смысл в контексте логической модели (например, поворот шейпа, обозначающего квест, никакого структурного изменения в нашем способе отображения под собой не подразумевает). Все действия инкапсулируются в объекты типа Command (узнали паттерн?) и являются обратимыми.

delegate void DoneHandler(bool firstTime);
delegate void UndoneHandler();

interface ICommand
{
	DoneHandler Done { get; set; }
	UndoneHandler Undone { get; set; }

	void Do();
	void Undo();
}

Данный интерфейс (и абстрактный класс, имплементирующий его) несут ответственность за изменение логической модели и кода. Обновление визуального представления происходит через подписку на Done/Undone делегаты.

Разбиение составных действий на части

Некоторые действия состоят из нескольких других. Например, связывание двух квестов. Если они оба активированы, то это одно действие — непосредственное добавление связи. Если хотя бы один из них неактивен, то перед накидывание связи необходимо его “включить”. Или другой пример — удаление квеста. Перед удалением квеста нужно удалить все связи с его участием и деактивировать квест. Такое разбиение позволяет дробить сложные действия на составляющие и уже из них конструировать другие.

В коде это реализовано через специфический случай команды — CompositeCommand:

class CompositeCommand : Command
{
	private readonly List<ICommand> commands = new List<ICommand>();

	public override void Do()
	{
		foreach (var cmd in commands) {
			cmd.Do();
		}
	}

	public override void Undo()
	{
		foreach (var cmd in Enumerable.Reverse(commands)) {
			cmd.Undo();
		}
	}
}

Здесь хочется выделить редактирование свойства Undone этой команды. Дело в том, что команды выполняются в порядке добавления, а откатываются — в обратном. Поэтому делегаты Undone необходимо комбинировать в обратном порядке:

public void AddCommands(IEnumerable<ICommand> commandsToAdd)
{
	//...
	foreach (var cmd in commandsToAdd) {
		Done += cmd.Done;
		Undone = (UndoneHandler)Delegate.Combine(cmd.Undone, Undone);
	}
}

Для редактирования синтаксических деревьев используются, как было описано выше, расширения класса CSharpSyntaxRewriter (методика хорошо описана здесь). Пример деактивации квеста (удаления вызова конструктора):

class DeactivateQuestCommand : Command
{
	//…
public override void Do()
	{
		var classDecls = codeBulk.Tree.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>();
		foreach (var classDecl in classDecls) {
			var typeRemover = new ClassConstructorCallRemover(
				Context.CodeEditor.GetSymbolFor(classDecl, codeBulk),
				Context.Code
			);
				Context.CodeEditor.ApplySyntaxRewriter(typeRemover);
		}
		//...
	}
	//...
}

class ClassConstructorCallRemover : SyntaxRewriter
{
	//…
	public override SyntaxNode VisitExpressionStatement(ExpressionStatementSyntax node)
	{
		var model = Compilation.GetSemanticModel(node.SyntaxTree);
		var expr = node.Expression;
		if (expr is ObjectCreationExpressionSyntax oces) {
			if (ReferenceEquals(model.GetTypeInfo(expr).Type, typeToRemove)) {
				return null;
			}
		}
		
return base.VisitExpressionStatement(node);
	}

	public override SyntaxNode VisitLocalDeclarationStatement(LocalDeclarationStatementSyntax node)
	{
		if (node.Declaration.Variables.Count == 1) {
			var model = Compilation.GetSemanticModel(node.SyntaxTree);
			var type = model.GetTypeInfo(node.Declaration.Variables.First().Initializer.Value).Type;
			if (ReferenceEquals(type, typeToRemove)) {
				return null;
			}
		}

		return base.VisitLocalDeclarationStatement(node);
	}
	//...
}

Хочу отметить, что некоторые функции редактирования Roslyn предоставляет “из коробки”. Яркий пример — переименование (Renamer) и форматирование пробелов (Formatter). И это сильно упрощает жизнь.
До текущего момента мы занимались только тем, что удаляли/изменяли уже существующий код. А что до генерации нового? Делается это просто — через SyntaxFactory.Parse* и функции модификации узлов:

var linkExprText = $"ReachedCondition = {newLinkText}";
var newInitializer = node.Initializer.AddExpressions(SyntaxFactory.ParseExpression(linkExprText));

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

Про code fix’ы

Roslyn — инструмент хороший, но не всемогущий. Например, после модификации кода могут возникнуть банальные ошибки компиляции. И исправлять их нужно самому. Так пришлось писать code-fix для порядка инициализации квестов в секторах.

Для написания такого кода нужны хорошие знания грамматики (что за синтаксические единицы необходимо проверять/редактировать/создавать) и Roslyn API. Но после того, как вы приобретете всё необходимое, программирование с Roslyn превращается в одно удовольствие (особенно в архитектурном плане — я большой поклонник красивого кода и функциональщины).

Возможности редактора и компромиссы. Практический пример.

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

Итак, решено редактировать код. Признаемся себе в том, что формулировка достаточно пугающая. Тут люди то не всегда пишут как надо, а нам нужно написать программу, которая будет анализировать и менять другой код. И вовсе необязательно написанный нами. Без ограничений не обойтись.

Первое, что хочется описать — это поведение редактора в случае обнаружения кода, который он не знает, как интерпретировать: какие-то внутренние дела программистов, хаки, новые фичи, про которые нельзя точно сказать, войдут они в проект или нет и т.п. Данную ситуацию необходимо “разрулить” очень аккуратно. Иначе от инструмента будет больше вреда, чем пользы.

Как мы решили эту проблему? Не играй с тем, чего не понимаешь. Под понимаем имеется в виду знание программы о логическом значении куска кода. Слишком абстрактно? Вот вам пара примеров.

Пример первый. Квест должен активироваться не только после другого, но также после трёх пройденных уровней. Или пяти. А ещё после того, как пользователю выдали ежедневную награду. В общем, есть некое дополнительное условие. Из редактора задать такое условие в общем случае нельзя — требуется вмешательство программиста. Окей, программист наконец-то освободился и дописал пару строчек в код. Что дальше? Во-первых, редактор отображает это в диаграмме:

image

Пользователь сразу видит, что не так всё просто с этим квестом. Во-вторых, запрещается редактирование неизвестного (неразобранного редактором) условия. Максимум, что мы можем сделать — это прочитать его исходный код и оставленный к нему комментарий (полезная функция — позволяет программисту объяснить непосвящённому значение кода). В-третьих, допускается только добавление новых условий (и, естественно, их редактирование и удаление). Т.е. условие может стать строже, и то настолько, насколько позволяет редактор.

Пример второй. Неразобранный код в логике (действиях) квеста. Наверное, самая частая ситуация. Философия здесь такая же: не дать редактировать и ненавязчиво показать табличку с надписью WARNING (вообще, предусмотрена галочка скрыть/показать неразобранный код).

Второе, что следует обсудить — это редактирование потока исполнения. Например, в одном из квестов есть часть логики, которая сама по себе опциональна и запускается только по определённому и неразбираемому условию. Глупо было бы вообще запретить её редактирование. Поэтому было решено оставить пользователю эту возможность, но с некоторыми оговорками: не допускается редактирование условия наступления (условия в if’е, while’е), а также перемещение всего блока в общем случае (да, горькая правда — перемещение может сломать компиляцию или, что ещё хуже, неочевидно изменить логику). Необходимо переместить логику? Два варианта: либо отключите блок и в нужном месте создайте новый, либо дайте задачу программисту.

А теперь рассмотрим простой пример для иллюстрации. Гейм-дизайнер получает задачу: вставить между квестами FindKey и OpenTheDoor квест TalkToLeeroy. В квесте мы показываем cut-сцену, ждём непосредственного прохождения квеста, затем показываем вторую cut-сцену и завершаем квест. Алгоритм действий гейм-дизайнера (от его лица):

  1. Находим квесты, между которыми необходимо вставить новый:
    image
  2. Добавляем новый квест с требуемым названием, перекидывает связи:
    image
  3. Открываем окно редактирования квеста, добавляем требуемые состояния:
    image
  4. Затем наполняем каждое состояние необходимым действием. Например, задача состояния Initial — показать первую cut-сцену:
    image
  5. По аналогии заполняются остальные состояния.
  6. Если вдруг обнаруживаются ошибки (например, мы скрыли интерфейс и забыли его показать), об этом нас предупреждают визуально (специальными отметками на диаграмме). Исправляем ошибки.
  7. Запускаем игру, убеждаемся, что всё работает, как было задумано.

Итак, задача выполнена, радостный дизайнер спокойненько отправляет её на тестирование и уходит пить чай.

Целью создания редактора было быстрое прототипирование и наполнение контентом. В конце концов, абсолютной властью над логикой игры обладает только программист — маленький царь и бог виртуального мира. Дешевле (и, как следствие, целесообразнее) дать ему задачу на полировку/доработку игрового функционала, нежели чем сильно усложнять инструмент. А то так и до такой ситуации недалеко:

image

Заключение

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

  1. В разы ускорена работа
  2. Улучшена структура и архитектура игры
  3. Автор изучил Roslyn, углубил знания C#, улучшил навыки проектирования

Порекомендую ли я Roslyn для изучения и использования? Безусловно. Наберитесь терпения и в путь.

Автор: Хощенко Артём

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js