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

337 lines
15 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.

// ─────────────────────────────────────────────────────────────────────────────
// EffectScaleGuard.cs — 원본 풀이 스폰하는 상태이상·히트 이펙트의 크기 가드(PD 지시 #813 · 발주서 WL-811s §1-3)
//
// ■ 무엇을 하나
// 원본 `InGameInfo.Show_Effect` 가 쓰는 이펙트 풀(`dic_str_Effect` · 전부 `tf_Effects` 의 직속 자식)을
// LateUpdate 에서 훑어, **활성화된 인스턴스**에 규칙 배율을 적용한다.
// 적용 = `localScale = 원 스케일 × 배율` **대입**(누적 곱이 아니다 → 몇 번 불려도 같은 값 = 멱등)
// 원복 = 원본 `TurnOff_GO.Off_Imm` 이 `SetActive(false)` 로 반납하면 원 스케일 대입
//
// ■ 왜 스폰 훅이 아니라 풀 스윕인가 (813m3 가 같은 이유로 같은 방식을 썼다)
// `Effect_Stun`(Actor.cs:1793) · `Effect_Burn`(Skill_Golem.cs:54) · SkillTypeConfig 의 상태이상 ·
// `ProjectileBase`(:365) 의 히트 이펙트는 **전부 원본 코드가 스폰**한다. 원본은 1줄도 고칠 수 없으므로
// (발주서 §2) 스폰 지점이 아니라 **풀 루트**를 본다. WL 스폰 지점용 공개 API `Apply(GameObject)` 도 함께 둔다.
//
// ■ 왜 LateUpdate 인가
// 원본 스폰은 Update·코루틴에서 일어난다. LateUpdate 는 그 뒤 · 렌더 앞이라 **활성화된 그 프레임에** 적용된다
// (Update 에서 돌면 스폰 순서에 따라 1프레임 원본 크기로 그려질 수 있다 = 과대 이펙트가 한 번 번쩍인다).
//
// ■ GC 0
// `Transform.GetChild` · `GetInstanceID` · `activeSelf` · `Dictionary<int,int>` 조회는 전부 비할당.
// 유일한 할당 = **처음 보는 인스턴스 1개당 `GameObject.name` 1회**(풀은 인스턴스를 재사용하므로 워밍 후 0 B).
//
// ■ C8 롤백: `WLEffectScaleSettings.enabled = 0`(또는 RuntimeOverride < 0) → 다음 틱에 전량 원복 후 무동작.
//
// 🔴 원본 프리팹/머티리얼/셰이더 무수정 — 런타임 인스턴스의 localScale 만 만진다.
// 🔴 Debug.LogError/LogException 금지(813z).
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace WL.Combat.Reaction
{
/// <summary>원본 이펙트 풀 인스턴스에 크기 배율을 걸고 반납 시 되돌린다. 정적 · 멱등 · GC 0.</summary>
public static class EffectScaleGuard
{
// ── 진단(프로브·QA 가 읽는다)
/// <summary>스윕 횟수 · 추적 중인 인스턴스 수 · 규칙이 붙은 인스턴스 수.</summary>
public static int SweepCount, TrackedCount, RuledCount;
/// <summary>배율을 건 횟수 · 되돌린 횟수 · 슬롯 부족으로 건너뛴 수.</summary>
public static int AppliedCount, RestoredCount, SkippedCount;
/// <summary>현재 배율이 걸려 있는 인스턴스 수.</summary>
public static int ActiveScaled;
/// <summary>대상 액터 높이로 배율을 다시 계산한 횟수 · 대상을 못 찾아 고정 배율로 떨어진 횟수.</summary>
public static int HeightResolved, HeightMissed;
/// <summary>마지막으로 배율을 건 이름·배율(표본).</summary>
public static string LastName = "";
public static float LastFactor;
// ── 추적 슬롯(사전 할당 · 병렬 배열)
static int[] s_id;
static Transform[] s_tf;
static Vector3[] s_orig;
static float[] s_factor; // 0 = 규칙 없음(손대지 않는다)
static int[] s_rule; // 1 = 규칙 없음
static bool[] s_applied;
static TurnOff_GO[] s_off;
static bool[] s_offResolved;
static int s_count;
static int s_capacity;
static readonly Dictionary<int, int> s_map = new Dictionary<int, int>(512);
static float s_nextSweep;
static bool s_sceneHooked;
// 원본 private 필드 리플렉션(대상 높이 옵션 전용 · 캐시 1회 · 참조형이라 박싱 없음)
static FieldInfo s_fiTarget2, s_fiTfTop;
static bool s_reflectDone;
static WLEffectScaleSettings St { get { return WLEffectScaleSettings.Instance; } }
// ───────────────────────────────────────── 틱
/// <summary>러너가 LateUpdate 마다 부른다. C8·주기 판정은 여기서 한다.</summary>
public static void Tick(float unscaledNow)
{
var st = St;
if (st == null || !WLEffectScaleSettings.Enabled)
{
if (ActiveScaled > 0) RestoreAll(); // C8: 끄면 다음 틱에 전량 원복
return;
}
if (unscaledNow < s_nextSweep) return;
s_nextSweep = unscaledNow + Mathf.Max(0f, st.sweepIntervalSeconds);
EnsureCapacity(st);
HookScene();
SweepCount++;
int budget = Mathf.Max(1, st.maxNewPerSweep);
var info = InGameInfo.Ins;
if (info != null)
{
if (st.sweepIngameEffects) budget = SweepRoot(info.tf_Effects, st, budget);
if (st.sweepIngameObjs) budget = SweepRoot(info.tf_Objs, st, budget);
}
if (st.sweepSkillSpectacle && SkillSpectacleRunner.Exists)
{
var runner = SkillSpectacleRunner.Ensure(); // Exists 가 true 라 새로 만들지 않는다
if (runner != null)
{
budget = SweepRoot(runner.LiveRoot, st, budget);
budget = SweepRoot(runner.PoolRoot, st, budget);
}
}
if (st.sweepMainPc)
{
var pc = MyValue.MyPC;
if (pc != null) budget = SweepRoot(pc.transform, st, budget);
}
}
/// <summary>루트의 직속 자식을 훑는다. 처음 보는 것은 등록(예산 소모)하고, 아는 것은 활성/비활성만 반영한다.</summary>
static int SweepRoot(Transform root, WLEffectScaleSettings st, int budget)
{
if (root == null) return budget;
int n = root.childCount;
for (int i = 0; i < n; i++)
{
var child = root.GetChild(i);
if (child == null) continue;
int id = child.GetInstanceID();
int slot;
if (!s_map.TryGetValue(id, out slot))
{
if (budget <= 0) continue;
budget--;
slot = Register(child, id, st);
if (slot < 0) continue;
}
UpdateSlot(slot, st);
}
return budget;
}
/// <summary>처음 보는 인스턴스를 등록한다(이름 1회 읽기 = 유일한 할당). 슬롯이 없으면 1.</summary>
static int Register(Transform t, int id, WLEffectScaleSettings st)
{
if (s_count >= s_capacity)
{
Compact();
if (s_count >= s_capacity) { SkippedCount++; return -1; }
}
int slot = s_count++;
s_id[slot] = id;
s_tf[slot] = t;
s_orig[slot] = t.localScale;
s_applied[slot] = false;
s_off[slot] = null;
s_offResolved[slot] = false;
string nm = t.name; // ← 인스턴스당 1회
int core = WLEffectScaleSettings.CoreLength(nm);
int r = st.FindRule(nm, core);
s_rule[slot] = r;
s_factor[slot] = r >= 0 ? Mathf.Max(0f, st.rules[r].scale) : 0f;
if (r >= 0) RuledCount++;
s_map[id] = slot;
TrackedCount = s_count;
return slot;
}
/// <summary>활성이면 배율을 대입하고 비활성이면 되돌린다(둘 다 멱등).</summary>
static void UpdateSlot(int slot, WLEffectScaleSettings st)
{
var t = s_tf[slot];
if (t == null) return;
if (s_rule[slot] < 0) return; // 규칙 없음 = 원본 100 %
bool active = t.gameObject.activeSelf;
if (active)
{
if (s_applied[slot]) return;
float f = ResolveFactor(slot, st);
if (f <= 0f) return;
t.localScale = s_orig[slot] * f; // 대입(멱등) — 누적 곱이 아니다
s_applied[slot] = true;
AppliedCount++; ActiveScaled++;
LastName = t.name; LastFactor = f;
if (st.verboseLog) Debug.Log("[WL811s] effectScale " + LastName + " ×" + f.ToString("F3")); // 🔴 Log 만(813z)
}
else if (s_applied[slot])
{
if (st.restoreOnReturn) t.localScale = s_orig[slot];
s_applied[slot] = false;
RestoredCount++; if (ActiveScaled > 0) ActiveScaled--;
}
}
/// <summary>고정 배율 · (옵션) 대상 액터 높이 기반 재계산.</summary>
static float ResolveFactor(int slot, WLEffectScaleSettings st)
{
int r = s_rule[slot];
float f = s_factor[slot];
if (!st.scaleByTargetHeight || r < 0) return f;
var rule = st.rules[r];
if (!rule.useTargetHeight || rule.baseDiameter <= 0f || rule.targetRatio <= 0f) return f;
float h;
if (!TryTargetHeight(slot, out h) || h <= 0f) { HeightMissed++; return f; }
HeightResolved++;
float byHeight = (h * rule.targetRatio) / rule.baseDiameter;
return Mathf.Clamp(byHeight, Mathf.Max(0.0001f, st.minScale), Mathf.Max(st.minScale, st.maxScale));
}
/// <summary>이 이펙트가 따라다니는 대상 액터의 실높이(발밑→머리). 원본 private 필드라 리플렉션(캐시 1회).</summary>
static bool TryTargetHeight(int slot, out float height)
{
height = 0f;
if (!s_offResolved[slot])
{
var tf = s_tf[slot];
s_off[slot] = tf != null ? tf.GetComponent<TurnOff_GO>() : null;
s_offResolved[slot] = true;
}
var off = s_off[slot];
if (off == null) return false;
if (!s_reflectDone)
{
s_reflectDone = true;
s_fiTarget2 = typeof(TurnOff_GO).GetField("m_target2", BindingFlags.Instance | BindingFlags.NonPublic);
s_fiTfTop = typeof(Actor).GetField("tf_Top", BindingFlags.Instance | BindingFlags.NonPublic);
}
if (s_fiTarget2 == null || s_fiTfTop == null) return false;
var actor = s_fiTarget2.GetValue(off) as Actor; // 참조형 필드 → 박싱 없음
if (actor == null) return false;
var top = s_fiTfTop.GetValue(actor) as Transform;
if (top == null) return false;
height = top.position.y - actor.transform.position.y;
return height > 0.01f;
}
// ───────────────────────────────────────── 공개 API · 정리
/// <summary>WL 스폰 지점용(후속 1줄 연결). 스폰 직후 부르면 그 인스턴스를 즉시 등록·적용한다.</summary>
public static void Apply(GameObject go)
{
var st = St;
if (st == null || !WLEffectScaleSettings.Enabled || go == null) return;
EnsureCapacity(st);
var t = go.transform;
int id = t.GetInstanceID();
int slot;
if (!s_map.TryGetValue(id, out slot))
{
slot = Register(t, id, st);
if (slot < 0) return;
}
UpdateSlot(slot, st);
}
/// <summary>적용된 배율을 전부 되돌린다(C8 · 씬 전환 · 프로브).</summary>
public static void RestoreAll()
{
for (int i = 0; i < s_count; i++)
{
if (!s_applied[i]) continue;
var t = s_tf[i];
if (t != null) t.localScale = s_orig[i];
s_applied[i] = false;
RestoredCount++;
}
ActiveScaled = 0;
}
static void EnsureCapacity(WLEffectScaleSettings st)
{
int want = Mathf.Max(32, st.trackCapacity);
if (s_id != null && s_capacity >= want) return;
Array.Resize(ref s_id, want);
Array.Resize(ref s_tf, want);
Array.Resize(ref s_orig, want);
Array.Resize(ref s_factor, want);
Array.Resize(ref s_rule, want);
Array.Resize(ref s_applied, want);
Array.Resize(ref s_off, want);
Array.Resize(ref s_offResolved, want);
s_capacity = want;
}
/// <summary>파괴된 인스턴스 슬롯을 정리한다(슬롯이 꽉 찼을 때 · 씬 전환 뒤).</summary>
static void Compact()
{
int w = 0;
for (int i = 0; i < s_count; i++)
{
if (s_tf[i] == null) continue;
if (w != i)
{
s_id[w] = s_id[i]; s_tf[w] = s_tf[i]; s_orig[w] = s_orig[i];
s_factor[w] = s_factor[i]; s_rule[w] = s_rule[i]; s_applied[w] = s_applied[i];
s_off[w] = s_off[i]; s_offResolved[w] = s_offResolved[i];
}
w++;
}
s_count = w;
s_map.Clear();
for (int i = 0; i < s_count; i++) s_map[s_id[i]] = i;
TrackedCount = s_count;
}
static void HookScene()
{
if (s_sceneHooked) return;
s_sceneHooked = true;
UnityEngine.SceneManagement.SceneManager.activeSceneChanged += OnSceneChanged;
}
static void OnSceneChanged(UnityEngine.SceneManagement.Scene a, UnityEngine.SceneManagement.Scene b)
{
s_map.Clear(); s_count = 0; TrackedCount = 0; ActiveScaled = 0;
}
/// <summary>프로브용: 카운터·추적을 비운다(적용분은 먼저 원복한다).</summary>
public static void ResetDiagnostics()
{
RestoreAll();
s_map.Clear(); s_count = 0; s_nextSweep = 0f;
SweepCount = TrackedCount = RuledCount = 0;
AppliedCount = RestoredCount = SkippedCount = ActiveScaled = 0;
HeightResolved = HeightMissed = 0;
LastName = ""; LastFactor = 0f;
}
}
}