Project_WL/Assets/WL/Combat/Telegraph/Telegraph.cs

618 lines
30 KiB
C#
Raw Permalink Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// 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<Actor, State> s_states = new Dictionary<Actor, State>(32);
static State[] s_active = new State[16];
static int s_activeCount;
static readonly Dictionary<RuntimeAnimatorController, float[]> s_hitTimes = new Dictionary<RuntimeAnimatorController, float[]>(16);
static readonly Dictionary<AnimationClip, float> s_clipHit = new Dictionary<AnimationClip, float>(32);
static readonly List<AnimatorClipInfo> s_clipInfo = new List<AnimatorClipInfo>(4);
static readonly List<AnimationClip> s_tmpClips = new List<AnimationClip>(8);
static FieldInfo s_tableField;
static bool s_tableLooked;
static readonly float[] s_sfxTimes = new float[8];
static int s_sfxHead;
/// <summary>
/// 815b 스테이지 진행기가 스테이지별 예고 배수를 여기에 넣는다(기본 1 = 무변경).
/// 🔴 815b 가 아직 병합되지 않았으므로 지금 값은 항상 1 이다 — 리플렉션/옵션 참조를 쓰지 않는다(같은 어셈블리라 병합 뒤 한 줄로 연결된다).
/// </summary>
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;
}
/// <summary>프로브용 — 에디트 모드에서 구독을 강제한다(RuntimeInitializeOnLoadMethod 가 안 도는 환경).</summary>
public static void EnsureSubscribed()
{
if (Subscribed) return;
Register();
}
/// <summary>811gh ImpactTier 가 부른다 — 이 몹이 지금 예고(충전) 중인가.</summary>
public static bool IsTelegraphing(Actor actor)
{
State s;
return actor != null && s_states.TryGetValue(actor, out s) && s.phase == Phase.Charging;
}
/// <summary>프로브용 — 이 몹에 적용된 애니메이터 속도(예고 중이 아니면 -1).</summary>
public static float AppliedSpeedOf(Actor actor)
{
State s;
return actor != null && s_states.TryGetValue(actor, out s) && s.phase == Phase.Charging ? s.appliedSpeed : -1f;
}
/// <summary>프로브용 — 계획된 예고 초(예고 중이 아니면 -1).</summary>
public static float PlannedSecondsOf(Actor actor)
{
State s;
return actor != null && s_states.TryGetValue(actor, out s) && s.phase == Phase.Charging ? s.plannedSeconds : -1f;
}
/// <summary>프로브용 — 이 몹이 쓰는 데칼 풀 인덱스(-1 = 없음).</summary>
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;
}
/// <summary>첫 틱에서 실제 재생 중인 클립을 읽어 히트 시각을 보정한다(이름 추정이 틀렸을 때 자가 치유 · 할당 0).</summary>
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++;
}
/// <summary>예고 SFX 동시 상한 토큰(창 안에서 sfxMaxConcurrent 개까지). 프로브가 사운드 매니저 없이 상한만 검증할 때도 부른다.</summary>
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;
}
// ───────────────────────────────────────── 정리 · 프로브
/// <summary>전부 원복한다(씬 전환 · C8 · 러너 파괴 · 프로브 정리).</summary>
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();
}
/// <summary>상태를 통째로 버린다(파괴된 Actor 참조 방지 · 씬 전환).</summary>
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;
}
/// <summary>프로브용 — 클립 히트 시각 캐시를 비운다(에셋을 갈아 끼운 뒤 재측정).</summary>
public static void ClearClipCache() { s_hitTimes.Clear(); s_clipHit.Clear(); }
/// <summary>프로브용 — 컨트롤러의 공격 클립 히트 시각 표(초). null = 못 찾음.</summary>
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;
}
}
}