Глава I серии руководств о том, как создать игру с нуля с помощью TypeScript и собственных API-интерфейсов браузера.

Добро пожаловать в первую статью из серии «Создание игры с помощью TypeScript»! Мы начнем наше путешествие с одного из наиболее широко используемых шаблонов разработки игр: Entity Component System.

Оглавление:

  1. Что такое ECS?
  2. Компонент реализации
  3. Реализующий объект
  4. Тестирование
  5. Заключение

Что такое ECS?

Это один из архитектурных паттернов, который позволяет нам комбинировать и составлять элементы кодовой базы, не создавая сильных иерархических взаимосвязей. По сути, он позволяет подключать / отключать функциональные возможности к элементам системы во время выполнения.

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

В этом примере игрок - Сущность, а способности читать заклинание или сражаться мечом - это Компоненты. Мы можем назначать различные компоненты сущности, чтобы обеспечить новую функциональность, не меняя ее код.

Сущность может быть чем угодно: игроком, сеткой, сценой, маркерами и даже самой игрой. ECS не накладывает никаких ограничений на функциональность Организации. Есть только одно правило: Сущность должна уметь обрабатывать Компоненты.

Компонент может быть любым, если он может быть прикреплен к объекту. Некоторые компоненты могут быть необходимы для сущности, что означает: сущность не может существовать без нее.

В нашей игре будет довольно много Сущностей и Компонентов. Одним из примеров является Корабль, который может летать, атаковать, получать урон и т. Д.

Компонент реализации

Для начала стоит взглянуть на наш проект. Исходный код находится на GitHub. Перейдите в ветку init, и вы должны увидеть полностью настроенный, но пустой проект.

Взгляните на srcfolder. Все, что у него есть, - это пустой main.ts файл и фиктивный тест в main.spec.ts. Давай killmain.spec.ts, потому что он нам больше не нужен.

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

Однако эти помощники не зависят от самой игры и потенциально могут быть повторно использованы в других играх. Давайте начнем с выделения папки, в которой будут храниться все подобные помощники в этом проекте. Я назову это utils.

ECS состоит из двух элементов: сущностей и компонентов. Начнем с самого простого: Компонент. Как упоминалось ранее, ECS не заботится о «внутренней кухне» самого Компонента. Это может быть что угодно, лишь бы это Компонент. Это означает, что тот, кто утверждает, что является Компонентом, должен соответствовать интерфейсу Компонента. Давай определимся!

Под utils я создаю специальную папку для Entity Component System и называю ее ecs. Внутри этой папки я создаю файл, который будет содержать IComponent interface: component.h.ts. Это одно из соглашений об именах, которым я должен следовать в этом проекте. Все файлы, содержащие только типы, будут иметь суффикс .h (читайте: заголовочный файл).

Я также собираюсь добавить файл баррель index.ts в каждую папку по всему проекту, чтобы избежать циклических зависимостей и упростить импорт / экспорт наших модулей:

Мы могли бы остановиться на этом и оставить Component таким универсальным. Однако он должен, по крайней мере, указывать, к какому объекту он принадлежит (если он есть! Помните, что компонент может быть отсоединен от объекта в любой момент). Итак, давайте немного настроим интерфейс:

На данный момент TypeScript жалуется на несуществующий Entity. Пора это исправить!

Реализующий объект

Сущность - это немного более сложный зверь. Он должен уметь:

  1. Добавить компонент к себе
  2. Удалите компонент из самого себя
  3. Вернуть компонент по типу, если он был добавлен
  4. Ответьте на вопрос «добавлен этот компонент или нет?»

Чтобы удовлетворить все эти требования, он должен отслеживать подключенные компоненты. Другими словами, он должен поддерживать свое собственное состояние, он не может быть интерфейсом.

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

Обратите внимание, что в имени файла нет суффикса «.h». Это потому, что файл содержит реализацию, а не определение (читай: класс, а не интерфейс)

Не забудьте также настроить файл ствола для повторного экспорта Entity:

Сущность должна отслеживать прикрепленные компоненты. Самый простой способ сделать это - сохранить внутренний член класса:

Мы не хотим, чтобы какой-либо внешний код вмешивался в этот список, но мы согласны предоставить полный доступ к его потомкам; следовательно, член protected. Однако мы можем предоставить доступ только для чтения для внешнего мира. Для этого добавим публичный геттер для этого поля:

Красивый! Теперь давайте поддержим добавление компонентов. Для этого мы создаем специальный метод. Этот метод добавляет компонент в массив и устанавливает ссылку на объект:

Потрясающие! Следующий шаг - GetComponent. Это немного сложнее. Мы могли бы сделать что-то вроде этого:

но потребует от потребителей этого кода предоставить ссылку на конкретный компонент. Если у них уже есть эта ссылка, нет смысла ее искать.

Вместо этого мы должны реализовать метод, который возвращает ссылку на конкретный компонент по предоставленному типу. Рассмотрим этот пример:

