// ─────────────────────────────────────────────────────────────────────────────
// 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;
}
}
}