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