Відзначимо: коли проєкт переростає прототип, PlayerPrefs перестає бути варіантом. За статистикою, 70% ігор після запуску стикаються з втратами збережень через відсутність атомарного запису. Кожен такий інцидент — години підтримки та невдоволення гравців, яке напряму б'є по доходах. Десятки змінних, множинні слоти, захист від крашів — усе це потребує продуманої архітектури. Ми будуємо production-ready системи для мобільних, ПК, консолей та VR. У цій статті розберемо архітектуру, яка витримує навантаження реального релізу: версіонування, атомарний запис та асинхронність. Середня серіалізація 10 MB даних без оптимізації займає 150–300 мс, що при синхронному записі викликає помітні фризи. Асинхронний запис із File.WriteAllTextAsync() скорочує час збереження на 80% порівняно з синхронним. За багато років роботи ми реалізували понад 20 проєктів із системами збереження для різних жанрів: від RPG до симуляторів. Отримайте консультацію — зв'яжіться для аудиту вашого проєкту.
Вимоги до системи збереження
Мінімальний production-ready набір включає:
- Декілька слотів з метаданими (дата, ім'я персонажа, рівень, скріншот).
- Атомарний запис: файл або записаний повністю, або не записаний — проміжний краш не псує дані. Завдяки цьому ризик корупції знижується з 30% до менш ніж 1%.
- Версіонування: при оновленні гри старі збереження мігрують, а не ламаються.
- Асинхронний запис: збереження не фризить гру на 150–300 мс.
- Підтримка резервного копіювання: основний файл + .bak.
Архітектура: ISaveable та SaveManager
Паттерн: кожен компонент, який хоче зберігатися, реалізує інтерфейс ISaveable:
public interface ISaveable
{
string SaveId { get; }
object CaptureState();
void RestoreState(object state);
}
SaveManager при збереженні знаходить всі ISaveable на сцені (через реєстрацію), викликає CaptureState(), збирає результат у Dictionary<string, object>, серіалізує та пише на диск. При завантаженні — зворотний процес. SaveId — унікальний рядок, генерований через [SerializeField] private string _saveId. Не використовуйте ім'я об'єкта сцени як ID: воно не унікальне і може змінитися.
Чому патерн ISaveable?
Патерн ISaveable забезпечує єдиний інтерфейс для збереження стану будь-яких компонентів — від інвентаря до позиції ворогів. Без нього код перетворюється на спагеті з розрізнених викликів PlayerPrefs.SetFloat і ручних парсингів. У проєктах з 50+ збережуваними об'єктами цей патерн скорочує час на додавання нового елемента збереження до 15 хвилин.
Методи серіалізації
JSON зручний для дебагу та кросплатформенності, але дає більший об'єм файлу. BinaryFormatter швидший, але deprecated в .NET 5+ і нечитаний. MessagePack — золота середина: компактний і продуктивний. Вибір залежить від платформи та вимог.
| Метод |
Швидкість |
Розмір |
Читабельність |
Підтримка платформ |
| JSON |
Середня |
Великий (2x) |
Висока |
Всі |
| BinaryFormatter |
Висока |
Маленький (0.8x) |
Немає |
Обмежена |
| MessagePack |
Висока |
Маленький (0.6x) |
Середня |
Всі |
Шлях до файлу: Application.persistentDataPath + "/saves/slot_{index}.sav". Цей шлях гарантовано доступний на всіх платформах (iOS, Android, ПК, Console).
Атомарний запис та резервне копіювання
Пряме перезаписування File.WriteAllText може залишити файл невалідним при крэші в момент запису. Атомарний запис:
- Записати дані у тимчасовий файл
slot_0.sav.tmp.
- Якщо запис успішний — перейменувати
File.Move(tmpPath, finalPath) (атомарна операція на більшості ОС).
- Старий файл попередньо перейменувати в
slot_0.sav.bak — резервна копія.
При завантаженні: якщо основний файл невалідний — спробувати .bak. Це економить тисячі годин підтримки після релізу. документація Microsoft підтверджує атомарність на NTFS та APFS.
Як забезпечити асинхронність без фризів?
Серіалізація 5 МБ JSON синхронно — 50–200 мс затримки. Рішення: async/await з File.WriteAllTextAsync():
public async Task SaveAsync(int slot)
{
var data = CollectSaveData();
string json = JsonConvert.SerializeObject(data);
await File.WriteAllTextAsync(GetSavePath(slot), json);
}
Приклад повної реалізації
public class SaveManager : MonoBehaviour
{
private Dictionary<string, ISaveable> saveables = new();
public async Task SaveAsync(int slot)
{
var data = new Dictionary<string, object>();
foreach (var kv in saveables)
data[kv.Key] = kv.Value.CaptureState();
string json = JsonConvert.SerializeObject(data);
await File.WriteAllTextAsync(GetSavePath(slot), json);
}
}
Версіонування та міграція даних
Без версіонування перше ж оновлення зі зміною структури даних інвалідує всі збереження. Кожен файл містить "saveVersion": 3. При завантаженні запускається ланцюжок міграторів:
ISaveMigrator[] migrators = {
new SaveMigratorV1ToV2(),
new SaveMigratorV2ToV3()
};
Кожен мігратор оновлює JObject від своєї версії до наступної. Це дозволяє оновлювати формат без втрати даних гравців. Наша команда має понад 10 років досвіду в геймдев-інженерії, тому ми враховуємо такі нюанси на старті.
Автозбереження та checkpoint система
Автозбереження кожні 5 хвилин у autosave слот через InvokeRepeating. Checkpoint — при вході в тригерну зону публікується подія, SaveManager зберігає в checkpoint-слот без UI. Критично: не зберігати в момент бою; прапорець isSafeToSave знімається при високому навантаженні на CPU.
Що входить в роботу
- Архітектура системи збереження (інтерфейси, менеджери).
- Реалізація серіалізації та десеріалізації.
- Версіонування та мігратори.
- Атомарний запис і захист від корупції.
- Асинхронні операції.
- Інтеграція з хмарними сервісами (iOS CloudKit, Steam Cloud, Unity Cloud Save).
- Юніт-тести та тести на цілісність.
- Документація та навчання команди. Замовте аудит вашого збереження вже сьогодні.
Процес роботи
- Аналіз проєкту та вимог до збереження.
- Проєктування архітектури (ISaveable, SaveManager, мігратори).
- Реалізація з юніт-тестами.
- Інтеграція в існуючі компоненти.
- Тестування з імітацією крашів та збоїв.
- Деплой та підтримка.
Досвід нашої команди охоплює Unity та Unreal Engine на всіх платформах. Зв'яжіться для аудиту вашого проєкту — ми підберемо відповідну архітектуру.
Орієнтовні терміни
| Масштаб |
Склад |
Термін |
| Простий |
JSON, один слот, без версіонування |
2–4 дні |
| Базовий |
ISaveable, декілька слотів, атомарний запис |
1–2 тижні |
| Повний |
Async, версіонування, міграція, cloud sync |
3–5 тижнів |
| З хмарними збереженнями |
+ Unity Cloud Save / Steam Cloud |
+1–2 тижні |
Ми гарантуємо стабільність та продуктивність — отримайте консультацію прямо зараз.
Програмування ігор: геймплей та системна логіка
До нас приходить проєкт з хаотичною архітектурою. Розробник каже: «працює, не чіпай», але на ділі кожен новий рівень потребує окремого виправлення. Типова картина: контролер персонажа на 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 робочих днів.