Около десяти лет назад, в 2017 году, некий Роберт Мартин выпустил книгу "Clean Architecture", в которой он рассказал о своем видение лучшей архитектуры для программных проектов. На самом деле он писал об этом еще в 2012 году, но тогда его идеи, к сожалению, не получили большой популярности. И даже сейчас, далеко не все понимают и используют эти идеи.
Если коротко, то суть его идей заключалась в том, что проекты должены состоять из нескольких слоев: пользовательский интерфейс (не обязательно графический), приложение, предметная область бизнеса и инфраструктура. Разумеется, что первые слои могут иметь зависимости только на следующие. Так же важным было то, что слой бизнеса не должен иметь зависимости на инфраструктуру, а должен использовать инверсию зависимостей. Хотя последнее, во многих случаях, - излишний оверкодинг. И вроде бы в этом нет ничего особо хитрого, но, к сожалению, потребовались десятилетия, чтобы хоть кто-то пришел к этим идеям и написал книгу.
А в игровой индустрии, складывается впечатление, что до сих пор о подобном никто всерьез не думает. К сожалению, существующие модные движки (особенно editor-first движки) имеют свои проблемы и ограничения, которые мешают реализовать эти идеи. Например, Unreal сильно зависит от своего фреймворка, который задаёт некую другую архитектуру. А Unity вынуждает использовать проклятый MonoBehaviour, ибо только MonoBehaviour может слушать события других компонентов. Возможно в Godot с этим лучше? Поэтому, ради интереса, я реализовал эти идеи в обычном .NET проекте, который назвал GameFramework.Pro. И теперь я хочу рассказать вам об этом фреймворке.
Обзор
Данный фреймворк определяет основные слои проекта, основные сущности и сервисы, а так же содержит минимальный набор утилит. Давайте рассмотрим это подробнее...
System
Check - набор проверок для аргументов и операций.
DisposableBase - освобождаемый объект. Содержит некоторый дополнительный функционал.
IDependencyProvider - локатор служб. Предоставляет запрашиваемые объекты и значения.
GameFramework.Pro
Main - корень проекта.
ProgramBase - главная сущность проекта. Создает другие сущности и предоставляет запрашиваемые зависимости.
UI - аудио-графический пользовательский интерфейс.
ThemeBase - сущность аудио темы. Проигрывает музыкальные плейлисты.
PlayListBase
ScreenBase - сущность графического экрана. Показывает дерево виджетов.
WidgetBase
ViewableWidgetBase
RouterBase - сервис менеджера состояния. Предоставляет методы для загрузки главного меню, загрузки/перезагрузки/выгрузки игры, а так же выхода из приложения.
App - модель приложения.
ApplicationBase - сущность приложения. Выполняет инициализацию, запуск главного цикла, запуск игры, а так же предоставляет хранилище и подобное.
Game - модель домена / бизнеса (в нашем случае самой игры).
GameBase - сущность игры. Содержит информацию об игре, игровые правила, состояние и сущности игроков.
PlayerBase - сущность игрока. Содержит информацию об игроке и состояние. Так же может обрабатывать ввод.
WorldBase - сущность мира. Содержит все остальные сущности.
EntityBase - любой значимый для игры объект.
Детальный обзор
Теперь давайте рассмотрим некоторые компоненты этого фреймворка более детально.
Program
// Программа содержит другие сущности и сервисы, // а так же реализует паттерн локатор служб. // Заметьте, что локатор служб считается анти-паттерном, // но другие решения приводят к оверкодингу. public abstract class ProgramBase : DisposableBase { public ProgramBase(); private protected override void OnDisposeInternal(); }
// Каждая сущность и сервис разделены на две класса: базовый и расширенный. // Все расширенные классы добавляют поддержку IDependencyProvider. public abstract class ProgramBase2<TTheme, TScreen, TRouter, TApplication> : ProgramBase, IDependencyProvider where TTheme : ThemeBase where TScreen : ScreenBase where TRouter : RouterBase where TApplication : ApplicationBase { protected TTheme Theme { get; init; } protected TScreen Screen { get; init; } protected TRouter Router { get; init; } protected TApplication Application { get; init; } public ProgramBase2(); private protected override void OnDisposeInternal(); object? IDependencyProvider.GetValue(Type type, object? argument); }
Theme
// Тема содержит плейлист, // который проигрывает текущий список аудио треков. // Заметьте, что я использовал машину состояний, // которая позволляет вам легко управлять текущим плейлистом. public abstract class ThemeBase : DisposableBase { protected StateMachine Machine { get; } public ThemeBase(); private protected override void OnDisposeInternal(); } // Заметьте, что вместо наследования, // я использовал двухсторонюю связь между плейлистом и состоянием. public abstract class PlayListBase { public sealed class State2 : State { public PlayListBase PlayList { get; } public State2(PlayListBase playList); protected override void OnDispose(); protected override void OnActivate(object? argument); protected override void OnDeactivate(object? argument); } public State2 State { get; } public PlayListBase(); protected internal abstract void OnDispose(); private protected virtual void OnDisposeInternal(); protected internal abstract void OnActivate(object? argument); protected internal abstract void OnDeactivate(object? argument); }
public abstract class ThemeBase2<TRouter, TApplication> : ThemeBase where TRouter : RouterBase where TApplication : ApplicationBase { protected IDependencyProvider Provider { get; } protected TRouter Router { get; } protected TApplication Application { get; } public ThemeBase2(); private protected override void OnDisposeInternal(); } public abstract class PlayListBase2 : PlayListBase { protected IDependencyProvider Provider { get; } public PlayListBase2(); private protected override void OnDisposeInternal(); }
Screen
// Экран содержит дерево виджетов, // которые рисуют текущий пользовательский интерфейс. // Заметьте, что я использовал машину дерева, // которая позволляет вам легко управлять иерархией виджетов. public abstract class ScreenBase : DisposableBase { protected TreeMachine Machine { get; } public ScreenBase(); private protected override void OnDisposeInternal(); } // Заметьте, что вместо наследования, // я использовал двухсторонюю связь между виджетом и нодой. public abstract class WidgetBase { public sealed class Node2 : Node { public WidgetBase Widget { get; } public Node2(WidgetBase widget); protected override void OnDispose(); protected override void OnActivate(object? argument); protected override void OnDeactivate(object? argument); protected override void Sort(List<INode> children); } public Node2 Node { get; } public WidgetBase(); protected internal abstract void OnDispose(); private protected virtual void OnDisposeInternal(); protected internal abstract void OnActivate(object? argument); protected internal abstract void OnDeactivate(object? argument); protected internal virtual void OnBeforeDescendantActivate(INode descendant, object? argument); protected internal virtual void OnAfterDescendantActivate(INode descendant, object? argument); protected internal virtual void OnBeforeDescendantDeactivate(INode descendant, object? argument); protected internal virtual void OnAfterDescendantDeactivate(INode descendant, object? argument); protected internal virtual void Sort(List<INode> children); } public abstract class ViewableWidgetBase : WidgetBase { public object View { get; protected init; } internal ViewableWidgetBase(); private protected override void OnDisposeInternal(); } public abstract class ViewableWidgetBase<TView> : ViewableWidgetBase where TView : notnull { protected new TView View { get; init; } public ViewableWidgetBase(); private protected override void OnDisposeInternal(); }
public abstract class ScreenBase2<TRouter, TApplication> : ScreenBase where TRouter : RouterBase where TApplication : ApplicationBase { protected IDependencyProvider Provider { get; } protected TRouter Router { get; } protected TApplication Application { get; } public ScreenBase2(); private protected override void OnDisposeInternal(); } public abstract class WidgetBase2 : WidgetBase { protected IDependencyProvider Provider { get;} public WidgetBase2(); private protected override void OnDisposeInternal(); } public abstract class ViewableWidgetBase2<TView> : ViewableWidgetBase<TView> where TView : notnull { protected IDependencyProvider Provider { get;} public ViewableWidgetBase2(); private protected override void OnDisposeInternal(); }
Router
// Роутер предоставляет ссылки на другие сущности. public abstract class RouterBase : DisposableBase { public RouterBase(); private protected override void OnDisposeInternal(); } public abstract class RouterBase2<TTheme, TScreen, TApplication> : RouterBase where TTheme : ThemeBase where TScreen : ScreenBase where TApplication : ApplicationBase { protected IDependencyProvider Provider { get; } protected TTheme Theme { get; } protected TScreen Screen { get; } protected TApplication Application { get; } public RouterBase2(); private protected override void OnDisposeInternal(); }
Application
// Приложение предоставляет лишь провайдер зависимостей. // Остальные сущности являются такими же простыми, // поэтому я не буду продолжать описывать их. public abstract class ApplicationBase : DisposableBase { public ApplicationBase(); private protected override void OnDisposeInternal(); } public abstract class ApplicationBase2 : ApplicationBase { protected IDependencyProvider Provider { get; } public ApplicationBase2(); private protected override void OnDisposeInternal(); }
Дополнение
Чтобы создать качественный проект, вам нужен фреймворк определяющий основные слои и компоненты вашего проекта. А чтобы качественно проектировать отдельные компоненты проекта, вам нужно понимать какие бывают компоненты и из чего они состоят. К сожалению, я не смог найти хоть какую-то информацию на эту тему. Но я смог прийти к некоторым идеям, о которых хочу далее рассказать.
Я могу предложить следующую классификацию объектов:
Entity - первичный объект (названный существительным), который, в идеале, только обрабатывает события и не предоставляет никакого API.
Service - первичный объект (названный отглагольным существительным), который, в идеале, только предоставляет свой API другим сущностям.
Data - вторичный объект, который, в идеале, только содержит какие-то данные. Иногда такие объекты имеют в названии суффиксы Info или Desc.
Utility - технический объект содержащий какие-то полезные методы.
Так же я могу предложить следующие классификации:
Для свойств объектов:
Property/Data - полезная нагрузка.
Property/Info - информация об объекте.
Property/Info/Question - вопрос об объекте.
Property/Info/Assertion - утверждение об объекте.
Property/Info/Directive - инструкция объекту.
Property/Reference/Object - ссылка на объект.
Property/Reference/Delegate - ссылка на метод.
Property/Reference/Event - ссылка на методы (обработчики событий).
Для методов объектов:
Method/Constructor
Method/Destructor
Method/Command/Query - команда объекту на получение данных.
Method/Command/Request - команда объекту на выполнение работы.
Method/Handler - обработчик события.
Для атрибутов объектов:
Attribute/Info
Attribute/Info/Directive
Заключение
В заключении, я хочу отметить, что большую сложность фреймворка, я выделил в две отдельные библиотеки StateMachine.Pro и TreeMachine.Pro. Эти библиотеки могли бы быть достойны отдельных статей, но думаю, их суть ясна из их названий.