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

221 lines
10 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// NullMaterialGuard.cs — 빈 머티리얼 슬롯 렌더러 비활성(D-40 마젠타 폴리곤)
//
// PD 지시 #813 · 발주서 WL-813m3 §1-4 · 근거 = Q9 `qa/W2-9_재채점_v7.md` §6
//
// ■ Q9 가 특정한 5종(전부 `InGameInfo/Effects/**` · `slot=0` 이 null)
// Effect_Hit_10501(Clone)/Pos
// Effect_Hit_AnubisSkill2(Clone)/vfx_Hit_Orb08_Orange/Light
// Effect_Poison(Clone)/GameObject
// Select_WindFairy(Clone)/PowerOrbSciFi05 (+ /OrbGlow · /Sparks)
// Hit_P_Batty_Meele(Clone)/FX_Yellow_Hit/Pos
// 🔴 다섯 개 **전부 원본(힘민지) 이펙트 풀**이 스폰한 것이다 — WL 이 만든 것이 아니다.
// 그래서 이 가드는 WL 스폰 지점에 훅을 넣는 대신 **풀 루트를 스윕**한다
// (그 스폰 파일들 = SkillSpectacle · LootBurst · LevelUpBurst 는 이번 발주의 수정 대상이 아니다).
// WL 스폰 지점에서 부를 공개 API `Inspect(GameObject)` 도 함께 둔다(후속 1줄 연결용).
//
// ■ 어떻게
// 루트(`InGameInfo.tf_Effects` · `tf_Objs` · 811i Live/Pool · 메인 PC)의 **직속 자식**을 주기적으로 본다.
// 처음 보는 인스턴스만 하위 렌더러를 전수 검사하고(인스턴스 ID 캐시 = 스폰 1회 검사),
// `sharedMaterials` 에 null 슬롯이 있으면 `Renderer.enabled = false`(SO 로 변경 가능).
// 풀은 같은 인스턴스를 재사용하므로 한 번 끈 렌더러는 계속 꺼져 있다.
//
// ■ GC 0
// `GetComponentsInChildren<Renderer>(true, List)` · `Renderer.GetSharedMaterials(List)` 둘 다 비할당 오버로드.
// 캐시는 `HashSet<int>`(성장 때만 할당) · 문자열은 **증거 표본 pathSampleMax 개까지만** 만든다.
//
// ■ C8 롤백: `WLNullMaterialSettings.enabled = 0` → 러너도 스윕도 없다(마젠타 그대로 = 원본 100 %).
// 🔴 원본 프리팹 수정 0 — 근본 수리는 아트 발주(완료보고 「아트 수정 목록」).
// 🔴 Debug.LogError/LogException 금지(813z).
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace WL.Combat.Reaction
{
/// <summary>빈 머티리얼 슬롯 렌더러를 찾아 끈다. 정적 · 스폰 1회 검사 · GC 0.</summary>
public static class NullMaterialGuard
{
// ── 진단(프로브가 읽는다)
/// <summary>스윕 횟수 · 검사한 오브젝트 수 · 검사한 렌더러 수.</summary>
public static int SweepCount, InspectedObjects, InspectedRenderers;
/// <summary>빈 슬롯을 가진 렌더러 수 · 실제로 끈 수.</summary>
public static int NullMaterialRenderers, DisabledRenderers;
/// <summary>결함이 있던 오브젝트 수(렌더러가 아니라 스폰 단위).</summary>
public static int OffendingObjects;
/// <summary>마지막으로 끈 경로 · 마지막 스윕 시각(unscaled).</summary>
public static string LastPath = "";
public static float LastSweepTime;
/// <summary>증거 표본(경로 문자열 · pathSampleMax 개까지).</summary>
public static readonly List<string> Samples = new List<string>(16);
static readonly HashSet<int> s_inspected = new HashSet<int>();
static readonly List<Renderer> s_rends = new List<Renderer>(32);
static readonly List<Material> s_mats = new List<Material>(8);
static StringBuilder s_sb;
static float s_nextSweep;
static WLNullMaterialSettings St { get { return WLNullMaterialSettings.Instance; } }
/// <summary>캐시에 담긴 인스턴스 수(프로브).</summary>
public static int CacheCount { get { return s_inspected.Count; } }
// ───────────────────────────────────────── 스윕
/// <summary>러너가 매 프레임 부른다. 주기·비활성 판정은 여기서 한다(C8 = 즉시 반환).</summary>
public static void Tick(float unscaledNow)
{
var st = St;
if (st == null || !WLNullMaterialSettings.Enabled) return;
if (unscaledNow < s_nextSweep) return;
s_nextSweep = unscaledNow + Mathf.Max(0f, st.sweepIntervalSeconds);
Sweep(st, unscaledNow);
}
static void Sweep(WLNullMaterialSettings st, float unscaledNow)
{
SweepCount++; LastSweepTime = unscaledNow;
if (s_inspected.Count > Mathf.Max(64, st.inspectCacheMax)) s_inspected.Clear();
int budget = Mathf.Max(1, st.maxInspectPerSweep);
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, WLNullMaterialSettings st, int budget)
{
if (root == null || budget <= 0) return budget;
int n = root.childCount;
for (int i = 0; i < n && budget > 0; i++)
{
var child = root.GetChild(i);
if (child == null) continue;
int id = child.GetInstanceID();
if (s_inspected.Contains(id)) continue;
s_inspected.Add(id);
budget--;
InspectTransform(child, st);
}
return budget;
}
// ───────────────────────────────────────── 검사
/// <summary>WL 스폰 지점용 공개 API — 스폰 직후 1회 부르면 된다(캐시 · 중복 호출 안전).</summary>
public static void Inspect(GameObject go)
{
var st = St;
if (st == null || !WLNullMaterialSettings.Enabled || go == null) return;
int id = go.transform.GetInstanceID();
if (s_inspected.Contains(id)) return;
s_inspected.Add(id);
InspectTransform(go.transform, st);
}
static void InspectTransform(Transform t, WLNullMaterialSettings st)
{
InspectedObjects++;
s_rends.Clear();
try { t.GetComponentsInChildren<Renderer>(true, s_rends); }
catch (Exception) { return; }
bool offending = false;
for (int i = 0; i < s_rends.Count; i++)
{
var r = s_rends[i];
if (r == null) continue;
InspectedRenderers++;
if (!HasNullSlot(r, st)) continue;
NullMaterialRenderers++;
offending = true;
if (Samples.Count < Mathf.Max(0, st.pathSampleMax))
{
string path = PathOf(r.transform);
Samples.Add(path);
LastPath = path;
}
switch (st.action)
{
case NullMaterialAction.DisableRenderer:
if (r.enabled) { r.enabled = false; DisabledRenderers++; }
break;
case NullMaterialAction.DeactivateObject:
if (r.gameObject.activeSelf) { r.gameObject.SetActive(false); DisabledRenderers++; }
break;
default:
break; // MeasureOnly — 계측만
}
if (st.verboseLog)
Debug.Log("[WL813m3] nullMaterial " + (st.action == NullMaterialAction.MeasureOnly ? "measured " : "disabled ")
+ (Samples.Count > 0 ? LastPath : r.name)); // 🔴 Log 만(813z)
}
if (offending) OffendingObjects++;
}
static bool HasNullSlot(Renderer r, WLNullMaterialSettings st)
{
s_mats.Clear();
try { r.GetSharedMaterials(s_mats); }
catch (Exception) { return false; }
if (s_mats.Count == 0) return st.treatEmptyArrayAsNull;
for (int i = 0; i < s_mats.Count; i++)
if (s_mats[i] == null) return true;
return false;
}
static string PathOf(Transform t)
{
if (s_sb == null) s_sb = new StringBuilder(160); else s_sb.Length = 0;
s_sb.Append(t.name);
var p = t.parent;
int guard = 0;
while (p != null && guard++ < 12)
{
s_sb.Insert(0, '/');
s_sb.Insert(0, p.name);
p = p.parent;
}
return s_sb.ToString();
}
/// <summary>프로브용: 카운터·캐시 초기화.</summary>
public static void ResetDiagnostics()
{
SweepCount = InspectedObjects = InspectedRenderers = 0;
NullMaterialRenderers = DisabledRenderers = OffendingObjects = 0;
LastPath = ""; LastSweepTime = 0f; s_nextSweep = 0f;
Samples.Clear(); s_inspected.Clear();
}
}
}