EffectSpawnSystem
Namespace: Vortex.Sdk.EffectSpawnSystem.*
Сборка: ru.vortex.sdk.effectspawn
Назначение
Пул GameObject'ов для визуальных эффектов «выстрел и забыл» (взрывы, искры, попадания, облачка пыли). Spawn → проигрывание → авто-возврат в пул, без аллокаций в hot path.
Управление визуалом — через TweenerHub на префабе (он сам дёргает Animator/Spine/ParticleSystem). Сама система пула не знает, чем именно играет эффект — только запускает Forward/Back на TweenerHub при активации/деактивации.
Зависимости
| Зависимость | Назначение |
|---|---|
Vortex.Sdk.Core.GameCore |
GameController.OnGameStateChanged, GameStates.Paused — для broadcast паузы активным эффектам |
Vortex.Unity.UI.TweenerSystem |
TweenerHub — анимационная сторона эффекта |
Архитектура
Двухслойное хранилище (lazy auto-create)
[EffectVoid] ← root, active, DontDestroyOnLoad
└── Storage ← child, INACTIVE — все idle-инстансы тут
├── ExplosionPrefab(Clone) (parent inactive → ребёнок не Update'ит)
├── DustPrefab(Clone)
└── ...
Корневой GameObject пула называется [EffectVoid] и несёт компонент EffectPool. Создаётся lazy при первом обращении в EffectSpawn (по образцу MapLevelsController.VoidParent). Никаких сценных объектов руками — пул возникает сам, когда нужен.
Idle: ребёнок неактивного Storage → activeInHierarchy = false → Update/OnEnable не вызываются. Никаких ручных SetActive.
Active: при Spawn инстанс перекладывается из Storage в активный target → Unity сам вызовет OnEnable на EffectView.
Возврат: при Release инстанс перекладывается обратно в Storage → Unity сам вызовет OnDisable.
Поиск parent для активного эффекта
Spawn принимает обязательный target — Transform заказчика. Где эффект будет припаркован в иерархии:
1. owner.GetComponentInParent<EffectsLayer>() ← поиск маркера вверх по цепочке
2. layer != null:
├── layer.Target != null → паркуем в layer.Target
└── layer.Target == null → паркуем в layer.transform
3. layer == null → fallback: паркуем в сам target
Эффект ставится в низ списка детей (SetAsLastSibling).
Позиционирование эффекта
| Параметр | Значение по умолчанию | Источник |
|---|---|---|
| Мировая позиция | target.position |
Spawn-параметр position? (если передан) |
| Мировая ротация | Quaternion.identity |
Spawn-параметр rotation? (если передан) |
// Дефолт: позиция = target.position, ротация = identity
EffectSpawn.Spawn(target, prefab);
// Кастомная позиция (например, точка попадания), ротация дефолтная
EffectSpawn.Spawn(target, prefab, position: hitPoint);
// Кастомная ротация (например, по нормали поверхности), позиция дефолтная
EffectSpawn.Spawn(target, prefab, rotation: Quaternion.LookRotation(normal));
// Полная переопределённая трансформация
EffectSpawn.Spawn(target, prefab, hitPoint, Quaternion.LookRotation(normal));
target остаётся обязательным: он используется для поиска EffectsLayer в parent-цепочке (где парковать) и как источник дефолтной позиции, если она не передана явно.
EffectsLayer — маркер парковки
Пустой компонент-маркер на сцене (по образцу MapsView). Обозначает «парковать сюда эффекты любого потомка». Опциональное поле Target — куда фактически класть, если оно не задано — в transform самого маркера.
Типичные сценарии:
- Один
EffectsLayerна корне сцены — все эффекты сцены идут туда. EffectsLayerна под-сцене (логически удобно) сTargetна дочернийVisuals/EffectsParent(визуально удобно).EffectsLayerна враге → его-личные хиты прицепляются к локальному узлу, а не куда-то вверх по сцене.
EffectView — на префабе
EffectView (RequireComponent TweenerHub)
├── duration: float — длительность активного цикла, unscaled-время
├── tweenerHub: TweenerHub
│
├── OnEnable → enabled=true, _spawnTime=Time.unscaledTime, tweenerHub.Forward()
│ + проверка EffectSpawn.IsPaused → старт сразу в _paused=true если игра в паузе
├── OnDisable → tweenerHub.Back(skip:true)
├── Update → отсчитывает duration в unscaled-времени, копит _pausedAccum при паузе,
│ по достижении вызывает Release()
└── Release() → enabled=false (флаг "уже освобождён в этом цикле")
+ EffectSpawn.Release(this)
Никаких LifetimeStrategy/maxPoolSize/prewarmCount/useUnscaledTime — только duration и связь с TweenerHub. Активация всегда через OnEnable (parent stack даёт это автоматически).
Рекомендованная структура префаба эффекта
Effect (корень префаба)
├── EffectView + TweenerHub ← на корне
├── SkeletonGraphic / ParticleSystem / Image ← визуальная часть
│ └── … (анимация Forward/Back через TweenerHub)
└── [Sound] (GameObject)
└── AudioHandler
├── Audio Source: None (без локального AudioSource)
├── Audio Sample: <DbRecord(Sound) — GUID семпла>
├── Channel: sfx
└── Play On Enable: ✓
Почему так:
- Визуальная и звуковая части — отдельные дочерние узлы. Аудио независимо от анимации, его легко глушить/менять без правки визуала.
AudioHandlerбез локальногоAudioSourceретранслирует звук черезAudioController.PlaySound→ пул изAudioPlayer. Эффекты не плодят AudioSource'ы и не требуют ручного управления жизненным циклом источника.Play On Enable: ✓— звук запускается ровно в момент, когда эффект активируется черезEffectSpawn.Spawn(...)(OnEnable дочернего[Sound]отрабатывает синхронно с активацией корня).- Узел
[Sound]называется явно с квадратными скобками — отделяет «системный звуковой слой» от визуальной иерархии префаба в Hierarchy-окне.
Один эффект — один префаб с тремя слоями (визуал, звук, EffectView/TweenerHub). Каскады эффектов (например, hit-эффект + последующий glow) собираются как несколько Spawn-вызовов разных префабов с одного места.
EffectsCatalog — индекс по ключу
EffectsCatalog : ScriptableObject
├── effects: GameObject[]
├── Keys → IReadOnlyList<string> ← prefab.name каждого
├── GetPrefab(key)
└── ScanProject() / Validate() — Editor-only
Регистрируется автоматически: EffectSpawn.RegisterCatalog() ([RuntimeInitializeOnLoadMethod]) при старте загружает singleton-каталог через AssetDatabaseExt.GetSingletonAsset<EffectsCatalog>(). Параметра и метода UnregisterCatalog нет.
[EffectKey] атрибут на string-поле даёт popup из EffectSpawn.AllKeys. В Editor-mode без регистрации drawer собирает union ключей из всех EffectsCatalog-ассетов проекта (через AssetDatabase). Ключ опционален — первым пунктом списка идёт [NONE] (значение ""), чтобы поле можно было оставить пустым без автозаписи на первый эффект.
EffectSpawn — Bus
EffectSpawn (static)
├── RegisterCatalog() ← [RuntimeInitializeOnLoadMethod], авто-загрузка singleton-каталога
├── AllKeys → IReadOnlyList<string>
├── IsPaused → GameController.GetState() == Paused
│
├── Spawn(target, prefab) → EffectView ← target обязателен
├── Spawn(target, key) → EffectView
├── Spawn(rectTarget, key) → EffectView ← UI-вариант: случайный разброс в пределах rect
├── Release(view)
│
└── (static-ctor)
└── GameController.OnGameStateChanged += OnGameStateChanged
├── Paused → Pool.PauseAll()
└── иначе → Pool.ResumeAll()
Spawn первым параметром принимает target — без него API не вызывается (Debug.LogError + null). UI-перегрузка Spawn(RectTransform target, string key, ...) дополнительно раскидывает позицию случайно в пределах target.rect.
Поток
GAME-CODE
| EffectSpawn.Spawn(bullet.transform, "Explosion")
v
[Bus] catalog.GetPrefab("Explosion") → prefab
v
[Pool] Acquire:
├── stack.Pop() или Instantiate(prefab, Storage)
├── ResolveLayerTarget(target): GetComponentInParent<EffectsLayer> → Target | layer.transform | target
├── view.transform.SetParent(layer) ← parent active → Unity дёрнет OnEnable
├── SetAsLastSibling
├── SetPositionAndRotation(position ?? target.position, rotation ?? Quaternion.identity)
↓
[Unity] OnEnable вызывается автоматически:
├── enabled = true (сброс с прошлого цикла)
├── _spawnTime = Time.unscaledTime
├── _paused = EffectSpawn.IsPaused ← при спауне в паузе сразу замораживается
├── tweenerHub.Forward()
↓
[View.Update] каждый кадр:
├── if (_paused) _pausedAccum += unscaledDeltaTime; return;
├── if (now - _spawnTime - _pausedAccum >= duration) Release();
↓
[View.Release] enabled = false → EffectSpawn.Release(this)
↓
[Pool] Return:
├── _activePrefab.Remove(view)
├── view.transform.SetParent(Storage) ← parent inactive → Unity дёрнет OnDisable
├── _idle[prefab].Push(view)
↓
[Unity] OnDisable вызывается автоматически:
└── tweenerHub.Back(skip: true) ← мгновенный сброс анимации в исходное
При GameController.OnGameStateChanged → Paused:
[Bus] OnGameStateChanged() → state == Paused
↓
[Pool] PauseAll: foreach view in _activePrefab → view.OnPause() → _paused = true
↓
[View.Update] начинает копить _pausedAccum, не Release'ит
При выходе из паузы — Pool.ResumeAll, _paused = false, отсчёт duration возобновляется.
Использование
Простой случай — снаряд взрывается
public class Bullet : MonoBehaviour
{
[SerializeField] private GameObject explosionPrefab;
private void OnCollisionEnter(Collision c)
{
// позиция = bullet.position (дефолт), ротация = identity (дефолт)
EffectSpawn.Spawn(transform, explosionPrefab);
Destroy(gameObject);
}
}
По ключу из каталога с кастомной ротацией
public class Weapon : MonoBehaviour
{
[EffectKey] [SerializeField] private string muzzleFlash;
[EffectKey] [SerializeField] private string impactSparks;
// дефолтная позиция (muzzle.position), дефолтная ротация (identity)
private void Fire(Transform muzzle) => EffectSpawn.Spawn(muzzle, muzzleFlash);
// кастомные позиция (точка попадания) и ротация (по нормали поверхности)
private void OnImpact(Transform owner, Vector3 hitPoint, Vector3 normal)
=> EffectSpawn.Spawn(owner, impactSparks, hitPoint, Quaternion.LookRotation(normal));
}
Регистрация каталога
Регистрация автоматическая — код в бутстрапе не нужен. EffectSpawn.RegisterCatalog() помечен [RuntimeInitializeOnLoadMethod] и при старте сам загружает singleton-каталог через AssetDatabaseExt.GetSingletonAsset<EffectsCatalog>(). Достаточно, чтобы в проекте был единственный ассет EffectsCatalog. Ручной перегрузки с параметром и метода UnregisterCatalog нет.
EffectsLayer на сцене
Scene Root
├── World
│ ├── Player
│ ├── Enemy
│ └── ...
└── Visuals
└── EffectsLayer (EffectsLayer-компонент, Target = self)
└── (сюда паркуются все эффекты сцены)
Если у конкретного врага нужно держать эффекты локально (двигаются вместе с ним):
Enemy
├── EffectsLayer (Target = LocalEffects)
│ └── LocalEffects
│ └── (сюда паркуются эффекты, заспауненные на этом враге)
└── Mesh
Граничные случаи
| Ситуация | Поведение |
|---|---|
Spawn(null, ...) |
Debug.LogError + null. target обязателен по контракту |
Spawn(target, prefab=null) |
Debug.LogError + null |
Spawn(target, "unknown-key") |
Debug.LogError + null. Ключ не найден в каталоге |
Spawn(target, key) без зарегистрированного каталога |
Debug.LogError + null. Spawn-by-prefab продолжает работать |
[EffectKey]-поле с ключом, отсутствующим в текущих каталогах |
Drawer рисует поле красным с пометкой «⚠ X (отсутствует)» и popup рядом, значение не перезаписывается до явного выбора в popup'е |
[EffectKey]-поле оставлено пустым |
Сохраняется как "" (выбран [NONE]), Spawn по такому ключу даст Debug.LogError — обрабатывать на стороне вызывающего кода |
Префаб без EffectView |
Debug.LogError + Destroy инстанса; null возврат |
EffectsLayer.Target не задан |
Парковка в transform самого маркера |
EffectsLayer на неактивном объекте |
GetComponentInParent его пропустит (по умолчанию), fallback на target |
Несколько EffectsLayer в parent-цепочке |
Берётся ближайший (поведение GetComponentInParent по умолчанию) |
Spawn во время GameStates.Paused |
OnEnable сразу замораживает view (_paused = true); счётчик duration ждёт unpause |
Двойной view.Release() |
Второй вызов: enabled уже false → ранний return |
| Pause TweenerHub'а | Не реализовано в первой версии — TweenerHub продолжает играть. Завязано на наличие Pause/Resume в TweenerHub (TODO) |
Object.Destroy(view.gameObject) извне |
При следующем Acquire _idle.Pop() отдаст null-ссылку, Pool отбросит её и Instantiate новый |
| Перезагрузка домена в Editor | static-поля сбросятся, _pool = null, lazy-create при следующем Spawn |
Editor-сторона каталога
EffectsCatalog использует Odin-атрибуты прямо в SO — отдельного CustomEditor нет:
[InfoBox]на массивеeffectsс пояснением «ключ = prefab.name»;[Button] Scan Project—AssetDatabase.FindAssets("t:Prefab")+ фильтрGetComponent<EffectView>(), добавляет новые без удаления существующих, итог в Console;[Button] Validate— проверка null-элементов, отсутствующихEffectView, дубликатов имён; результатDebug.Log(успех) илиDebug.LogError(список проблем);[ShowInInspector] IndexPreview— read-only словарь «key → asset path», виден всегда в инспекторе под кнопками; рендерится Odin-DictionaryDrawer'ом.
EffectKeyAttributeDrawer — popup со всеми ключами. Источники:
- если каталог зарегистрирован в
EffectSpawn→ берётAllKeys; - иначе (Editor-mode без регистрации) → собирает union из всех
EffectsCatalog-ассетов проекта черезAssetDatabase.
Поведение popup'а:
- Опциональность. Первым пунктом всегда
[NONE](значение""). Пустое значение не перезаписывается на «первый из доступных» при отрисовке. - Симметрия источников. Обе ветки (шина и AssetDatabase) проходят один пайплайн:
Distinct → OrderBy(Ordinal) → Insert(0, ""). Пустые ключи внутри каталога фильтруются —[NONE]не дублируется. - Fail-loud на невалидный ключ. Если в поле задан ключ, которого больше нет в источнике (эффект удалили, переименовали, выгрузили каталог), drawer не перезаписывает значение молча. Поле подсвечивается красным с пометкой «⚠ X (отсутствует)», рядом рисуется popup для осознанного выбора замены. Запись произойдёт только после явного клика дизайнера в popup'е — до этого старое значение сохраняется в сериализованных данных, и факт «здесь был ключ X» виден в git-diff.
- Пустые каталоги. Если
keys.Length == 0(нет ни одногоEffectsCatalog-ассета в проекте), drawer рисует стандартноеEditorGUI.PropertyFieldплюсHelpBoxс предупреждением.
Контракт публичного API
namespace Vortex.Sdk.EffectSpawnSystem.Bus
{
public static class EffectSpawn
{
public static IReadOnlyList<string> AllKeys { get; }
public static bool IsPaused { get; }
[RuntimeInitializeOnLoadMethod]
public static void RegisterCatalog(); // авто-загрузка singleton-каталога через AssetDatabaseExt.GetSingletonAsset<EffectsCatalog>()
public static EffectView Spawn(Transform target, GameObject prefab,
Vector3? position = null, Quaternion? rotation = null);
public static EffectView Spawn(Transform target, string key,
Vector3? position = null, Quaternion? rotation = null);
public static EffectView Spawn(RectTransform target, string key,
Vector3? deltaPosition = null, Quaternion? rotation = null); // UI-вариант: случайный разброс в пределах rect
public static void Release(EffectView view);
}
}
namespace Vortex.Sdk.EffectSpawnSystem.Components
{
public class EffectView : MonoBehaviour
{
public float Duration { get; }
public void Release();
}
public sealed class EffectsLayer : MonoBehaviour
{
public Transform Target { get; }
}
}
namespace Vortex.Sdk.EffectSpawnSystem.Catalog
{
public class EffectsCatalog : ScriptableObject
{
public IReadOnlyList<string> Keys { get; }
public IReadOnlyList<GameObject> Effects { get; }
public GameObject GetPrefab(string key);
}
}
namespace Vortex.Sdk.EffectSpawnSystem.Attributes
{
public class EffectKeyAttribute : PropertyAttribute { }
}
EffectPool — internal, не входит в публичный API.