// ───────────────────────────────────────────────────────────────────────────── // EliteMarker.cs — 엘리트 몹 마커: 크기 1.3× + 상시 색 + 처치 시간(TTK) 측정 // // PD 지시 #813 · 발주서 WL-813s §1-2 · 기준서 v1 §D-2 813s 행 · §B 요소 1 (2026-09-09) // // ■ 무엇을 하나 (원본 훅 0 · Assets/Script/** 무수정 · 정적 · 러너 0) // Spawned(isElite) → ⓐ localScale = 테이블 기본 스케일 × WLEliteSettings.scaleMultiplier(1.3) // — **멱등**이다. 풀 재사용 몹이 다시 등장해도 기준 스케일에서 다시 곱하므로 1.3^n 이 되지 않는다. // ⓑ MobHitFlash 에 「상시 엘리트 색」을 등록한다(MPB · 아래 참고). // ⓒ 등장 시각을 적어 둔다(TTK). // Killed(Elite) → ⓐ 등장 → 처치 시간(TTK)을 재서 기준서 8~12 s 대조 로그를 남긴다(링버퍼 · GC 0) // ⓑ 상시 색을 뗀다(디졸브·풀 잔류 0). **크기는 그대로 둔다** — 사망 순간에 되돌리면 // 죽는 프레임에 몸집이 튄다. 되돌림은 RestoreAll/ClearAll(씬 전환·C8)에서만. // // ■ 왜 색을 MobHitFlash 에 넘기는가 (렌더러 MPB 의 소유자는 하나여야 한다) // MobHitFlash.Apply 는 렌더러별 MPB 를 **매번 Clear 후 재구성**하고, 오버라이드가 없으면 SetPropertyBlock(null) // 로 블록을 떼어 SRP Batcher 로 되돌린다. 여기서 별도의 MPB 를 쓰면 첫 피격 점멸에서 통째로 지워진다. // → 상시 색을 MobHitFlash 의 상태(State.elite)로 넣어 **같은 Apply 가 합성**하게 했다. // 우선순위 = 피격 점멸(0.03 s) > 상시 엘리트 색. 점멸이 꺼지면 상시 색으로 복귀한다. // // ■ 러너를 만들지 않았다 // 크기는 등장 1회 · 색은 상시(MobHitFlash 가 이미 틱을 갖는다) · TTK 는 이벤트 2개의 시각 차이라 // **매 프레임 할 일이 없다**. 씬 전환 정리만 SceneManager.activeSceneChanged 로 직접 잡는다 // (811gh ImpactRunner · 811def WLReactionRunner 어느 쪽 파일도 건드리지 않는다 = 병합 충돌 0). // 시각은 ImpactRunner.Now(Play = Time.unscaledTime · 프로브 = 시뮬 시계)를 읽어 에디트 모드에서도 TTK 를 잴 수 있다. // // ■ 값(C45) — 게임 수치 상수 0. 전부 Assets/WL/Combat/Settings/Resources/WL/WLEliteSettings.asset. // HP(8~12 s)는 코드가 아니라 **데이터**다 — MonsterAppear.json 엘리트 스포너 행 813021~813024 의 n_MonsterHP. // 킬캠(811d)·드랍(813f)·임팩트 티어(811gh)는 이미 eSubRol.Elite 분기를 갖고 있어 이 파일이 손대지 않는다. // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; using WL.Combat.Core; namespace WL.Combat.Reaction { /// 엘리트 몹 마커. 정적 · 새 Manager/Singleton 0 · 러너 0 · GC 0(상태는 액터당 1회 할당 = 풀 크기 상한). public static class EliteMarker { sealed class State { public Actor actor; public Vector3 baseScale; // 마커 적용 전(= MonsterList.f_DefaultScale) 스케일 public bool baseCaptured; // 기준 스케일을 잡았는가(멱등 보장) public bool scaled; // 지금 배수가 걸려 있는가 public bool tinted; public float spawnAt; // 등장 시각(ImpactRunner.Now) public bool alive; } static readonly Dictionary s_states = new Dictionary(16); static bool s_sceneHooked; // ── 처치 시간(TTK) 표본 — 사전 할당 링버퍼(GC 0) const int kSamples = 16; static readonly float[] s_ttk = new float[kSamples]; static int s_ttkCount, s_ttkHead; // ── 진단(프로브가 읽는다) public static bool Subscribed; public static int SpawnCount, KillCount, ScaleApplied, ScaleSkipped, TintApplied, TintSkipped, StatesCreated, SkippedNotElite, SkippedDisabled; public static string LastInfo = ""; public static float LastKillTime = -1f; public static float LastAppliedScale; public static int StateCount { get { return s_states.Count; } } public static int TtkCount { get { return s_ttkCount; } } /// 표본 평균 처치 시간(초). 표본이 없으면 -1. public static float TtkAverage { get { if (s_ttkCount == 0) return -1f; float sum = 0f; for (int i = 0; i < s_ttkCount; i++) sum += s_ttk[i]; return sum / s_ttkCount; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void Register() { CombatEvents.Spawned.Add(OnSpawned); CombatEvents.Killed.Add(OnKilled); if (!s_sceneHooked) { SceneManager.activeSceneChanged += OnSceneChanged; s_sceneHooked = true; } Subscribed = true; } static void OnSceneChanged(Scene from, Scene to) { ClearAll(); } static WLEliteSettings St { get { return WLEliteSettings.Instance; } } static void Log(string msg) { var st = St; if (st != null && st.verboseLog) Debug.Log("[EliteMarker] " + msg); } /// 엘리트 판정 — 이벤트 스냅샷을 먼저 보고, 없으면 액터를 다시 읽는다(둘 다 Actor.m_SubRole 이 출처). static bool IsElite(Actor a, bool snapshot) { return snapshot || (a != null && a.IsSubRole(eSubRol.Elite)); } // ───────────────────────────────────────── 이벤트 static void OnSpawned(in SpawnedEvent e) { var actor = e.actor; if (actor == null) return; if (!IsElite(actor, e.isElite)) { SkippedNotElite++; return; } if (!WLEliteSettings.Enabled) { SkippedDisabled++; return; } var st = St; var s = GetState(actor); s.alive = true; s.spawnAt = ImpactRunner.Now; SpawnCount++; ApplyScale(s, st); ApplyTint(s, st); LastInfo = "spawn"; Log("등장 " + actor.name + " scale=" + actor.transform.localScale.x.ToString("F2") + " (기본 " + s.baseScale.x.ToString("F2") + ") 색=" + s.tinted); } static void OnKilled(in KilledEvent e) { var victim = e.victim; if (victim == null) return; if (e.subRole != eSubRol.Elite && !IsElite(victim, false)) return; State s; if (!s_states.TryGetValue(victim, out s)) return; if (!s.alive) return; s.alive = false; KillCount++; var st = St; float ttk = ImpactRunner.Now - s.spawnAt; if (ttk >= 0f) { LastKillTime = ttk; s_ttk[s_ttkHead] = ttk; s_ttkHead = (s_ttkHead + 1) % kSamples; if (s_ttkCount < kSamples) s_ttkCount++; if (st != null && st.killTimeLogEnabled) { string verdict = ttk < st.killTimeTargetMin ? "빠름" : ttk > st.killTimeTargetMax ? "느림" : "목표"; Debug.Log("[EliteMarker] 엘리트 처치 " + victim.name + " " + ttk.ToString("F1") + " s (" + verdict + " · 목표 " + st.killTimeTargetMin.ToString("F0") + "~" + st.killTimeTargetMax.ToString("F0") + " s · 평균 " + TtkAverage.ToString("F1") + " s)"); } } // 색만 뗀다(디졸브·풀 잔류 0) · 크기는 유지(사망 프레임 몸집 튐 방지) if (s.tinted) { MobHitFlash.ClearEliteTint(victim); s.tinted = false; } LastInfo = "kill"; } // ───────────────────────────────────────── 적용 static State GetState(Actor actor) { State s; if (s_states.TryGetValue(actor, out s)) return s; s = new State { actor = actor }; s_states.Add(actor, s); StatesCreated++; return s; } static void ApplyScale(State s, WLEliteSettings st) { if (st == null || !st.scaleEnabled) { ScaleSkipped++; return; } var tf = s.actor != null ? s.actor.transform : null; if (tf == null) { ScaleSkipped++; return; } // 기준 스케일은 처음 볼 때 1회만 잡는다. 원본 MobActor.Set 이 f_DefaultScale 로 넣은 값이다. if (!s.baseCaptured) { s.baseScale = tf.localScale; s.baseCaptured = true; } float mul = st.scaleMultiplier; if (mul <= 0f) { ScaleSkipped++; return; } var target = s.baseScale * mul; if (st.scaleMaxAbsolute > 0f && target.x > st.scaleMaxAbsolute) target = s.baseScale * (st.scaleMaxAbsolute / Mathf.Max(0.0001f, s.baseScale.x)); tf.localScale = target; // 대입(누적 곱이 아니다) = 멱등 s.scaled = true; LastAppliedScale = target.x; ScaleApplied++; } static void ApplyTint(State s, WLEliteSettings st) { if (st == null || !st.tintEnabled) { TintSkipped++; return; } if (MobHitFlash.SetEliteTint(s.actor, st.tintAlbedo, st.tintEmission)) { s.tinted = true; TintApplied++; } else TintSkipped++; } // ───────────────────────────────────────── 정리 (C8 · 씬 전환 · 프로브) /// 모든 엘리트의 크기·색을 원복한다. 상태 캐시는 유지. public static void RestoreAll() { foreach (var kv in s_states) { var s = kv.Value; if (s.scaled && s.baseCaptured && s.actor != null && s.actor.transform != null) s.actor.transform.localScale = s.baseScale; s.scaled = false; if (s.tinted) { MobHitFlash.ClearEliteTint(s.actor); s.tinted = false; } s.alive = false; } } /// 씬 전환: 원복 후 상태 캐시(파괴된 Actor 참조)를 버린다. public static void ClearAll() { RestoreAll(); s_states.Clear(); } /// 프로브용 — 이 액터가 지금 마커 상태인가. public static bool IsMarked(Actor actor) { State s; return actor != null && s_states.TryGetValue(actor, out s) && (s.scaled || s.tinted); } /// 프로브용 — 이 액터의 기준(원본) 스케일. 없으면 -1. public static float BaseScaleOf(Actor actor) { State s; return actor != null && s_states.TryGetValue(actor, out s) && s.baseCaptured ? s.baseScale.x : -1f; } /// 프로브용 — 에디트 모드에는 구독자가 없다(RuntimeInitializeOnLoadMethod 는 Play 전용) → 핸들러를 직접 부른다. public static void ProbeSpawned(Actor actor) { var e = new SpawnedEvent { actor = actor, isBoss = actor != null && actor.IsSubRole(eSubRol.Boss), isElite = actor != null && actor.IsSubRole(eSubRol.Elite), position = actor != null ? actor.Get_position() : Vector3.zero, time = Time.unscaledTime, frame = Time.frameCount }; OnSpawned(in e); } /// 프로브용 — 가짜 Killed. public static void ProbeKilled(Actor victim, eSubRol subRole) { var e = new KilledEvent { victim = victim, killer = null, subRole = subRole, id = victim != null ? victim.Get_ID() : 0, position = victim != null ? victim.Get_position() : Vector3.zero, byDirectHit = true, time = Time.unscaledTime, frame = Time.frameCount }; OnKilled(in e); } /// 프로브용 카운터 초기화. public static void ResetDiagnostics() { SpawnCount = KillCount = ScaleApplied = ScaleSkipped = TintApplied = TintSkipped = StatesCreated = SkippedNotElite = SkippedDisabled = 0; LastInfo = ""; LastKillTime = -1f; LastAppliedScale = 0f; s_ttkCount = 0; s_ttkHead = 0; } } }