Воссоздаем игру "Астероиды" на Unity, изучая дизайн паттерны

Object Pool, Factory и Fly Weight для производительного, расширяемого и геймдизайнер - френдли проекта.

Вступление

В какой то момент своей жизни (примерно два месяца назад) я решил устроиться на позицию Unity разработчика. Составив резюме и раскинув по вакансиям которые я посчитал интересными, я стал ждать. За первые 3 дня я получил 7 отказов, что сильно било по мотивации, но в один день я получил письмо.

Прячу имена и ссылки чтобы никого не обидеть
Прячу имена и ссылки чтобы никого не обидеть

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

Постановка задачи

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

  • Наличие двух схем управления, клавиатура и клавиатура + мышь которые можно менять во время игры
  • Необходимо использовать Object Pool
  • Все параметры в игре, такие как скорость игрока, радиус разлета новых астероидов при смерти, и время неуязвимости игрока на старте, можно задать через инспектор Unity
  • И разумеется нельзя использовать сторонние ассеты, код готовых решений и прочее

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

Object Pool

Переиспользование старых объектов вместо создания и удаления новых с целью снизить нагрузку с процессора.

Создание и удаление объекта это довольно дорогая операция. И когда нам необходимо часто выполнять эти операции то это сильно сказывается на производительности. Это решение предполагает хранить несколько заранее созданных объектов в контейнере "пуле" и когда нам понадобится новый объект мы отдадим один из тех объектов что сейчас не используются. И разумеется когда объект вышел из использования мы возвращаем его в пул.

Это не только освобождает нас от поиска памяти и ее освобождения, но также позволяет хранить все объекты в одном месте, что увеличивает количество кэш попаданий и повышает производительность. Вдобавок решается проблема фрагментации памяти, но так как современные устройства щедры на память это не так критично. Локальность кэша (cache locality) и фрагментация памяти достойны отдельного упоминания и говорить о них здесь я не стану, но вы узнали что они существуют и я буду рад если вы как-нибудь прочтете и об этом тоже.

Какие-нибудь минусы? Может только неверное использование. Этот паттерн решает острую проблему без сложных структур и крайне эффективен. Однако если инициализация и удаление объекта проходят быстро и одновременно живы не так много экземпляров, то возможно вам не нужно его использовать. Не используемые объекты занимают память, а инициализация проходит быстро, зачем добавлять в проект больше кода?

Классный стикер https://www.redbubble.com/i/sticker/Keep-It-Simple-Stupid-KISS-by-TheStunner/20012421.EJUG5
Классный стикер https://www.redbubble.com/i/sticker/Keep-It-Simple-Stupid-KISS-by-TheStunner/20012421.EJUG5

Мы поговорили о том что из себя представляет этот паттерн и когда его использовать. Приступим же к самому интересному.

Создадим класс и назовем его "GameObjectPool". Он не будет наследоваться от MonoBehaviour так как этот класс просто утилита и создавать экземпляры этого класса мы будем из других скриптов, а не вешать на GameObject'ы на сцене.

public class GameObjectPool { }

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

public class GameObjectPool { private readonly GameObject[] pool; public GameObjectPool(int maxSize, GameObject prototype) { pool = new GameObject[maxSize]; for (int i = 0; i < pool.Length; i++) pool[i] = Object.Instantiate(prototype); } public GameObject Get() { for (int i = 0; i < maxSize; i++) if (!pool[i].activeInHierarchy) return pool[i]; return null; } }

Можно сказать что на этом мы закончили. Просто не правда ли?) Но сейчас я хотел бы задать вопрос - что делать если все объекты в пуле используются?

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

Еще один вариант - расширить пул. Либо создать новый массив объектов побольше и использовать его, либо создать новый объект отдельно от основного пула и следить за его жизнью отдельно. Этот вариант тоже имеет право на существование, однако этот сценарий будет неприятен и если обнаружиться что это случилось то в следующий раз размер пула нужно будет увеличить. Часто при использовании такого варианта ставят ограничение для создания новых объектов когда пул исчерпан.

И последний вариант - попросить клиента подождать пока один из объектов не освободиться. Самый сложный по технической реализации и вряд ли подойдет для пуль, так как игра просто встанет. Чтобы продолжить геймплей нужна пуля, но пули нет поэтому жди, но если геймплей ждет то пуля не исчезнет, вечный круг, смерть приложения. Такой подход очень полезен в других продуктах, или же для других не геймплейных объектов.

