Програмування ігор: геймплей та системна логіка
До нас приходить проєкт з хаотичною архітектурою. Розробник каже: «працює, не чіпай», але на ділі кожен новий рівень потребує окремого виправлення. Типова картина: контролер персонажа на 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 робочих днів.






