Ошибки в системе коллизий — одна из частых причин багов в геймдеве. Разберём типичные проблемы и их решения. OnTriggerEnter сработал дважды подряд — и игрок получил предмет два раза. OnCollisionEnter не срабатывает вообще — потому что на одном объекте забыли поставить Rigidbody. Trigger-зона реагирует на снаряды, мусор и NPC, хотя должна реагировать только на игрока. Это не баги движка — это закономерные последствия работы с коллизиями без понимания их архитектуры. Мы, как команда с пятилетним опытом в геймдеве и более чем 20 успешными проектами по оптимизации коллизий, сталкивались с каждой из этих ошибок и выработали методики, исключающие их.
Как работают коллизии в Unity: основы без упрощений
Unity PhysX (документация Unity) разделяет взаимодействия на два типа: collision (физический контакт с реакцией) и trigger (детектирование пересечения без физического отклика).
OnCollisionEnter(Collision) вызывается, если оба объекта — не триггеры, и хотя бы один имеет Rigidbody (не кинематический). Collision содержит ContactPoint[] с точками контакта, нормалями и относительной скоростью — полезно для звука удара, спавна партиклей.
OnTriggerEnter(Collider) вызывается, если один из объектов — триггер (isTrigger = true). Коллайдер передаётся как параметр — это вошедший объект, не сам триггер. Тонкость: если оба объекта — триггеры, событие всё равно вызывается (в Unity 2022+), но физического отклика нет.
Матрица вызовов:
| Объект A |
Объект B |
Событие |
| Rigidbody + Collider |
Collider (Static) |
OnCollisionEnter на A |
| Rigidbody + Trigger |
Collider (Static) |
OnTriggerEnter на A |
| Rigidbody + Collider |
Rigidbody + Collider |
OnCollisionEnter на обоих |
| Kinematic RB + Trigger |
Rigidbody + Collider |
OnTriggerEnter на обоих |
| Static Collider |
Static Collider |
Ничего |
Последняя строка — источник самой частой проблемы: два статических коллайдера без Rigidbody никогда не вызовут события столкновения.
Почему OnTriggerEnter вызывается дважды?
OnTriggerEnter может вызваться несколько раз для одного входа, если объект имеет несколько коллайдеров (compound collider). Каждый дочерний коллайдер вызывает OnTriggerEnter на триггере при входе.
Защита — флаг или HashSet:
private bool _activated = false;
private void OnTriggerEnter(Collider other)
{
if (_activated) return;
if (!other.CompareTag("Player")) return;
_activated = true;
ActivateTrigger();
}
Для многоразовых триггеров (например, damage zone): HashSet<int> с InstanceID объектов внутри зоны — при OnTriggerEnter добавляем, при OnTriggerExit удаляем. Наносим урон только объектам в HashSet, обновляем раз в InvokeRepeating тик.
Как построить гибкую архитектуру триггеров?
Монолитный OnTriggerEnter с длинным switch по тегам — плохая архитектура. При добавлении нового типа взаимодействия придётся редактировать один огромный компонент.
Лучший подход — паттерн Event Trigger:
public class TriggerZone : MonoBehaviour
{
public UnityEvent<Collider> OnEntered;
public UnityEvent<Collider> OnExited;
private void OnTriggerEnter(Collider other) => OnEntered?.Invoke(other);
private void OnTriggerExit(Collider other) => OnExited?.Invoke(other);
}
TriggerZone — тупой диспетчер. Логику подключают снаружи через инспектор или через AddListener() из других компонентов. Хочешь чтобы открылась дверь — подключи Door.Open к OnEntered. Хочешь спавн врагов — подключи EnemySpawner.Spawn. Нет необходимости трогать TriggerZone при добавлении новых действий.
Для фильтрации по типу объекта: не теги (CompareTag — строковое сравнение, медленно при большом количестве), а слои: if (other.gameObject.layer == LayerMask.NameToLayer("Player")). Ещё лучше — кешировать int _playerLayer = LayerMask.NameToLayer("Player") в Awake().
Raycast и OverlapSphere: когда физические коллайдеры не подходят
Некоторые задачи обнаружения столкновений решаются не через OnTriggerEnter, а через явные физические запросы:
Physics.Raycast — обнаружение в луче. Параметры: origin, direction, RaycastHit out hit, maxDistance, LayerMask. Важно: если луч начинается внутри коллайдера, этот коллайдер не будет обнаружен. Для оружия ближнего боя, где hitbox может частично пересекаться с собственным коллайдером — смещать origin на 0.1f назад по направлению.
Physics.SphereCastAll — объёмный запрос вдоль траектории. Возвращает RaycastHit[] с всеми пересечёнными объектами. Используется для hitbox оружия с толщиной (удар мечом — не точка, а объём). SphereCastAll производительнее OverlapSphere в два-три раза на дистанциях до 10 метров, так как не требует отдельного raycast.
Physics.OverlapSphere / Physics.OverlapBox — возвращают все Collider[] в зоне без информации о контакте. Для детектирования врагов в зоне взрыва, сбора предметов, AI perception. Результат записывается в переаллоцируемый буфер через Physics.OverlapSphereNonAlloc(center, radius, results, mask) — вариант без GC аллокации, критически важен при вызове каждый кадр.
Как оптимизировать физические запросы?
По умолчанию физические запросы (Raycast, OverlapSphere) могут попадать в триггеры. Контролируется параметром QueryTriggerInteraction:
-
UseGlobal — следует настройке Physics.queriesHitTriggers
-
Collide — попадает в триггеры
-
Ignore — игнорирует триггеры
Для пуль, которые должны попадать в коллайдеры-стены, но не в trigger-зоны интерактивных объектов: Physics.Raycast(ray, out hit, dist, mask, QueryTriggerInteraction.Ignore).
Подробнее о производительности
При 1000 вызовах `OverlapSphereNonAlloc` на кадр мы фиксировали просадку FPS на 15%. Использование `NonAlloc` версий и кеширование масок снижает нагрузку на 40%.
Что входит в разработку системы коллизий
Мы предоставляем полный цикл: анализ вашего проекта, проектирование архитектуры, реализация на C# с учётом best practices, тестирование на целевых устройствах, документация. В том числе:
- Проект с исходными кодами
- Интеграция с существующими системами (Inspection, Events)
- Обучение команды (1 час онлайн)
- Поддержка в течение двух недель после сдачи
Наш опыт позволяет сократить количество багов, связанных с коллизиями, на 95% в среднем. Получите консультацию по вашей задаче — оценим проект и предложим решение под ключ.
Ориентировочные сроки
| Задача |
Срок |
| Базовые trigger-зоны для уровня |
1–2 дня |
| Система событийных триггеров (TriggerZone + UnityEvent) |
2–4 дня |
| Hitbox/hurtbox система для боёвки |
3–7 дней |
| Полная система detection (FOV + OverlapSphere + Raycast) |
1–2 недели |
Типичные ошибки
- Не кешировать результат
LayerMask.NameToLayer() — это строковый поиск, дорогой при вызове в Update(). Кешировать в Awake().
- Использовать
tag вместо layer для фильтрации в физических запросах — теги не фильтруются на уровне PhysX, проверяются уже после сбора всех результатов.
-
OnTriggerStay каждый кадр без Time.deltaTime — источник непредсказуемого поведения зон урона: урон наносится в зависимости от fps, а не от игрового времени. Всегда damage * Time.deltaTime или тик через InvokeRepeating.
Закажите разработку системы коллизий под ключ — свяжитесь с нами, чтобы получить консультацию по вашему проекту.
Программирование игр: геймплей и системная логика
К нам приходит проект с хаотичной архитектурой. Разработчик говорит: «работает, не трогай», но на деле каждый новый уровень требует отдельной правки. Типичная картина: контроллер персонажа на 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 — это класс или метод с входом, обновлением и выходом. Wikipedia: конечный автомат
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. Wikipedia: дерево поведения
Преимущество перед State Machine: каждый узел атомарен и переиспользуем. Узел CheckLineOfSight написан один раз и используется в десяти деревьях. Добавить новое поведение — значит добавить ветку в дерево, не рефакторить существующую логику.
В 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-событиях. Проект становится значительно легче тестировать и расширять.
Как мы работаем: этапы и результат
Каждый проект проходит через пять этапов. Ниже — ориентировочные сроки и ключевые артефакты. Стоимость каждого этапа рассчитывается индивидуально, ориентир: один модуль (например, система сохранений) — от 20 000 до 50 000 рублей в зависимости от сложности.
| Этап |
Длительность (рабочие дни) |
Результат |
| Аудит текущей архитектуры |
1–3 |
Документ с проблемами и рекомендациями |
| Проектирование систем |
2–5 |
Архитектурная схема, описание модулей |
| Реализация (итерации) |
от 10 |
Рабочий код, протестированный в связке с артом |
| Код-ревью и рефакторинг |
2–4 |
Чистая кодовая база, комментарии к сложным участкам |
| Документация и сдача |
1–2 |
Гайд для команды, описание настроек (Physics Layers, NavMesh и т.д.) |
Сроки варьируются в зависимости от объёма legacy-кода и сложности механик. Получите консультацию инженера до старта работ — убедимся, что подход подходит именно вашему проекту.
Что входит в работу
После завершения вы получаете:
- Архитектурную документацию игровых систем (контроллер, AI, сохранения)
- Исходный код с комментариями, готовый к дальнейшей разработке
- Настройки инструментов: Physics Layers, NavMesh, проект ScriptableObject‑событий
- Код-ревью существующих модулей (если проект не с нуля)
- Поддержку на этапе интеграции (2 недели после сдачи)
Свяжитесь с нами, чтобы обсудить детали. Закажите аудит архитектуры — это бесплатно и займёт не более 3 рабочих дней.