В астероидах ObjectPool использовался для спавнеров создающих объекты на сцене. Каждый спавнер имел в себе экземпляр класса ObjectPool и через инспектор ему передавались размер и префаб. В придачу этот класс содержит методы Spawn(Vector3 position, Quaternion rotation) и KillAll(), из за последнего пул пришлось расширить и добавить метод для деактивации всех объектов пула на сцене. Сам класс выглядит так:

public class Spawner : MonoBehaviour { [SerializeField] protected GameObject prefab; SerializeField] Tooltip("Max number of simultaneously active objects in scene. \nFor performance keep as low as possible")] protected int maxCount; SerializeField] private AudioSource audioOnSpawn; public virtual event EventHandler<SpawnArgs> OnSpawn; protected GameObjectPool pool; protected virtual void Awake() { pool = new GameObjectPool(maxCount, prefab); } ///returns true if spawn succeded and false otherwise public virtual bool Spawn(Vector3 position, Quaternion rotation) { var gameObj = pool.Get(); if (gameObj != null) { gameObj.transform.SetPositionAndRotation(position, rotation); gameObj.SetActive(true); OnSpawn?.Invoke(this, new SpawnArgs(gameObj, position, rotation)); if (audioOnSpawn != null) audioOnSpawn.Play(); return true; } Debug.LogWarning($"Pool on {gameObject.name} was empty and spawn failed"); return false; } public virtual void KillAll() { pool.DisableAll(); } }

В игре создавать какой либо объект может только спавнер. Виновник этому EntityManager который следит за жизнью всех объектов. Он начисляет очки при смерти кого либо, играет звук их уничтожения, и просит спавн новых объектов когда необходимо. Если создать объект в обход спавнера, то EntityManager об этом не узнает. Это в свою очередь паттерн Фабрика (Factory), который мы сейчас обсудим.

Factory

Вместо создания объекта клиентом, иметь объект "Фабрики" чья работа заключается в создании этих объектов. Фабрика будет инициализировать объекты, скрывая процесс от пользователя.

Перечислим плюсы этого паттерна.

1. Этот паттерн позволяет объектам быть полностью автономными и не зависеть от среды. Например, если мы захотим следить за количеством объектов, мы просим фабрику ввести счет создаваемых объектов, вместо создания статичной переменной на классе объекта.

2. Мы можем иметь ивент на фабрике когда объект создается и удаляется, так мы можем выполнять какую-нибудь логику при срабатывании этих событий.

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

И с третьим пунктом приходит основной минус паттерна - подразумевается что создание этого объекта всегда должно проходить через эту фабрику, иначе оно "не учтется" - фабрика это создание не увидит. Очень легко случайно создать объект самому)

Так этот паттерн вижу я. Но при поиске в гугл я больше встречал статей об абстрактной фабрике. Ее отличие заключается в том что при обращении к фабрике мы просим не конкретный объект, а интерфейс, "какой-то объект". Фабрика получает запрос, и затем передает этот запрос одной из реализаций конструктора объектов, и уже этот конструктор создаст и вернет объект. Этот паттерн я не использовал, поэтому углубляться в него не будем и перейдем к использованию обычной фабрики.

В моей игре фабрикой стали спавнеры объектов (класс Spawner выше). Они создают астероиды, пули и космические корабли. Этот дизайн сделал возможным создание EntityManager'а который следит за жизнью объектов в игре. Он слушает ивенты OnSpawn на спавнерах и OnDestroy на DestroyableObject, и реагирует на них.

public class EntityManager : MonoBehaviour { [SerializeField] private Scorer scorer; [SerializeField] private AudioSource audioSource; [Space(8)] [SerializeField] private SceneInfoSO sceneInfo; [SerializeField] private Spawner[] spawners; private int aliveSpaceObjects; private int asteroidsOnLastRound; private void Awake() {...} private void DeathMessage(object sender, DeathArgs args) {...} private void DeathSpawnPlayer(SpaceShip player, DeathArgs args) {...} private void DeathSpawnAsteroids(DestroyableObject asteroid, DeathArgs args) {...} public void GameInit(int bigAsteroidNumber) {...} private void SpawnAsteroidOffCamera(AsteroidType asteroid, int number) {...} private Spawner GetSpawner(string name) {...} private (Vector3, Quaternion) RandomOffCameraPosition() {...} }

На Awake EntityManager подписывается на OnSpawn ивент у всех спавнеров

private void Awake() { void f(object sender, SpawnArgs args) { aliveSpaceObjects++; args.SpawnedObject.GetComponent<DestroyableObject>().OnDestroy += DeathMessage; } foreach (var spawner in spawners) spawner.OnSpawn += f; }

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

