2889

Прийоми під час проєктування архітектури ігор

Інформацію про проєктування архітектури ігор досить важко знайти. У більшості вона не систематизована, і для пошуку потрібної інформації доводиться занурюватися в товщу води. Саме для того, щоб спростити розробникам-початківцям життя, в даній публікації зібрана деяка кількість інформації, яка, ймовірно, буде вам корисна.

Для вивчення, ми будемо використовувати дуже популярний двигун Unity3D.

У статті ми розглянемо такі теми:

  • Складні ієрархії класів юнітів, предметів та іншого
  • Спадкування VS компоненти
  • Абстракції ігрових об'єктів
  • Спрощення доступу до інших компонентів в об'єкті, сцені
  • Складні складові ігрові об'єкти
  • Характеристики об'єктів у грі
  • Машини станів, дерева поведінки
  • Серіалізація даних
  • Модифікатори (бафи/дебафи)

Складні ієрархії класів юнітів, предметів та іншого

Більшість розробників, які мають належного досвіду в КОП, припускаються схожу помилку при проєктуванні складних систем класів, як юнітів, так предметів.

Для того, щоб текст був не таким об'ємним, юніти можуть бути і персонажами, і будівлями.

unit tree 1

 

У цьому прикладі червоними лініями виділено місця, де можуть виникати помилки. Причина цього в тому, що успадкований тип повинен мати властивості та поведінку різних типів. Такий приклад не вирішується, через регулярні зміни всіх класів у цій ієрархії.

На прикладі нижче ми покажемо, як може виглядати частина цієї схеми в  КОП.

unit tree 3

 

У такому варіанті всі об'єкти збираються з компонентів, і завдяки цьому не потрібно при зміні одного об'єкта змінювати інші. До того ж, вам більше не доведеться витрачати свій час для створення ієрархії класів, що підходить всім створеним об'єктам.

Спадкування VS компоненти

Ні для кого не секрет, що у великих іграх досить складна архітектура. Складна сутність, складна взаємодія між класами. Використання стандартного ООП підходу тягне за собою постійні переробки коду, внаслідок чого час розробки значно збільшиться.

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

КОП (компонентно-орієнтовне програмування) було сформовано саме як вирішення цієї проблеми. Коротко, принцип роботи КОП така:
«Є клас-контейнер, а також клас-компонент, який можна додати в клас-контейнер. Об'єкт складається з контейнера та компонентів у цьому контейнері.»

Компоненти в чомусь схожі на інтерфейси. При цьому інтерфейси дають можливість тільки виділити у класів загальну сигнатуру функцій і властивостей, а компоненти дозволяють окремо винести загальну реалізацію класів. У підході ООП об'єкт визначається приписаним йому класом.

У підході КОП об'єкт визначають компоненти, з яких він складається. Не важливо, який це об'єкт, важливо з яких компонентів він складається, і що він вміє виконувати. Працюючи з КОП, можна спростити собі завдання повторного використання вже написаного коду, адже ви просто вставляєте готовий компонент в різні об'єкти. Завдяки цьому комбінуючи різні компоненти, можна зібрати новий тип об'єкта.

Візьмемо, наприклад, об'єкт “Персонаж”. Створюючи його через ООП, ви отримали б один великий клас, можливо, успадкований від чогось. Для КОП це комбінація компонентів, з яких і складається об'єкт “Персонаж”.

Наприклад:
Управління персонажем CharacterController, обробник зіткнень CharacterCollisionHandler, характеристики/стати персонажа компонент Stats, анімація персонажа CharacterAnimationController.

Не потрібно повністю перестати використовувати спадкування в іграх. Це цілком нормальна практика. Є ситуації, коли це буде обґрунтовано, але якщо ви бачите, що рівнів успадкування класів буде кілька — використовуйте компоненти.

Абстракції ігрових компонентів

Так як ви можете не знати точно, які зміни можуть відбутися з геймплеєм у вашій грі, не варто сприймати об'єкт Player як керований гравцем персонаж. Також варто задуматися, що цим персонажем може керувати не тільки людина, і не тільки комп'ютер.

