// ───────────────────────────────────────────────────────────────────────────── // 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` 조회는 전부 비할당. // 유일한 할당 = **처음 보는 인스턴스 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 { /// 원본 이펙트 풀 인스턴스에 크기 배율을 걸고 반납 시 되돌린다. 정적 · 멱등 · GC 0. public static class EffectScaleGuard { // ── 진단(프로브·QA 가 읽는다) /// 스윕 횟수 · 추적 중인 인스턴스 수 · 규칙이 붙은 인스턴스 수. public static int SweepCount, TrackedCount, RuledCount; /// 배율을 건 횟수 · 되돌린 횟수 · 슬롯 부족으로 건너뛴 수. public static int AppliedCount, RestoredCount, SkippedCount; /// 현재 배율이 걸려 있는 인스턴스 수. public static int ActiveScaled; /// 대상 액터 높이로 배율을 다시 계산한 횟수 · 대상을 못 찾아 고정 배율로 떨어진 횟수. public static int HeightResolved, HeightMissed; /// 마지막으로 배율을 건 이름·배율(표본). 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 s_map = new Dictionary(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; } } // ───────────────────────────────────────── 틱 /// 러너가 LateUpdate 마다 부른다. C8·주기 판정은 여기서 한다. 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); } } /// 루트의 직속 자식을 훑는다. 처음 보는 것은 등록(예산 소모)하고, 아는 것은 활성/비활성만 반영한다. 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; } /// 처음 보는 인스턴스를 등록한다(이름 1회 읽기 = 유일한 할당). 슬롯이 없으면 −1. 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; } /// 활성이면 배율을 대입하고 비활성이면 되돌린다(둘 다 멱등). 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--; } } /// 고정 배율 · (옵션) 대상 액터 높이 기반 재계산. 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 피격 이펙트 = 적 상단 /// 적 상단으로 올린 횟수 · 주인 적을 못 찾아 원본 위치로 둔 횟수 · 마지막 높이(검증용). public static int HitTopApplied, HitTopMissed; public static float LastHitTopY; /// /// 피격 이펙트를 그 적의 **상단**(Actor.tf_Top = 게임이 eEffectLocation.Top 에 쓰는 머리 앵커)으로 올린다. /// 원본은 몸 중심(`target.Get_Center_position()`)에 스폰하므로 y 만 갈아끼운다(x·z = 타격 지점 유지). /// 주인 적을 못 찾으면 **아무것도 하지 않는다**(원본 위치 그대로 · HitTopMissed 로 센다). /// 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; } /// 이 이펙트가 따라다니는 대상 액터의 실높이(발밑→머리). 원본 private 필드라 리플렉션(캐시 1회). 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() : 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 · 정리 /// WL 스폰 지점용(후속 1줄 연결). 스폰 직후 부르면 그 인스턴스를 즉시 등록·적용한다. 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); } /// 적용된 배율을 전부 되돌린다(C8 · 씬 전환 · 프로브). 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; } /// 파괴된 인스턴스 슬롯을 정리한다(슬롯이 꽉 찼을 때 · 씬 전환 뒤). 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; } /// 프로브용: 카운터·추적을 비운다(적용분은 먼저 원복한다). 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; } } }