Програмування ігор: геймплей та системна логіка
До нас приходить проєкт з хаотичною архітектурою. Розробник каже: «працює, не чіпай», але на ділі кожен новий рівень потребує окремого виправлення. Типова картина: контролер персонажа на 2000 рядків, де фізика, анімація, UI та звук перемішані в одному MonoBehaviour. Збереження через PlayerPrefs з ключами типу "player_hp_current_value_int". Це не гіпотетика — прямий результат відсутності архітектурного рішення на початку. Ми займаємося геймплейним програмуванням понад 7 років, реалізували понад 15 проєктів — від мобільних гіперказуалок до PC-шутерів. Гарантуємо, що після нашого втручання проєкт перестає бути «чорною скринькою». Якщо впізнали свою ситуацію — замовте безкоштовний аудит коду, оцінимо стан за 2 дні.
Ми беремо такий код, проводимо аудит і реорганізуємо його в модульну систему. Геймплейне програмування — серце гри. Тут закладається відчуття від керування, інтелект противників, чесна фізика та надійна система прогресу. Зроблено погано — жодний арт не врятує.
У матеріалі розберемо, як ми будуємо контролери, фізику, ШІ та систему збережень, щоб гра працювала передбачувано та без багів. Якщо ваш проєкт вже страждає від хаотичної архітектури — отримайте консультацію інженера до старту робіт.
Який контролер персонажа обрати?
Контролер персонажа задає тон усій грі. Перше, з чим стикається гравець — керування. Затримки, слизький рух, «залипання» на перешкодах — все це зчитується миттєво та псує враження раніше, ніж гравець встигає побачити геймплей. Базовий вибір зводиться до двох варіантів: CharacterController або Rigidbody.
CharacterController — вбудований компонент Unity, спеціалізований для персонажів. Ігнорує фізичний рушій для руху, але коректно обробляє сходи, похилі поверхні та перешкоди. Рекомендований для екшн-ігор, платформерів, шутерів від першої особи — там, де потрібен точний передбачуваний відгук.
Rigidbody — фізичний об'єкт. Необхідний, коли персонаж повинен взаємодіяти з фізичними об'єктами: штовхати ящики, реагувати на вибухи, бути підкинутим. Вимагає акуратної роботи через FixedUpdate та обережного вимкнення гравітації або тертя, щоб керування не здавалося «плаваючим».
Для більшості 3D-проєктів ми використовуємо CharacterController з кастомним обробником гравітації — це дає контроль без артефактів фізичного рушія. Для 2D — Rigidbody2D з constraints на обертання та ретельно налаштованим Collision Detection Mode: Continuous. Кожне рішення приймаємо під жанр та цільову платформу.
Фізика та колізії
Rigidbody та колайдери — джерело регулярних проблем, якщо їх не налаштувати правильно з самого початку. Декілька правил, які економлять час:
-
Collision Detection: Continuousдля швидких об'єктів (кулі, снаряди) — інакше вони «пролітають» крізь тонку геометрію - Складні меш-колайдери замінюємо складовими примітивами (Box + Capsule + Sphere) — це дешевше для фізики на 70-80%
- Шари (
Physics Layers) та матриця колізій вPhysics Settingsналаштовуються на початку проєкту — потім додати їх без рефакторингу дуже боляче - Всі фізичні обчислення — в
FixedUpdate, не вUpdate. Інакше поведінка залежить від FPS
Дотримання цих правил знижує кількість багів з колізіями на 90% вже на етапі прототипу. Типові помилки при налаштуванні фізики:
- Частинки або UI-об'єкти з колайдерами — невидимі перешкоди для куль
- Тригери, що висять на об'єктах без
Rigidbody— події не спрацьовують - Швидкість кулі > 100 м/с без
Continuous Dynamic— наскрізні прольоти - Один колайдер на складну сітку замість композитних — падіння FPS на 40-50%
Як архітектура ШІ впливає на ігровий досвід?
Поганий AI видно одразу: противники застрягають у кутах, атакують крізь стіни, передбачувано патрулюють за одним маршрутом. Різниця між «працює» та «працює добре» тут найбільш відчутна. Розглянемо три рівні деталізації.
Кінцеві автомати (State Machines)
Найпоширеніший підхід — ієрархічний кінцевий автомат (HSM). Кожен стан: Idle, Patrol, Chase, Attack, Dead — це клас або метод з входом, оновленням та виходом. Вікіпедія описує кінцевий автомат як математичну модель поведінки з фіксованим набором станів і переходів.
public enum EnemyState { Idle, Patrol, Chase, Attack, Dead } private void UpdateStateMachine() { switch (_currentState) { case EnemyState.Patrol: UpdatePatrol(); if (CanSeePlayer()) TransitionTo(EnemyState.Chase); break; case EnemyState.Chase: _navMeshAgent.SetDestination(_player.position); if (InAttackRange()) TransitionTo(EnemyState.Attack); if (!CanSeePlayer() && _lostSightTimer > 5f) TransitionTo(EnemyState.Patrol); break; // ... } } State Machine добре працює для противників з невеликою кількістю станів (5-8). При зростанні складності — вибухове зростання переходів між станами, код стає важко читати та тестувати.
Behaviour Trees
Behaviour Tree — наступний рівень. Дерево поведінки описує логіку агента через ієрархію задач: Sequence, Selector, Decorator, Leaf. Вікіпедія визначає Behaviour Tree як граф, що дозволяє модульно будувати складну поведінку.
Перевага перед State Machine: кожен вузол атомарний і перевикористовуваний. Вузол CheckLineOfSight написаний один раз і використовується в десяти деревах. Додати нову поведінку — означає додати гілку в дерево, не рефакторити існуючу логіку. Для проєктів з понад 5 типами ворогів BT скорочує час на додавання нового типу в 2-3 рази порівняно зі State Machine.
У Unity BT реалізується через асети (NodeCanvas, Behaviour Designer) або кастомну реалізацію. Для великих проєктів з кількома типами ворогів це окупається вже на етапі другого типу противника. Приклад структури дерева для патрульного противника:
Root └── Selector ├── Sequence (Combat) │ ├── IsPlayerVisible │ ├── IsPlayerInRange │ └── AttackPlayer ├── Sequence (Alert) │ ├── HeardSound │ └── InvestigatePosition └── Sequence (Patrol) ├── HasPatrolRoute └── FollowPatrolRoute GOAP — коли BT недостатньо
Goal-Oriented Action Planning — підхід для дійсно складного AI, де агент повинен планувати послідовність дій для досягнення мети з урахуванням поточного стану світу. Класичний приклад — противник, якому потрібно «вбити гравця». Якщо в нього немає зброї, він шукає зброю. Якщо немає боєприпасів, він шукає патрони. Якщо гравець сховався, він шукає обхідний маршрут. GOAP дозволяє задати ці дії та їх передумови/постумови, а планувальник будує ланцюжок автоматично.
GOAP значно складніший у реалізації, ніж BT, і виправданий не завжди. Для платформерів і казуальних ігор це надмірно. Для тактичних ігор, симуляторів виживання, стелс-екшн — може бути правильним вибором.
NavMeshAgent та навігація
NavMeshAgent — стандартний інструмент для навігації в Unity. Працює коректно при правильному налаштуванні NavMesh та агентів:
-
Agent RadiusтаAgent Heightповинні точно відповідати колайдеру персонажа -
Stopping Distanceпотрібно налаштовувати під дальність атаки кожного типу ворога -
NavMesh ObstacleзCarve: trueдля динамічних перешкод (ящики, що падають, двері, що зачиняються) — інакше агенти будуть намагатися пройти крізь них - Для великих відкритих світів —
NavMesh Linksдля з'єднання окремих сегментів таOff-Mesh Linksдля стрибків і спусків
| Критерій | State Machine | Behaviour Tree |
|---|---|---|
| Перевикористання логіки | Низьке (стани прив'язані до контексту) | Високе (вузли незалежні) |
| Масштабування | Вибух переходів при 10+ станів | Лінійне зростання дерева |
| Налагодження | Складне (потрібен повний стейт-трекер) | Просте (видно поточний вузол) |
| Складність впровадження | Низька (старт за 1 день) | Середня (3-5 днів на налаштування) |
| Рекомендований обсяг | До 6 типів ворогів | Від 6 типів ворогів |
Чому система збережень потребує версіонування?
Друга область, де архітектурні рішення на початку критично впливають на все подальше. Збереження, додані в кінці розробки «за тиждень», майже завжди ламаються при зміні структури даних. Розглянемо інструменти.
PlayerPrefs — коли підходить і коли ні
PlayerPrefs — це сховище простих ключ-значення (string, int, float). Підходить суворо для налаштувань (гучність, керування, мова). Використовувати його для зберігання стану ігрового світу — помилка: немає типізації, немає версіонування, немає зручного дебагу.
JSON-серіалізація
Робочий підхід для більшості проєктів — серіалізація даних у JSON через JsonUtility (вбудований, швидкий, але обмежений) або Newtonsoft.Json (повноцінний, підтримує словники, успадкування, nullable типи). Структура системи збережень:
[Serializable] public class SaveData { public int version = 1; // версіонування public PlayerSaveData player; public WorldSaveData world; public SettingsSaveData settings; } public class SaveSystem : MonoBehaviour { private const string SAVE_FILE = "/save.json"; public void Save(SaveData data) { string json = JsonConvert.SerializeObject(data, Formatting.Indented); File.WriteAllText(Application.persistentDataPath + SAVE_FILE, json); } public SaveData Load() { string path = Application.persistentDataPath + SAVE_FILE; if (!File.Exists(path)) return new SaveData(); string json = File.ReadAllText(path); return JsonConvert.DeserializeObject<SaveData>(json); } } ScriptableObject як контейнер даних
ScriptableObject — недооцінений інструмент для зберігання ігрових даних. Конфігурації предметів, характеристики противників, параметри рівнів — все це зручніше тримати в ScriptableObject, ніж у JSON або константах у коді. Для збережень ScriptableObject використовується в патерні Runtime Set і Variable: значення зберігаються в ScriptableObject, збереження записує лише дельту відносно дефолтних значень.
Версіонування збережень
Поле version в корені SaveData — не бюрократія, а необхідність. Коли після релізу додається нова механіка з новими полями, потрібно коректно мігрувати старі збереження. Міграційний метод:
private SaveData MigrateSaveData(SaveData data) { if (data.version < 2) { data.player.newField = defaultValue; data.version = 2; } if (data.version < 3) { // наступна міграція data.version = 3; } return data; } Без версіонування доводиться вибирати між зламаними збереженнями у гравців або відмовою від зміни структури даних.
Що дає ScriptableObject-архітектура?
Для середніх і великих проєктів ми використовуємо підхід, популяризований Ryan Hipple на GDC: ScriptableObject як шина подій та сховище спільного стану.
// Змінна-подія [CreateAssetMenu] public class GameEvent : ScriptableObject { private List<GameEventListener> _listeners = new(); public void Raise() { for (int i = _listeners.Count - 1; i >= 0; i--) _listeners[i].OnEventRaised(); } } Це дозволяє системам у грі взаємодіяти без прямих посилань одна на одну. PlayerHealth не знає про UI, UI не знає про GameManager — всі вони знають тільки про ScriptableObject-події. Проєкт стає значно легше тестувати та розширювати.
Як ми працюємо: етапи та результат
Кожен проєкт проходить через п'ять етапів. Нижче — орієнтовні терміни та ключові артефакти. Вартість кожного етапу розраховується індивідуально.
| Етап | Тривалість (робочі дні) | Результат |
|---|---|---|
| Аудит поточної архітектури | 1-3 | Документ з проблемами та рекомендаціями |
| Проектування систем | 2-5 | Архітектурна схема, опис модулів |
| Реалізація (ітерації) | від 10 | Робочий код, протестований у зв'язці з артом |
| Код-рев'ю та рефакторинг | 2-4 | Чиста кодова база, коментарі до складних ділянок |
| Документація та здача | 1-2 | Гайд для команди, опис налаштувань (Physics Layers, NavMesh тощо) |
Терміни варіюються залежно від обсягу legacy-коду та складності механік. Отримайте консультацію інженера до старту робіт — переконаємося, що підхід підходить саме вашому проєкту.
Що входить у роботу
Після завершення ви отримуєте:
- Архітектурну документацію ігрових систем (контролер, AI, збереження)
- Вихідний код з коментарями, готовий до подальшої розробки
- Налаштування інструментів: Physics Layers, NavMesh, проєкт ScriptableObject-подій
- Код-рев'ю існуючих модулів (якщо проєкт не з нуля)
- Гарантію: безкоштовна підтримка на етапі інтеграції протягом 2 тижнів після здачі
Зв'яжіться з нами, щоб обговорити деталі. Замовте аудит архітектури — це безкоштовно та займе не більше 3 робочих днів.