Player (це може бути як людина, так і комп'ютер, але не варто їх змішувати в одному класі) окремий об'єкт.

Юніт, або персонаж - окремий об'єкт, керувати яким може будь-який гравець. В іграх жанру "Стратегії" можна винести ще один окремий об'єкт - "Загін".

Слідувати подібною логікою та розділяти різноманітні ігрові об'єкти нескладно. При цьому використовувати такий поділ можна в іграх абсолютно різних жанрів.

Спрощення доступу до інших компонентів в об'єкті, сцені

Якщо об'єкт складається з багатьох компонентів, може виникнути проблема при необхідності звернення до них. Причина цього — потреба створювати у кожному компоненті поля, у яких зберігатимуться посилання інші компоненти, чи звертатися до них через GetComponent().
Це можна виправити, реалізуючи патерн медіатор, тобто наше завдання створити щось подібне, компонент-посередник. З його допомогою компоненти могли б звертатися один до одного. До того ж це дозволить винести перевірку існування інших компонентів на даний компонент, завдяки чому код потрібно написати лише 1 раз.

Подібний компонент краще створювати різним для різних типів об'єктів, тому що в них використовують різні набори компонентів. У такому випадку ми просто кешуємо посилання в одному класі для зручності доступу до інших компонентів об'єкта.

Наприклад:

public class CharacterLinks : MonoBehaviour
{

   public Stats stats;
   public CharacterAnimationController animationController;
   public CharacterController charactercontroller;


   void Awake()
   {
       stats = GetComponent‹Stats>();
       animationController = GetComponent‹CharacterAnimationController›();
       characterController = GetComponent<CharacterController›();
   }
}

public class CharacterAnimationController : MonoBehaviour
{

   CharacterLinks_links;

   void Start()
   {

        _links = GetComponent<CharacterLinks>();
   }

   void Update()
   {

        if (_links.characterController.isGrounded)
            ...
   }
}

У сценах аналогічна ситуація. Можна в певному об'єкті синглтоне зробити посилання на компоненти, що часто використовуються, щоб в інспекторі конкретних компонентів не доводилося постійно вказувати посилання на інші об'єкти.

Приклад:

public class GameSceneUILinks: MonoSingleton<GameSceneUILinks>
{
    public MainMenu MainMenu;
    public SettingsMenu SettingsMenu;
    public Tooltip Tooltip;
}

Використання:

GameSceneUILinks.Instance.MainMenu.Show();

Завдяки тому, що компоненти потрібно вказувати лише в одному об'єкті, обсяг роботи в редакторі зменшиться, як і кількість коду.

Складні складові ігрові об'єкти

Обов'язково потрібно опрацювати ієрархію таких об'єктів як UI елементи, персонажі, і навіть деякі інші об'єкти, так як вони складаються із досить великої кількості компонентів-скриптів, адже це може сильно ускладнити розробку.

Ми можемо запропонувати вам два випадки, коли дійсно варто продумати ієрархію об'єкта:

  • У процесі гри певні частини об'єкта повинні замінюватися іншими.
  • Деяка частина скриптів має спрацьовувати в одній сцені, а інша в іншій.

Спершу ми розберемо перший випадок.

Повністю замінити об'єкт не проблема, але завдання відчутно ускладнюється, якщо об'єкт яким ми замінили, повинен мати ті ж дані, і знаходитися в такому стані, що і початковий. Щоб це було простіше, потрібну частину об'єкта треба структурувати.

Наприклад, це можна зробити так:

  • Character
  • Data
  • ControlLogic (скрипти для керування персонажем)
  • RootBone (рутова кістка персонажа; компоненти Animator та скрипти для роботи з IK повинні бути тут, інакше вони не працюватимуть)
  • Animation (інші скрипти для роботи з анімацією)
  • Model

Завдяки такій організації змінити контролер анімації, або вид об'єкта буде простіше, а також це не сильно торкнеться інших компонентів.

У другому випадку ми беремо якийсь об'єкт із даними та спрайтом. При натисканні на нього у сцені покращень, потрібно покращити цей об'єкт. А під час кліку по ньому в сцені гри, має проводитись певна ігрова дія.

Для початку можна зробити 2 префаби, але якщо об'єктів багато, то і кількість префабів буде збільшуватися.

Тоді ми можемо піти іншим шляхом і структурувати так:

  • ObjectView (картинка об'єкта)
  • Data (дані об'єкта, що використовуються в обох сценах)
  • UpgradeLogic (кнопка та скрипти для сцени покращень)
  • GameLogic (кнопка та скрипти для сцени гри)

Поле targetGraphic у кнопках має посилатися на картинку в ObjectView.
Цей підхід перевірявся в uGUI.

Характеристики об'єктів у грі

В багатьох іграх предмети, навички, персонажі наділені характеристиками. Це може бути здоров'я, міцність, запас сил, потужність. При цьому набір характеристик залежить від типу. Для зручності роботи ми можемо порадити винести характеристики в окремі компоненти. Тобто, функціонал та дані мають бути окремо. Найкраще, щоб не заплутатися в цій складній системі, зберігати ці класи у словнику, який у свою чергу перебуватиме в класі, який контролює роботу з цим словником.

Ще вам може знадобитися завести додатковий клас, у якому зберігатимуться самі значення у словнику. Таким чином, словник зберігатиме не самі значення, а екземпляри класу-обгортки для цих значень.

У такому класі може бути:

  • Подія, що викликається за зміни значення;
  • Значення, яке може бути кількох видів, наприклад:
  1. мінімальне та максимальне значення (наприклад, випадкова сила атаки в діапазоні значень 20 - 40);
  2. поточне значення;
  3. поточне та максимальне значення (наприклад, мана).

Так як набори статів у навичок, персонажів та предметів різні, досягти універсальності буде нелегко. До того ж для запобігання плутанині потрібно виключити можливість перетину цих наборів. Для цього ми радимо зберігати їх у різних enum-ax. Ще виникає проблема завдання показників в інспекторі, так як словники та тип “object” не серіалізуються у Unity3D.

Загалом ми не радимо гнатися за універсальність, адже часто вистачає лише одного типу даних (float або int), щоб ваша робота стала простішою. Додатково можна винести унікальні характеристики окремо від словника.

Машини станів, дерева поведінки

Щоб спростити роботу з юнітами, персонажами (тобто об'єктами зі складною поведінкою) використовуються дерева поведінки та машини станів.

Почнемо з машини станів. Для її використання, логіку об'єкта потрібно розбити на переходи, події, стани, можливо, вам знадобиться розбивати ще й на дії. Варіанти, щоб реалізувати ці елементи можуть сильно відрізнятися.

Стан об'єкта може грати роль класу, що не володіє ігровою логікою, в якому зберігаються певні дані, наприклад, назва стану об'єкта: захист, кидок. В іншому варіанті клас “стан” може описувати поведінку об'єкта в певному стані.

Дія – доступна до виконання в даному стані функція.

Перехід — зв'язок між декількома станами, що вказує на можливість переходу з одного до іншого.

Подія — використовується для вказівки потреби виконати перехід у інший стан, за умови, що цей перехід можливий з поточного стану.

На скріншоті ви можете бачити графік станів, яку виконали в програмі PlayMaker.

e6667ca774f94128b182b3dfd3fe3d40

 

У плагіні Behaviour Machine цікаво працює машина станів. Там MonoBehaviour компонент є станом, який відповідає за логіку роботи у цьому стані. Так само станом може бути і дерево поведінки. Тепер ми розглянемо ієрархічну машину станів.

Ієрархічна машина станів використовується, щоб спростити роботу коли станів багато. Адже це збільшує кількість зв'язків між ними. Головна відмінність ієрархічної машини станів у тому, що як стан вона використовує вкладену машину станів, завдяки цьому виходить деревоподібна ієрархія станів.

Дерево поведінки

Щоб прописати штучний інтелект в іграх було простіше, використовують дерева поведінки (Behavior Tree).

Дерево поведінки - структура деревоподібної форми, яка використовує деякі блоки ігрової логіки як вузли. Розробник, використовуючи ці блоки у візуальному редакторі, створює гіллясту структуру, а потім налаштовує вузли дерева. За допомогою саме цієї структури персонаж буде взаємодіяти з ігровим світом, а також приймати рішення за будь-яких змін.

Кожен задіяний вузол повертатиме результат, це дозволяє з'ясувати, як оброблятимуться інші вузли дерева. Повертається результат зазвичай буває трьох видів: “Успіх”, “Невдача” і “Виконується”.

Зараз ми швидко розберемо основні типи вузлів у дереві поведінки:

Condition (умова) — функція, яка дозволяє визначити, чи повинні виконуватися вузли, що за нею слідують. При false в результаті ви отримаєте "Ffail", а при "Ttrue" - "Success".

Iterator (замінює цикл "for") - функція, яка дозволяє використовувати в циклі серії дій кілька разів.

Action Node (Дія) – певна функція, яка виконується при відвідуванні даного вузла.

Sequencer (послідовність) - по порядку виконує всі вкладені в неї вузли. Якщо якийсь із вузлів завершитися невдало, ви отримаєте "Fail", якщо всі вузли успішно закінчаться, вам повернеться результат "Success"

Selector - на відміну від Sequencer, обробка припиняється відразу після того, як будь-який із вкладених вузлів поверне "Success".

Parallel Node - створює ілюзію виконання всіх вторинних вузлів "одночасно". Насправді це не так, відбувається процес, аналогічний корутинам у Unity 3d.

Скріншот показує вам дерево поведінки, яке було створено на базі плагіна Behaviour Machine.

behavior tree

 

В який момент тоді варто використовувати дерево поведінки, а коли машину станів?

Є книга, яка називається Artificial Intelligence for Games II. У ній говориться, що дерева поведінки складно реалізовані, якщо вони повинні давати відгук на зміни ззовні. У цій же книзі є два рішення, які можна використати:

Перше: запровадження поняття “завдання” в дерево поведінки. Передбачається, що, щоб змінити поведінку, контролер дерева поведінки переходитиме на інший вузол-завдання.

Друге: використовувати те, що вже реалізовано у плагіні Behaviour Machine, тобто об'єднати машину станів та дерево поведінки.

При використанні першого рішення, робота помітно ускладнюється, адже вам потрібно створити не тільки зміну завдань, але й опрацювати "обнулення" змінних задач (завершеної або скасованої).

Ми ж порадимо вам дотримуватися такої логіки - якщо AI самостійно отримує інформацію з ігрового світу, то краще використовувати Behavior Tree. Якщо "іскін" чимось управляється, гравцем чи іншим AI (наприклад, юніт в іграх стратегіях, де він управляється "гравцем" - комп'ютером), тоді вигідніше задіяти стейт-машину.

Серіалізація даних

При масі зручних альтернатив (JSON, SQLite) розробники все ще часто використовують XML. Звичайно, вибір залежить не тільки від зручності, але і від поставлених завдань.

На жаль, багато творців комп'ютерних ігор, які використовують JSON або XML, роблять це не оптимізовано. Створюється купа коду, яка служить лише для створення/читання/запису структур даного формату, при цьому ви обов'язково маєте вказувати у рядковому вигляді назви імен елементів, до яких треба звертатися. Щоб уникнути подібного, краще використати серіалізацію. У такому разі структура генеруватиметься за допомогою коду (Code first підхід).

Для того, щоб скористатися серіалізацією XML у Unity 3D, ви можете використовувати вбудовані в .NET інструменти. Для JSON є чудовий плагін JSONFx. Дані рішення ми перевіряли на Android, але працювати вони повинні на будь-якій іншій платформі, адже використовуване API, застосовувалося і в сторонніх кросплатформових плагінах.

Модифікатори (бафи/дебафи)

Завдяки певним ефектам характеристики будь-якого ігрового об'єкта можуть змінюватися. У цьому контексті сутність, яка змінює ці характеристики, можна назвати модифікатором.

Модифікатор - певна величина, компонент, в ньому записані характеристики, на які може вплинути. Тобто, коли персонаж піддається якомусь ефекту, нею використовується модифікатор (компонент).

Після цього даний модифікатор використовує функцію “застосувати себе до такого-то об'єкту”. У нашому випадку це об'єкт “Персонаж”. Після цього відбувається перерахунок параметрів “Персонажу”, які може змінити цей модифікатор. Після видалення модифікатора (наприклад, ефект мав певну тривалість), знову виконується перерахунок характеристик.

Можливо вам знадобиться зберігати два словники характеристик, в одному будуть актуальні (розраховані) характеристики, а в другому початкові.