618 lines
30 KiB
C#
618 lines
30 KiB
C#
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
// 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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|