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

391 lines
18 KiB
C#
Raw Permalink Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// 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 bool[] s_isHit; // #816x: 피격 이펙트인가(이름 접두사 판정 · 등록 때 1회)
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++;
// #816x — 피격 이펙트 판정(이름 접두사 · 등록 때 1회). 규칙이 없어도 위치·배율은 적용한다.
s_isHit[slot] = !string.IsNullOrEmpty(st.hitNamePrefix)
&& nm.StartsWith(st.hitNamePrefix, StringComparison.Ordinal);
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;
bool hit = s_isHit[slot] && st.hitTopEnabled; // #816x
if (s_rule[slot] < 0 && !hit) return; // 규칙 없음 + 피격 이펙트도 아님 = 원본 100 %
bool active = t.gameObject.activeSelf;
if (active)
{
if (s_applied[slot]) return;
float f = s_rule[slot] >= 0 ? ResolveFactor(slot, st) : 1f;
if (hit) f *= Mathf.Max(0.01f, st.hitScaleMultiplier); // #816x 크기 ×1.5
if (f <= 0f) return;
t.localScale = s_orig[slot] * f; // 대입(멱등) — 누적 곱이 아니다
if (hit) LiftToEnemyTop(t, st); // #816x 적 상단으로 이동
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));
}
static void EnsureReflection()
{
if (s_reflectDone) return;
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);
}
// ───────────────────────────────────────── #816x 피격 이펙트 = 적 상단
/// <summary>적 상단으로 올린 횟수 · 주인 적을 못 찾아 원본 위치로 둔 횟수 · 마지막 높이(검증용).</summary>
public static int HitTopApplied, HitTopMissed;
public static float LastHitTopY;
/// <summary>
/// 피격 이펙트를 그 적의 **상단**(Actor.tf_Top = 게임이 eEffectLocation.Top 에 쓰는 머리 앵커)으로 올린다.
/// 원본은 몸 중심(`target.Get_Center_position()`)에 스폰하므로 y 만 갈아끼운다(x·z = 타격 지점 유지).
/// 주인 적을 못 찾으면 **아무것도 하지 않는다**(원본 위치 그대로 · HitTopMissed 로 센다).
/// </summary>
static void LiftToEnemyTop(Transform t, WLEffectScaleSettings st)
{
if (ActorInfo.Ins == null) { HitTopMissed++; return; }
EnsureReflection();
if (s_fiTfTop == null) { HitTopMissed++; return; }
Vector3 p = t.position;
float range = Mathf.Max(st.hitOwnerSearchRange, 0.1f);
var pc = MyValue.MyPC;
var actors = ActorInfo.Ins.Get_EnemyActors(pc != null && pc.IsEnemy(), p, range);
Actor best = null; float bestSq = float.MaxValue;
for (int i = 0; i < actors.Count; i++)
{
var a = actors[i];
if (a == null) continue;
if (!st.hitApplyToBoss && a.IsSubRole(eSubRol.Boss)) continue;
float sq = (a.Get_position() - p).sqrMagnitude;
if (sq < bestSq) { bestSq = sq; best = a; }
}
ActorInfo.Ins.Release_Actors(actors);
if (best == null) { HitTopMissed++; return; }
var top = s_fiTfTop.GetValue(best) as Transform; // 참조형 필드 → 박싱 없음
if (top == null) { HitTopMissed++; return; }
p.y = top.position.y + st.hitTopExtraY;
t.position = p;
HitTopApplied++; LastHitTopY = p.y;
}
/// <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;
EnsureReflection();
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);
Array.Resize(ref s_isHit, 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]; s_isHit[w] = s_isHit[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;
HitTopApplied = HitTopMissed = 0; LastHitTopY = 0f;
LastName = ""; LastFactor = 0f;
}
}
}