Project_WL/Assets/WL/Combat/Reaction/EliteMarker.cs

276 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>엘리트 몹 마커. 정적 · 새 Manager/Singleton 0 · 러너 0 · GC 0(상태는 액터당 1회 할당 = 풀 크기 상한).</summary>
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<Actor, State> s_states = new Dictionary<Actor, State>(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; } }
/// <summary>표본 평균 처치 시간(초). 표본이 없으면 -1.</summary>
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);
}
/// <summary>엘리트 판정 — 이벤트 스냅샷을 먼저 보고, 없으면 액터를 다시 읽는다(둘 다 Actor.m_SubRole 이 출처).</summary>
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 · 씬 전환 · 프로브)
/// <summary>모든 엘리트의 크기·색을 원복한다. 상태 캐시는 유지.</summary>
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;
}
}
/// <summary>씬 전환: 원복 후 상태 캐시(파괴된 Actor 참조)를 버린다.</summary>
public static void ClearAll()
{
RestoreAll();
s_states.Clear();
}
/// <summary>프로브용 — 이 액터가 지금 마커 상태인가.</summary>
public static bool IsMarked(Actor actor)
{
State s;
return actor != null && s_states.TryGetValue(actor, out s) && (s.scaled || s.tinted);
}
/// <summary>프로브용 — 이 액터의 기준(원본) 스케일. 없으면 -1.</summary>
public static float BaseScaleOf(Actor actor)
{
State s;
return actor != null && s_states.TryGetValue(actor, out s) && s.baseCaptured ? s.baseScale.x : -1f;
}
/// <summary>프로브용 — 에디트 모드에는 구독자가 없다(RuntimeInitializeOnLoadMethod 는 Play 전용) → 핸들러를 직접 부른다.</summary>
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);
}
/// <summary>프로브용 — 가짜 Killed.</summary>
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);
}
/// <summary>프로브용 카운터 초기화.</summary>
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;
}
}
}