// ───────────────────────────────────────────────────────────────────────────── // Telegraph.cs — 적 공격 예고 1차(§G-7 1차 = ① 데칼 + ⓐ 속도 곡선 + ⓓ 펄스 + ③ 색 규약 + ④ SFX) // // PD 지시 #815-5 「적의 공격을 확실히 유저가 인지하고 피할 수 있는 매커니즘」 · 발주서 WL-815f // SOT = 기준서 v1 §G-0~§G-4 · §G-7 // // ■ 원본 수정 0줄 — 이미 있는 배선 두 개만 구독한다 // CombatEvents.AttackStarted (Actor.Play_Attack 안 · Actor.cs:1957) → 예고 시작 // CombatEvents.HitboxSpawned (Actor.Shoot_Projectile 첫 줄 · :2342) → 히트 = 예고 끝 // 데미지는 공격 클립의 애니메이션 이벤트 `Projectile` 이 낸다. 그래서 **애니메이터를 늦추면 데미지도 같이 늦어진다** // (별도 동기화 0 · 기준서 §G-2 ⓐ). 프로브가 이것을 실측으로 증명한다(정규화 시간 진행 = 속도에 정확히 반비례). // // ■ 한계(기준서 §G-2 ⓐ 그대로) — 속도 곡선 단독으로 0.5 s 는 안 된다 // Batty 히트 시각 0.081 s 에서 0.5 s 를 만들려면 k = 0.16 인데 이는 사실상 정지 화면이다. // 자연스러운 하한 k = 0.35 를 지키면 예고는 0.23 s 까지만 늘어난다 → **나머지는 데칼·펄스·색·소리가 채운다.** // 즉 「몹 동작이 0.5 s 느려진다」가 아니라 「0.5 s 동안 바닥 판이 채워지고 몹이 커지며 소리가 난다」가 이번 1차의 실체다. // // ■ 보스는 건드리지 않는다 — Boss_Anubis 가 이미 코드로 3 s 차징을 건다(기준서 §G-0). // // ■ 다른 WL 시스템과의 관계 // · 색: 813s 가 만든 MobHitFlash.SetEliteTint/ClearEliteTint 경로를 **그대로** 쓴다(렌더러 MPB 소유자는 하나여야 한다는 813s 교훈). // 엘리트였다면 예고가 끝날 때 813s 의 상시 색을 다시 걸어 준다(MobHitFlash.cs 수정 0줄). // · 크기: 813s EliteMarker 가 대입한 스케일을 「기준」으로 잡고 곱한 뒤 그 기준으로 되돌린다(누적 0). // · 히트스톱: 811gh ImpactTier 가 예고 중인 몹에게는 히트스톱을 건너뛴다(기준서 §G-4 · ImpactTier.cs +7줄). // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using System.Reflection; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Telegraph { public static class Telegraph { enum Phase { None = 0, Charging = 1, Fading = 2 } sealed class State { public Actor actor; public Animator anim; public Phase phase; public float startTime, plannedSeconds, hitSeconds, origSpeed, appliedSpeed, targetSeconds; public float fadeUntil, guardUntil; public bool speedApplied, tinted, pulsed, flashed, wasElite, corrected; public Vector3 baseScale; public int decalIdx; public int attackIndex; public TelegraphDanger danger; public RuntimeAnimatorController controller; public int activeIndex; } static readonly Dictionary s_states = new Dictionary(32); static State[] s_active = new State[16]; static int s_activeCount; static readonly Dictionary s_hitTimes = new Dictionary(16); static readonly Dictionary s_clipHit = new Dictionary(32); static readonly List s_clipInfo = new List(4); static readonly List s_tmpClips = new List(8); static FieldInfo s_tableField; static bool s_tableLooked; static readonly float[] s_sfxTimes = new float[8]; static int s_sfxHead; /// /// 815b 스테이지 진행기가 스테이지별 예고 배수를 여기에 넣는다(기본 1 = 무변경). /// 🔴 815b 가 아직 병합되지 않았으므로 지금 값은 항상 1 이다 — 리플렉션/옵션 참조를 쓰지 않는다(같은 어셈블리라 병합 뒤 한 줄로 연결된다). /// public static float StageScale = 1f; // ── 진단(프로브가 읽는다) public static bool Subscribed; public static int StartCount, EndCount, SkippedBoss, SkippedNotMob, SkippedDisabled, SkippedNoTable, TimeoutCount, SpeedApplied, SpeedClamped, SpeedRestored, TintApplied, TintRestoredElite, PulseApplied, PulseRestored, FlashCount, SfxPlayed, SfxThrottled, SfxNoManager, DecalOn, DecalSkipped, HitTimeFallback, HitTimeCorrected; public static float LastPlannedSeconds = -1f, LastHitSeconds = -1f, LastOrigSpeed = -1f, LastAppliedSpeed = -1f, LastTargetSeconds = -1f; public static bool LastWasRanged, LastWasClamped; public static string LastInfo = ""; public static int ActiveCount { get { return s_activeCount; } } public static int StateCount { get { return s_states.Count; } } static WLTelegraphSettings St { get { return WLTelegraphSettings.Instance; } } static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void Register() { CombatEvents.AttackStarted.Add(OnAttackStarted); CombatEvents.HitboxSpawned.Add(OnHitboxSpawned); CombatEvents.Killed.Add(OnKilled); CombatEvents.Spawned.Add(OnSpawned); Subscribed = true; } /// 프로브용 — 에디트 모드에서 구독을 강제한다(RuntimeInitializeOnLoadMethod 가 안 도는 환경). public static void EnsureSubscribed() { if (Subscribed) return; Register(); } /// 811gh ImpactTier 가 부른다 — 이 몹이 지금 예고(충전) 중인가. public static bool IsTelegraphing(Actor actor) { State s; return actor != null && s_states.TryGetValue(actor, out s) && s.phase == Phase.Charging; } /// 프로브용 — 이 몹에 적용된 애니메이터 속도(예고 중이 아니면 -1). public static float AppliedSpeedOf(Actor actor) { State s; return actor != null && s_states.TryGetValue(actor, out s) && s.phase == Phase.Charging ? s.appliedSpeed : -1f; } /// 프로브용 — 계획된 예고 초(예고 중이 아니면 -1). public static float PlannedSecondsOf(Actor actor) { State s; return actor != null && s_states.TryGetValue(actor, out s) && s.phase == Phase.Charging ? s.plannedSeconds : -1f; } /// 프로브용 — 이 몹이 쓰는 데칼 풀 인덱스(-1 = 없음). public static int DecalIndexOf(Actor actor) { State s; return actor != null && s_states.TryGetValue(actor, out s) ? s.decalIdx : -1; } // ───────────────────────────────────────── 이벤트 static void OnAttackStarted(in AttackStartedEvent e) { var st = St; if (st == null || !WLTelegraphSettings.Enabled) { SkippedDisabled++; return; } var actor = e.actor; if (actor == null) return; if (!actor.IsRole(eRole.Mob)) { SkippedNotMob++; return; } if (st.bossUntouched && actor.IsSubRole(eSubRol.Boss)) { SkippedBoss++; return; } var table = GetTable(actor); if (table == null) { SkippedNoTable++; return; } string proj = table.s_Porjectile1; float projLifetime = table.f_ProjectileLifeTime1; bool ranged = projLifetime >= st.rangedLifetimeThreshold || (!string.IsNullOrEmpty(proj) && proj.IndexOf("Range", System.StringComparison.OrdinalIgnoreCase) >= 0); bool elite = actor.IsSubRole(eSubRol.Elite) || table.e_MonsterType == eSubRol.Elite; float target = elite ? st.telegraphSecondsElite : (ranged ? st.telegraphSecondsRanged : st.telegraphSecondsNormal); target *= (StageScale > 0f ? StageScale : 1f); if (target <= 0.01f) return; TelegraphRunner.Ensure(); // Play 에서 틱 러너 보장(에디트 모드에서는 아무것도 만들지 않는다) var s = GetState(actor); EndInternal(s, false, true); // 이전 예고가 남아 있으면 먼저 정리(원복 보장) s.anim = actor.m_animation; s.attackIndex = e.attackIndex; s.controller = s.anim != null ? s.anim.runtimeAnimatorController : null; s.hitSeconds = ResolveHitSeconds(s.controller, e.attackIndex, st); s.origSpeed = e.animSpeed > 0.0001f ? e.animSpeed : (s.anim != null && s.anim.speed > 0.0001f ? s.anim.speed : 1f); s.corrected = false; s.danger = elite ? st.dangerElite : (ranged ? st.dangerNormalRanged : st.dangerNormalMelee); s.startTime = TelegraphRunner.Now; s.targetSeconds = target; s.phase = Phase.Charging; ApplySpeed(s, st, target); // ── ① 지면 데칼 ──────────────────────────────────────────────── s.decalIdx = -1; if (st.decalEnabled) { var box = TelegraphShape.Get(proj); s.decalIdx = TelegraphDecal.Acquire(actor, in box, ranged, projLifetime, s.danger); if (s.decalIdx >= 0) DecalOn++; else DecalSkipped++; } // ── ⓓ 스케일 펄스(813s EliteMarker 가 대입한 값을 기준으로 잡는다) ── if (st.pulseEnabled && st.pulseScale > 1.0001f) { s.baseScale = actor.transform.localScale; s.pulsed = true; PulseApplied++; } // ── ③ 색 규약(MobHitFlash MPB 경로 · 813s 와 같은 소유자) ─────── if (st.tintEnabled) { s.wasElite = WL.Combat.Reaction.MobHitFlash.HasEliteTint(actor); bool unavoid = s.danger == TelegraphDanger.Unavoidable; s.tinted = WL.Combat.Reaction.MobHitFlash.SetEliteTint(actor, unavoid ? st.tintAlbedoUnavoidable : st.tintAlbedoAvoidable, unavoid ? st.tintEmissionUnavoidable : st.tintEmissionAvoidable); if (s.tinted) TintApplied++; } // ── ④ 예고 SFX(동시 상한) ───────────────────────────────────── PlaySfx(st, actor); Activate(s); StartCount++; LastTargetSeconds = target; LastWasRanged = ranged; if (Verbose) Debug.Log("[Telegraph] 시작 " + actor.name + " 등급=" + (elite ? "엘리트" : ranged ? "잡몹(원거리)" : "잡몹") + " 목표 " + target.ToString("F2") + "s · 히트시각 " + s.hitSeconds.ToString("F3") + "s · 속도 " + s.origSpeed.ToString("F2") + "→" + s.appliedSpeed.ToString("F2") + " · 실제 예고 " + s.plannedSeconds.ToString("F3") + "s · 판 " + (s.decalIdx >= 0 ? "O" : "X")); } static void OnHitboxSpawned(in HitboxSpawnedEvent e) { State s; if (e.shooter == null || !s_states.TryGetValue(e.shooter, out s) || s.phase != Phase.Charging) return; EndInternal(s, true, false); } static void OnKilled(in KilledEvent e) { State s; if (e.victim == null || !s_states.TryGetValue(e.victim, out s)) return; EndInternal(s, false, true); } static void OnSpawned(in SpawnedEvent e) { // 풀 재사용 몹 — 남아 있던 예고 상태를 지운다(스케일 감시 포함 · 813s EliteMarker 의 대입과 충돌 0) State s; if (e.actor == null || !s_states.TryGetValue(e.actor, out s)) return; EndInternal(s, false, true); s.guardUntil = 0f; } // ───────────────────────────────────────── 틱 internal static void Tick(float now) { if (s_activeCount == 0) return; var st = St; for (int i = s_activeCount - 1; i >= 0; i--) { var s = s_active[i]; if (s == null) continue; if (s.actor == null) { HardClear(s); continue; } if (s.phase == Phase.Charging) { if (st == null) { EndInternal(s, false, true); continue; } float elapsed = now - s.startTime; // 히트가 안 온다(피격·사망·중단) → 강제 원복 if (elapsed > Mathf.Max(0.1f, st.maxTelegraphSeconds) || !s.actor.gameObject.activeInHierarchy) { TimeoutCount++; EndInternal(s, false, true); continue; } if (!s.corrected) CorrectFromLiveClip(s, st); float planned = Mathf.Max(0.0001f, s.plannedSeconds); float t = Mathf.Clamp01(elapsed / planned); if (s.decalIdx >= 0) { TelegraphDecal.Follow(s.decalIdx, s.actor); TelegraphDecal.SetFill(s.decalIdx, t); } if (s.pulsed) { float k = t * t; // 뒤로 갈수록 빨리 커진다(선딜 느낌) float mul = 1f + (st.pulseScale - 1f) * k; s.actor.transform.localScale = s.baseScale * mul; } // 히트 직전 흰 섬광 if (st.flashEnabled && !s.flashed && (planned - elapsed) <= st.flashLeadSeconds) { if (WL.Combat.Reaction.MobHitFlash.SetEliteTint(s.actor, st.flashAlbedo, st.flashEmission)) { s.flashed = true; s.tinted = true; FlashCount++; } else s.flashed = true; } } else if (s.phase == Phase.Fading) { if (now >= s.fadeUntil) { ClearTint(s, st); s.phase = Phase.None; } } // 스케일 복원 감시 — 811b WLHitFeel 스케일 펀치가 예고 중에 잡은 기준값으로 되돌려 놓는 것을 되잡는다 if (s.phase == Phase.None) { if (s.guardUntil > 0f && now < s.guardUntil && s.actor != null && s.baseScale.sqrMagnitude > 0.0001f) { var cur = s.actor.transform.localScale; if (Mathf.Abs(cur.x - s.baseScale.x) > s.baseScale.x * 0.01f) s.actor.transform.localScale = s.baseScale; } else { Deactivate(s); } } } } // ───────────────────────────────────────── 내부 static void ApplySpeed(State s, WLTelegraphSettings st, float target) { s.appliedSpeed = s.origSpeed; s.plannedSeconds = s.hitSeconds / Mathf.Max(0.0001f, s.origSpeed); s.speedApplied = false; LastHitSeconds = s.hitSeconds; LastOrigSpeed = s.origSpeed; if (!st.speedCurveEnabled || s.anim == null) { LastAppliedSpeed = s.appliedSpeed; LastPlannedSeconds = s.plannedSeconds; return; } float desired = s.hitSeconds / target; // 절대 애니메이터 속도 float floorSpeed = s.origSpeed * Mathf.Clamp(st.minSpeedMultiplier, 0.01f, 1f); float applied = Mathf.Clamp(desired, floorSpeed, s.origSpeed); // 빨라지지는 않는다 bool clamped = desired < floorSpeed - 0.0001f; if (clamped) SpeedClamped++; s.anim.speed = applied; s.appliedSpeed = applied; s.speedApplied = true; s.plannedSeconds = s.hitSeconds / Mathf.Max(0.0001f, applied); SpeedApplied++; LastAppliedSpeed = applied; LastPlannedSeconds = s.plannedSeconds; LastWasClamped = clamped; } /// 첫 틱에서 실제 재생 중인 클립을 읽어 히트 시각을 보정한다(이름 추정이 틀렸을 때 자가 치유 · 할당 0). static void CorrectFromLiveClip(State s, WLTelegraphSettings st) { s.corrected = true; if (!st.correctFromLiveClip || s.anim == null || s.controller == null) return; s_clipInfo.Clear(); s.anim.GetCurrentAnimatorClipInfo(0, s_clipInfo); AnimationClip best = null; float bestW = 0f; for (int i = 0; i < s_clipInfo.Count; i++) if (s_clipInfo[i].clip != null && s_clipInfo[i].weight >= bestW) { bestW = s_clipInfo[i].weight; best = s_clipInfo[i].clip; } if (best == null) return; float ht = ClipHitTime(best, st); if (ht <= 0f || Mathf.Abs(ht - s.hitSeconds) <= 0.005f) return; // 캐시(다음 공격부터는 처음부터 정확하다) float[] arr; if (s_hitTimes.TryGetValue(s.controller, out arr) && arr != null) { int idx = Mathf.Clamp(s.attackIndex, 0, arr.Length - 1); if (arr.Length > 0) arr[idx] = ht; } float elapsed = TelegraphRunner.Now - s.startTime; s.hitSeconds = ht; float target = s.targetSeconds > 0f ? s.targetSeconds : s.plannedSeconds; ApplySpeed(s, st, target); s.startTime = TelegraphRunner.Now - elapsed; // 이미 흐른 만큼은 유지 HitTimeCorrected++; } static void EndInternal(State s, bool byHit, bool forced) { if (s.phase == Phase.None && !s.speedApplied && !s.tinted && !s.pulsed && s.decalIdx < 0) return; var st = St; float now = TelegraphRunner.Now; if (s.speedApplied && s.anim != null) { // 원본 값으로 되돌린다(Play_Attack 이 정한 FinalAttackSpeed 반영값). 원본이 그 사이 speed 를 바꿨으면(피격·사망) 건드리지 않는다. if (Mathf.Abs(s.anim.speed - s.appliedSpeed) < 0.0001f) { s.anim.speed = s.origSpeed; SpeedRestored++; } } s.speedApplied = false; if (s.decalIdx >= 0) { TelegraphDecal.Release(s.decalIdx); s.decalIdx = -1; } if (s.pulsed && s.actor != null) { s.actor.transform.localScale = s.baseScale; s.guardUntil = now + (st != null ? Mathf.Max(0f, st.pulseRestoreGuardSeconds) : 0f); PulseRestored++; } s.pulsed = false; if (byHit && !forced && st != null && st.flashEnabled && st.flashSeconds > 0f && s.actor != null) { if (!s.flashed && WL.Combat.Reaction.MobHitFlash.SetEliteTint(s.actor, st.flashAlbedo, st.flashEmission)) { s.tinted = true; FlashCount++; } s.flashed = true; s.phase = Phase.Fading; s.fadeUntil = now + st.flashSeconds; } else { ClearTint(s, st); s.phase = Phase.None; } s.flashed = false; EndCount++; if (s.guardUntil <= now && s.phase == Phase.None) Deactivate(s); } static void ClearTint(State s, WLTelegraphSettings st) { if (!s.tinted || s.actor == null) { s.tinted = false; return; } s.tinted = false; // 813s 엘리트 상시 색이 원래 걸려 있었다면 그 색을 되돌려 준다(MobHitFlash.cs 수정 0줄) var es = WL.Combat.Reaction.WLEliteSettings.Instance; if (s.wasElite && es != null && WL.Combat.Reaction.WLEliteSettings.Enabled && es.tintEnabled && WL.Combat.Reaction.MobHitFlash.SetEliteTint(s.actor, es.tintAlbedo, es.tintEmission)) { TintRestoredElite++; return; } WL.Combat.Reaction.MobHitFlash.ClearEliteTint(s.actor); } static void HardClear(State s) { if (s.decalIdx >= 0) { TelegraphDecal.Release(s.decalIdx); s.decalIdx = -1; } s.phase = Phase.None; s.tinted = false; s.pulsed = false; s.speedApplied = false; s.guardUntil = 0f; Deactivate(s); } static void PlaySfx(WLTelegraphSettings st, Actor actor) { if (!st.sfxEnabled) return; if (st.sfxIndex < 0 || st.sfxIndex >= (int)eSound.Max) return; if (!TakeSfxToken(st)) return; // 기준서 §G-4 동시 상한 if (!SoundInfo.isIns || SoundInfo.Ins == null) { SfxNoManager++; return; } SoundInfo.Ins.Play_OneShot_byDistance((eSound)st.sfxIndex, actor.transform.position, st.sfxVolume); SfxPlayed++; } /// 예고 SFX 동시 상한 토큰(창 안에서 sfxMaxConcurrent 개까지). 프로브가 사운드 매니저 없이 상한만 검증할 때도 부른다. public static bool TakeSfxToken(WLTelegraphSettings st) { if (st == null) return false; float now = TelegraphRunner.Now; float window = Mathf.Max(0.01f, st.sfxWindowSeconds); int recent = 0; for (int i = 0; i < s_sfxTimes.Length; i++) if (now - s_sfxTimes[i] < window) recent++; if (recent >= Mathf.Max(1, st.sfxMaxConcurrent)) { SfxThrottled++; return false; } s_sfxTimes[s_sfxHead] = now; s_sfxHead = (s_sfxHead + 1) % s_sfxTimes.Length; return true; } static State GetState(Actor actor) { State s; if (s_states.TryGetValue(actor, out s)) return s; s = new State { actor = actor, decalIdx = -1, activeIndex = -1 }; s_states.Add(actor, s); return s; } static void Activate(State s) { if (s.activeIndex >= 0) return; if (s_activeCount == s_active.Length) System.Array.Resize(ref s_active, s_active.Length * 2); s.activeIndex = s_activeCount; s_active[s_activeCount++] = s; } static void Deactivate(State s) { if (s == null) return; int i = s.activeIndex; if (i < 0) return; int last = --s_activeCount; s_active[i] = s_active[last]; if (s_active[i] != null) s_active[i].activeIndex = i; s_active[last] = null; s.activeIndex = -1; } // ── 몹 테이블(원본 protected 필드) — 리플렉션 1회 · 클래스 참조라 호출당 할당 0 static MonsterTableData GetTable(Actor actor) { var mob = actor as MobActor; if (mob == null) return null; if (!s_tableLooked) { s_tableField = typeof(MobActor).GetField("m_MonsterTableData", BindingFlags.Instance | BindingFlags.NonPublic); s_tableLooked = true; } if (s_tableField == null) return null; return s_tableField.GetValue(mob) as MonsterTableData; } // ── 공격 클립의 히트 이벤트 시각(초) — 컨트롤러당 1회 스캔 후 캐시 static float ResolveHitSeconds(RuntimeAnimatorController rac, int attackIndex, WLTelegraphSettings st) { if (rac == null) { HitTimeFallback++; return Mathf.Max(0.01f, st.defaultHitSeconds); } float[] arr; if (!s_hitTimes.TryGetValue(rac, out arr)) { arr = BuildHitTimes(rac, st); s_hitTimes[rac] = arr; } if (arr == null || arr.Length == 0) { HitTimeFallback++; return Mathf.Max(0.01f, st.defaultHitSeconds); } int i = Mathf.Clamp(attackIndex, 0, arr.Length - 1); float t = arr[i]; if (t <= 0f) { HitTimeFallback++; return Mathf.Max(0.01f, st.defaultHitSeconds); } return t; } static float[] BuildHitTimes(RuntimeAnimatorController rac, WLTelegraphSettings st) { s_tmpClips.Clear(); var clips = rac.animationClips; // 컨트롤러당 1회 if (clips != null) for (int i = 0; i < clips.Length; i++) { var c = clips[i]; if (c == null) continue; string n = c.name; if (n.IndexOf("attack", System.StringComparison.OrdinalIgnoreCase) < 0) continue; if (n.IndexOf("skill", System.StringComparison.OrdinalIgnoreCase) >= 0) continue; // 스킬은 Play_Skill 경로 if (ClipHitTime(c, st) <= 0f) continue; if (!s_tmpClips.Contains(c)) s_tmpClips.Add(c); } if (s_tmpClips.Count == 0) return null; s_tmpClips.Sort(CompareClipName); var res = new float[s_tmpClips.Count]; for (int i = 0; i < s_tmpClips.Count; i++) res[i] = ClipHitTime(s_tmpClips[i], st); return res; } static int CompareClipName(AnimationClip a, AnimationClip b) { if (a == null) return b == null ? 0 : 1; if (b == null) return -1; return string.CompareOrdinal(a.name, b.name); } static float ClipHitTime(AnimationClip clip, WLTelegraphSettings st) { float t; if (s_clipHit.TryGetValue(clip, out t)) return t; t = -1f; var evs = clip.events; // 클립당 1회 var names = st.hitEventNames; if (evs != null && names != null) for (int i = 0; i < evs.Length; i++) for (int k = 0; k < names.Length; k++) if (!string.IsNullOrEmpty(names[k]) && evs[i].functionName == names[k]) { if (t < 0f || evs[i].time < t) t = evs[i].time; } s_clipHit[clip] = t; return t; } // ───────────────────────────────────────── 정리 · 프로브 /// 전부 원복한다(씬 전환 · C8 · 러너 파괴 · 프로브 정리). public static void RestoreAll() { var st = St; foreach (var kv in s_states) { var s = kv.Value; if (s.speedApplied && s.anim != null && Mathf.Abs(s.anim.speed - s.appliedSpeed) < 0.0001f) { s.anim.speed = s.origSpeed; SpeedRestored++; } s.speedApplied = false; if (s.decalIdx >= 0) { TelegraphDecal.Release(s.decalIdx); s.decalIdx = -1; } if (s.pulsed && s.actor != null) { s.actor.transform.localScale = s.baseScale; PulseRestored++; } s.pulsed = false; ClearTint(s, st); s.phase = Phase.None; s.guardUntil = 0f; s.flashed = false; } for (int i = 0; i < s_activeCount; i++) if (s_active[i] != null) s_active[i].activeIndex = -1; System.Array.Clear(s_active, 0, s_active.Length); s_activeCount = 0; TelegraphDecal.ReleaseAll(); } /// 상태를 통째로 버린다(파괴된 Actor 참조 방지 · 씬 전환). public static void ClearAll() { RestoreAll(); s_states.Clear(); } public static void ResetDiagnostics() { StartCount = EndCount = SkippedBoss = SkippedNotMob = SkippedDisabled = SkippedNoTable = TimeoutCount = 0; SpeedApplied = SpeedClamped = SpeedRestored = TintApplied = TintRestoredElite = PulseApplied = PulseRestored = 0; FlashCount = SfxPlayed = SfxThrottled = SfxNoManager = DecalOn = DecalSkipped = HitTimeFallback = HitTimeCorrected = 0; LastPlannedSeconds = LastHitSeconds = LastOrigSpeed = LastAppliedSpeed = LastTargetSeconds = -1f; LastWasRanged = LastWasClamped = false; LastInfo = ""; for (int i = 0; i < s_sfxTimes.Length; i++) s_sfxTimes[i] = -999f; } /// 프로브용 — 클립 히트 시각 캐시를 비운다(에셋을 갈아 끼운 뒤 재측정). public static void ClearClipCache() { s_hitTimes.Clear(); s_clipHit.Clear(); } /// 프로브용 — 컨트롤러의 공격 클립 히트 시각 표(초). null = 못 찾음. public static float[] HitSecondsTable(RuntimeAnimatorController rac) { var st = St; if (rac == null || st == null) return null; float[] arr; if (!s_hitTimes.TryGetValue(rac, out arr)) { arr = BuildHitTimes(rac, st); s_hitTimes[rac] = arr; } return arr; } } }