using UnityEngine; using Platformer.Mechanics; using Platformer.Gameplay; using static Platformer.Core.Simulation; namespace EerieVillage.Skills.Effectors { /// /// A11 정령불 Effector — Category D (Minion). /// PD 지시 2026-05-13: /// - 15초마다 (BaseCooldown 15) Player 자식 spawn /// - 8초 유지 (FX_Rotating shield) /// - 지속 시간 동안 적 투사체 SelfDestruct·근접 적 매 초 5 피해 /// public class SpiritFireSpawner : IEffector { public void Trigger(ActiveSkillRuntime runtime, PlayerSkillInventory inventory) { var data = runtime.ActiveData; Vector2 spawnPos = (Vector2)inventory.transform.position + data.OffsetXY; GameObject shieldGo; if (data.OnHitFxPrefab != null) { shieldGo = Object.Instantiate(data.OnHitFxPrefab, spawnPos, Quaternion.identity, inventory.transform); shieldGo.transform.localScale *= data.HitFxScale; // PD 지시 2026-05-13 — ParticleSystem 명시 Play (playOnAwake 영역 정합·재발 안전망) foreach (var ps in shieldGo.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; ps.Play(true); } } else { shieldGo = new GameObject("SpiritFire_Fallback"); shieldGo.transform.SetParent(inventory.transform, false); } shieldGo.hideFlags = HideFlags.DontSave; float duration = data.MinionLifetime > 0.1f ? data.MinionLifetime : 8f; // PD 지시 2026-05-14 — 판정 박스 형태 일관 (HitboxSize 사용·OverlapBox) Vector2 boxSize = data.HitboxSize.sqrMagnitude > 0.01f ? data.HitboxSize : new Vector2(2.5f, 2.5f); int damage = data.BaseDamage > 0 ? data.BaseDamage : 5; // PD 지시 2026-05-14 — 일정 피해 간격 (DotInterval 우선·기본 1초) float interval = data.DotInterval > 0.01f ? data.DotInterval : 1f; // PD 지시 2026-05-14 — OffsetDistance 적용 (facing sign · A05·Laser 동일 패턴) Vector2 facing = Vector2.right; var pc = inventory.GetComponent(); if (pc != null) facing = pc.Facing; float signX = facing.x < 0f ? -1f : 1f; Vector2 offset = new Vector2(signX * data.OffsetDistance.x, data.OffsetDistance.y); var instance = shieldGo.AddComponent(); instance.Init(inventory.transform, duration, boxSize, damage, interval, offset); } } /// /// 정령불 인스턴스 — Player 자식 부착·duration 동안 OverlapCircle 영역 적 투사체 SelfDestruct·근접 적 매 초 5 피해. /// PD 지시 2026-05-13 — FX_Rotating shield Animator frame 제어 (60fps·intro 1~88·loop 89~105·outro 남은 frame). /// public class SpiritFireInstance : MonoBehaviour { // PD 지시 2026-05-13 — FX_Rotating shield.anim 정합 (m_SampleRate 60·m_StopTime 2.8166666 → 169 frame·2.8167s) const float FPS = 60f; const int INTRO_END_FRAME = 88; const int LOOP_END_FRAME = 105; const float CLIP_LENGTH = 2.8166666f; static readonly int STATE_HASH = Animator.StringToHash("Base Layer.FX_Rotating shield"); Transform _player; Animator _animator; float _spawnTime; float _duration; Vector2 _boxSize; Vector2 _offset; // PD 지시 2026-05-14 — OffsetDistance (facing sign 적용 후·Player 기준) int _damage; float _interval; float _lastDamageTime; Transform _debugBoxTransform; // PD 지시 2026-05-14 — 소멸 0.5초 전 페이드 (alpha 1→0·scale 1→0.5) const float FADE_DURATION = 0.5f; Vector3 _baseScale; Renderer[] _renderers; MaterialPropertyBlock _mpb; float[] _baseAlphas; public void Init(Transform player, float duration, Vector2 boxSize, int damage, float interval, Vector2 offset) { _player = player; _duration = duration; _boxSize = boxSize; _damage = damage; _interval = interval; _offset = offset; _spawnTime = Time.unscaledTime; _animator = GetComponent(); if (_animator == null) _animator = GetComponentInChildren(); if (_animator != null) { _animator.updateMode = AnimatorUpdateMode.UnscaledTime; _animator.speed = 1f; } // PD 지시 2026-05-14 — 판정 박스 시각화 (붉은 반투명 박스·ShowDebugVisuals 토글) // PD 지시 2026-05-14 — OffsetDistance 영역 박스 localPosition 적용 (facing sign 영역 Spawner 영역 사전 처리) var dbg = new GameObject("SpiritFireHitbox_Debug"); dbg.hideFlags = HideFlags.DontSave; dbg.transform.SetParent(_player, false); // Player 자식·매 frame 동조 float lpx = _player.lossyScale.x != 0f ? Mathf.Abs(_player.lossyScale.x) : 1f; float lpy = _player.lossyScale.y != 0f ? Mathf.Abs(_player.lossyScale.y) : 1f; dbg.transform.localPosition = new Vector3(_offset.x / lpx, _offset.y / lpy, 0f); dbg.transform.localScale = new Vector3(_boxSize.x / lpx, _boxSize.y / lpy, 1f); var sr = dbg.AddComponent(); sr.sprite = HitboxDebug.GetWhiteSprite(); sr.color = new Color(1f, 0f, 0f, 0.35f); sr.sortingOrder = 100; sr.enabled = HitboxDebug.ShowDebugVisuals; _debugBoxTransform = dbg.transform; Destroy(dbg, _duration); // PD 지시 2026-05-14 — fade 캐싱 (Renderer·MaterialPropertyBlock·기본 alpha) _baseScale = transform.localScale; _renderers = GetComponentsInChildren(); _mpb = new MaterialPropertyBlock(); _baseAlphas = new float[_renderers.Length]; for (int i = 0; i < _renderers.Length; i++) { var r = _renderers[i]; if (r == null) { _baseAlphas[i] = 1f; continue; } if (r.sharedMaterial != null && r.sharedMaterial.HasProperty("_TintColor")) _baseAlphas[i] = r.sharedMaterial.GetColor("_TintColor").a; else if (r.sharedMaterial != null && r.sharedMaterial.HasProperty("_Color")) _baseAlphas[i] = r.sharedMaterial.GetColor("_Color").a; else _baseAlphas[i] = 1f; } } // PD 지시 2026-05-14 — 페이드 보간 적용 void ApplyFadeout(float t) { t = Mathf.Clamp01(t); float alphaMul = 1f - t; float scaleMul = 1f - 0.5f * t; transform.localScale = _baseScale * scaleMul; if (_renderers == null) return; for (int i = 0; i < _renderers.Length; i++) { var r = _renderers[i]; if (r == null) continue; r.GetPropertyBlock(_mpb); string propName = (r.sharedMaterial != null && r.sharedMaterial.HasProperty("_TintColor")) ? "_TintColor" : "_Color"; Color baseCol = (r.sharedMaterial != null && r.sharedMaterial.HasProperty(propName)) ? r.sharedMaterial.GetColor(propName) : Color.white; baseCol.a = _baseAlphas[i] * alphaMul; _mpb.SetColor(propName, baseCol); r.SetPropertyBlock(_mpb); } } void Update() { // PD 지시 2026-05-13 — Animator frame 제어 (intro 0~88f·loop 88~105f·outro 105~clip_end) if (_animator != null) { float introEnd = INTRO_END_FRAME / FPS; // 1.4667s float loopEnd = LOOP_END_FRAME / FPS; // 1.75s float outroLength = Mathf.Max(0f, CLIP_LENGTH - loopEnd); // 1.0666s float outroStart = Mathf.Max(introEnd, _duration - outroLength); float elapsed = Time.unscaledTime - _spawnTime; float targetTime; if (elapsed < introEnd) { targetTime = elapsed; } else if (elapsed < outroStart) { float loopRange = Mathf.Max(0.01f, loopEnd - introEnd); float loopT = (elapsed - introEnd) % loopRange; targetTime = introEnd + loopT; } else { targetTime = loopEnd + (elapsed - outroStart); } float normalized = Mathf.Clamp01(targetTime / CLIP_LENGTH); _animator.Play(STATE_HASH, 0, normalized); } float elapsedFromSpawn = Time.unscaledTime - _spawnTime; if (elapsedFromSpawn >= _duration) { Destroy(gameObject); return; } // PD 지시 2026-05-14 — 판정 center = Player 위치 + OffsetDistance (박스 시각 ↔ 판정 일관) Vector2 basePos = _player != null ? (Vector2)_player.position : (Vector2)transform.position; Vector2 center = basePos + _offset; // PD 지시 2026-05-14 — 정령불의 범위에 있는 적은 지속 시간 동안 일정한 피해 간격마다 피해 if (Time.unscaledTime - _lastDamageTime >= _interval) { _lastDamageTime = Time.unscaledTime; ApplyDamageAround(center); } // PD 지시 2026-05-14 — 소멸 0.5초 전 페이드 (alpha 감소·scale 50% 축소) float remaining = _duration - elapsedFromSpawn; if (remaining <= FADE_DURATION && remaining > 0f) { float t = 1f - (remaining / FADE_DURATION); // 0→1 over 0.5s ApplyFadeout(t); } } void ApplyDamageAround(Vector2 center) { // PD 지시 2026-05-14 — OverlapBox 영역 박스 형태 판정 (HitboxSize 사용·박스 시각 ↔ 판정 정합) var cf = new ContactFilter2D { useTriggers = false }; var results = new Collider2D[32]; int n = Physics2D.OverlapBox(center, _boxSize, 0f, cf, results); for (int i = 0; i < n; i++) { var c = results[i]; if (c == null) continue; var e = c.GetComponent(); if (e == null) continue; var h = c.GetComponent(); if (h == null || !h.IsAlive) continue; h.DecrementBypassInvuln(_damage); if (!h.IsAlive) Schedule().enemy = e; } } } }