private void DeathMessage(object sender, DeathArgs args) { aliveSpaceObjects--; var so = args.SO as DestroyableObjectSO; if (so != null) scorer.AddScore(so.XP); AudioClip clip = args.Sender.GetComponent<DestroyableObject>().MyAudioClip; if (clip) { audioSource.clip = clip; audioSource.Play(); } if (args.Sender.name == sceneInfo.PlayerName) DeathSpawnPlayer(args.Sender.GetComponent<SpaceShip>(), args); DeathSpawnAsteroids(args.Sender.GetComponent<DestroyableObject>(), args); if (aliveSpaceObjects == 1) { asteroidsOnLastRound++; SpawnAsteroidOffCamera(AsteroidType.Big, asteroidsOnLastRound); } }

Не могу представить как бы я следил за всеми объектами без этого паттерна.

Flyweight

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

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

Лес до оптимизации
Лес до оптимизации
Лес после оптимизации
Лес после оптимизации

Теперь в нашей RAM лишь одна модель дерева и мы выиграли достаточно места для добавления чего-нибудь вроде жучков в этот лес. Этот пример и иллюстрации были нагло скопированы из главы об этом же паттерне в книге которую я очень рекомендую прочитать.

Давайте теперь подумаем о Unity. Выиграть память у вас не получится, потому что этот паттерн уже реализован за нас. Все объекты на сцене будут обращаться к ассетам по ссылке, не копируя данные которые там находятся. Но можно получить второй бонус - использовать один объект для задания настроек объектов. Наверняка у вас будет несколько видов гоблинов или машин которые отличаются друг от друга, но используют одни и те же данные для описания скорости передвижения или максимального здоровья. Иметь эти данные в одном месте удобно тем что при необходимости изменения этих настроек, все объекты получат изменения и будут использовать новые значения.

Наверняка вы догадались что это про ScriptableObject. В своей игре я использую этот подход для описания скорости пуль игрока и НЛО. Префабы у них разные из за различия в цвете, но данные они используют одни и те же. Пуля есть пуля.

Ответ от компании

Эта статья была не про процесс найма в компанию, но он выступил завязкой и думаю вам будет интересно узнать чем все закончилось) Спустя пару дней после того как я отправил результат, мне пришел ответ от компании. В нем меня поблагодарили за интерес и проделанную работу, и указали на пару "Ошибок".

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

Второе замечание было о "невозможности изменения настроек корабля из инспектора". Проверяющий не заметил что это выполняется через SO (поле для которого было на скрипте и его хорошо было видно).

И еще пара замечаний в том же духе о второй части тестового задания. Я написал письмо с ответом, в котором рассказал о своих решениях, но ответа так и не получил(

Оглядываясь назад я понимаю что моя ошибка в этом тоже есть. Я нарушил правило KISS - SO для проекта были лишними. В требованиях было сказано что кодовая база должна быть простой, а архитектура интуитивно понятной. Мой подход был бы плюсом для более крупного проекта, но для такого простого он излишен. Но мне было весело и я получил новый опыт, а это главное.

Надеюсь эта статься оказалась полезной и интересной для вас, буду рад услышать ваше мнение в комментариях. На этом я прощаюсь, всем удачи)

  • Книга, в которой более подробно описаны эти паттерны и множество других.
  • Мой гитхаб и рассматриваемый проект.
  • Мой вк
  • Почта для связи: e.makunev@gmail.com
11
2 комментария

Комментарий недоступен

Ответить

Насчет пула можно согласиться, но тут тоже проблема в том что размер увеличивается х2. Для оптимизации можно было создать хеш таблицу и получить сложность О(1), но не думаю что объектов в пуле будет так много что нужно над этим заморачиваться

А в чем проблема в моем подходе? EntityManager подписан на каждый из спавнеров и при получении события спавна реагирует соответствующе для каждого типа объекта. Возможно такой подход выйдет из под контроля когда вырастет размер игры, но сейчас все работает отлично

Если паттерн создания общий то и объект будет одмнаковый, разве нет?) Фабрики в моем понимании для того чтобы работу создания объекта поручить другому объекту и благодаря этому не забывать об инициализации или еще о чем нибудь важном

Не совсем какие контроллеры, но EntityManager на Awake подписывается на спавнеров. Я знаю что иметь всю логику в монобехах это не круто с точки зрения оптимизации, но здесь это необходимо

В любом случае оптимизировать нужно когда надо, иначе код будет еще сложнее

Ответить