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