Для этого наш GetComponent должен ожидать тип (читай: класс), а не экземпляр (читай: объект), а затем возвращать компонент этого типа. Конечно, этот тип должен соответствовать IComponent:

Давай переварим. Сначала мы определяем общий, соответствующий IComponent:

Нам это нужно, чтобы связать конструктор в аргументах с возвращаемым типом.

Затем мы определяем один аргумент constr, который должен быть типа этой странной вещи: { new(...args: any[]): C } Таким образом мы сообщаем TypeScript, что нам нужен конструктор (обратите внимание на ключевое словоnew), который может иметь любое количество аргументов любого типа (читать: ...args: any[]) и который создает объект типа C

Фактический поиск тривиален: мы просматриваем массив и возвращаем Component, который является экземпляром указанного типа. Метод выдает ошибку, если такого компонента нет:

Удаление теперь прямолинейно. Мы ожидаем тип Component так же, как и в GetComponent. Единственная разница в том, что RemoveComponent ничего не возвращает:

Есть много способов удалить элемент из массива. Например, мы можем использовать splice. Он полагается на индекс удаляемого элемента, который мы можем идентифицировать с indexOf. Проблема в том, что indexOf работает со значением value, а не с типом этого значения. Чтобы получить индекс, мы должны сначала пройти по массиву вручную:

Кстати, можно было бы использовать другой подход. Мы могли бы создать новый массив и просто пройти по старому и скопировать все значения, кроме значения, которое мы удаляем:

Однако подход с splice более предпочтителен с точки зрения производительности, поскольку это операция на месте. Также обратите внимание, что мы не перебираем весь массив, мы ломаемся, как только находим то, что ищем.

Наконец, если потребители хотят безопасно изучить наличие компонента определенного типа, они могут использовать метод HasComponent. Он работает так же, как GetComponent, но вместо выдачи ошибки возвращает true / false:

Хорошо, но вы можете заметить, что мы повторяем { new(...args: any[]): C } довольно много раз. Давайте немного упростим нашу жизнь и определим для нее специальный тип:

Мы не собираемся использовать его вне этого модуля, поэтому давайте сделаем его внутренним, опуская ключевое слово export. Кроме того, на этот раз я использую типобезопасную версию any: unknown. Теперь я обращаюсь к этому типу в методах:

Последний штрих: давайте добавим зависимость Entity к component.h.ts, чтобы он больше не жаловался:

Потрясающие! Если вы запустите npm start, ваш код должен компилироваться без ошибок

Тестирование

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

Давайте создадим модульный тест для нашей сущности:

Во-первых, нам нужно настроить фиктивные сущности и компоненты. Пусть они будут максимально простыми:

Здесь мы создали пустые классы, которые выполняют все необходимые обещания: E расширяет абстрактную сущность, а C1, C2 и C3 реализуют интерфейс IComponent и ожидают присоединения к E.

Теперь давайте создадим их все, и мы закончили подготовку:

Обратите внимание: мне не нужно создавать экземпляры компонентов каждый раз. Достаточно очистить объект.

Мы проверяем, можем ли мы добавлять, получать и удалять компонент из Entity, а также проверяем, присутствует ли компонент:

Для этого я просто вызову все наши методы и проверю состояние Entity:

Сначала мы проверяем, что Entity не имеет компонентов. Должен, потому что мы еще ничего не добавили. Затем мы добавляем все три компонента и ожидаем, что массив Entity Components станет трех элементов. Более того, мы ожидаем, что Сущность содержит правильные Компоненты.

Затем мы удалили второй компонент и проверили, что состояние объекта соответствующим образом обновляется.

Обратите внимание: мы указываем его тип, а не ссылку. Нам повезло, что мы смогли предоставить ссылку в этом случае. Но, как вы увидите в следующих главах, это не всегда так.

Наконец, мы проверили, работают ли GetComponent и HasComponent должным образом.

Если мы запустим npm t, мы увидим, что наш тест прошел:

Единственное, что мы не рассмотрели, - это тот факт, что GetComponent должен выдавать ошибку, если потребитель пытается получить доступ к отсутствующему компоненту. Давайте определим еще один случай для этого:

Давайте снова запустим npm t и убедимся, что все тесты пройдены:

Вы можете найти полный исходный код этого поста в ветке ecs репозитория.

Поздравляю! Вы закончили первую главу этой серии!

В этой главе мы создали проект, узнали, что такое Entity Component System и как реализовать его с помощью TypeScript, используя интерфейсы и абстрактные классы! Мы также рассмотрели их с помощью модульных тестов, чтобы убедиться, что они работают должным образом.

В следующем посте мы поговорим о еще одном распространенном шаблоне: Game Loop. Береги себя и увидимся тогда!

Это первая глава из серии руководств «Создание игры с помощью TypeScript». Другие главы доступны здесь: