497 lines
23 KiB
C#
497 lines
23 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// DashAfterImage.cs — 대시 잔상(811g-② · SkinnedMeshRenderer.BakeMesh 고스트 · 원본 훅 0 · 새 셰이더/머티리얼 에셋 0)
|
||
//
|
||
// PD 지시 #813 · 발주서 WL-811gh §1-3 · 설계안 §C 811g 행 (2026-09-09)
|
||
//
|
||
// ■ 무엇을 하나 (CombatEvents.Dash 구독 · DashDriver 가 이미 RaiseDashBegan 을 발행한다 — 실측 DashDriver.cs:158)
|
||
// 대시 시작 → 0.06 s 간격으로 고스트 3개를 남기고 각각 0.25 s 동안 알파를 0 으로 줄인다.
|
||
// 고스트 = PC 의 SkinnedMeshRenderer 를 그 순간 포즈 그대로 BakeMesh 한 정적 메시.
|
||
//
|
||
// ■ 왜 BakeMesh 인가 / 예산
|
||
// · 스킨 애니메이션을 그 프레임 포즈로 굳혀야 잔상이 '그 자세' 로 남는다. 머티리얼 교체·셰이더 추가가 필요 없다.
|
||
// · 메시·GameObject·MaterialPropertyBlock 은 전부 **풀**(기본 6)에서 재사용한다 → 2회차부터 할당 0.
|
||
// · BakeMesh 호출 = 대시 1회당 (고스트 수 × SkinnedMeshRenderer 수) · SO 상한(ghostCount · ghostMaxRenderers).
|
||
// · 런타임 머티리얼은 **1개**(모든 고스트가 공유 · 색/알파는 MaterialPropertyBlock) → SetPass 증가는 반투명 큐 1개분.
|
||
// · EffectBudget(AfterImage · 코어 SO afterImageMax) 경유 · 절두체 밖이면 스폰 자체를 건너뛴다.
|
||
//
|
||
// ■ 색 (발주서: 클래스 원소 색 · 없으면 흰색)
|
||
// ① 최근 시전 스킬의 추가효과(eSkillExtraType · 811i 와 같은 기준) 색 → ② 클래스 ID(Actor.Get_ID()) 색 → ③ 기본(흰색).
|
||
// ①·② 표는 SO 에 있고 비어 있으면 자동으로 다음 단계로 내려간다.
|
||
//
|
||
// ■ 대시가 없는 클래스
|
||
// AttackStepDriver.StepCount(정적 카운터) 변화를 폴링해 공격 전진 시작에도 1개(옵션 SO · 기본 off).
|
||
// 폴링이라 AttackStepDriver·원본 파일을 한 줄도 건드리지 않는다.
|
||
//
|
||
// ■ C8 롤백 — 에셋이 없거나 enabled=0 또는 ghostEnabled=0 이면 구독만 하고 아무 것도 만들지 않는다.
|
||
//
|
||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
using WL.Combat.Core;
|
||
|
||
namespace WL.Combat.Reaction
|
||
{
|
||
public static class DashAfterImage
|
||
{
|
||
sealed class Ghost
|
||
{
|
||
public GameObject root;
|
||
public Transform[] parts;
|
||
public MeshFilter[] filters;
|
||
public MeshRenderer[] renders;
|
||
public Mesh[] meshes;
|
||
public MaterialPropertyBlock mpb;
|
||
public bool active, budget;
|
||
public int used;
|
||
public float startAt, endAt;
|
||
public Color color;
|
||
}
|
||
|
||
static Ghost[] s_pool;
|
||
static Material s_material;
|
||
static bool s_materialTried;
|
||
static int s_colorPropId = -1;
|
||
static bool s_hasColorProp;
|
||
|
||
static readonly List<SkinnedMeshRenderer> s_tmpSmr = new List<SkinnedMeshRenderer>(8);
|
||
static SkinnedMeshRenderer[] s_smr;
|
||
static Actor s_smrOwner;
|
||
|
||
// ── 예약 스폰(간격을 두고 하나씩)
|
||
static Actor s_pendingActor;
|
||
static int s_pendingLeft;
|
||
static float s_nextSpawnAt;
|
||
|
||
// ── 최근 스킬 원소 기억
|
||
static int s_lastElement = -1;
|
||
static float s_lastElementAt = -999f;
|
||
|
||
// ── AttackStep 폴링
|
||
static int s_lastStepCount = -1;
|
||
|
||
// ── 진단(프로브가 읽는다)
|
||
public static bool Subscribed;
|
||
public static int SpawnCount, SkippedBudget, SkippedOffscreen, SkippedNoRenderer, SkippedNoShader, SkippedPoolFull, ReleasedCount, PoolCreated, BakeCount, StepSpawnCount;
|
||
public static string LastInfo = "";
|
||
public static Color LastColor;
|
||
public static int ActiveCount { get { int n = 0; if (s_pool != null) for (int i = 0; i < s_pool.Length; i++) if (s_pool[i] != null && s_pool[i].active) n++; return n; } }
|
||
public static int PoolSize { get { return s_pool != null ? s_pool.Length : 0; } }
|
||
public static bool MaterialReady { get { return s_material != null; } }
|
||
public static string MaterialShaderName { get { return s_material != null && s_material.shader != null ? s_material.shader.name : ""; } }
|
||
|
||
static WLImpactTierSettings St { get { return WLImpactTierSettings.Instance; } }
|
||
static bool Verbose { get { var st = St; return st != null && st.verboseLog; } }
|
||
|
||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
|
||
static void Register()
|
||
{
|
||
CombatEvents.Dash.Add(OnDash);
|
||
CombatEvents.SkillFired.Add(OnSkillFired);
|
||
CombatEvents.SkillCast.Add(OnSkillCast);
|
||
Subscribed = true;
|
||
}
|
||
|
||
// ───────────────────────────────────────── 이벤트
|
||
static void OnDash(in DashEvent e)
|
||
{
|
||
if (!e.began) return;
|
||
var st = St;
|
||
if (st == null || !WLImpactTierSettings.Enabled || !st.ghostEnabled) return;
|
||
Actor actor = e.actor != null ? (Actor)e.actor : MyValue.MyPC;
|
||
if (actor == null) return;
|
||
Schedule(st, actor, Mathf.Max(0, st.ghostCount));
|
||
}
|
||
|
||
static void OnSkillFired(in SkillFiredEvent e) { RememberElement(e.actor, e.skill); }
|
||
static void OnSkillCast(in SkillCastEvent e) { RememberElement(e.actor, e.skill); }
|
||
|
||
static void RememberElement(Actor actor, SkillListTableData skill)
|
||
{
|
||
if (skill == null || actor == null || !actor.IsMainPC()) return;
|
||
s_lastElement = (int)skill.e_SkillExtraType;
|
||
s_lastElementAt = ImpactRunner.Now;
|
||
}
|
||
|
||
static void Schedule(WLImpactTierSettings st, Actor actor, int count)
|
||
{
|
||
if (count <= 0) return;
|
||
s_pendingActor = actor;
|
||
s_pendingLeft = count;
|
||
s_nextSpawnAt = ImpactRunner.Now; // 첫 개는 즉시
|
||
ImpactRunner.Ensure();
|
||
}
|
||
|
||
// ───────────────────────────────────────── 틱(ImpactRunner)
|
||
internal static void Tick(float now)
|
||
{
|
||
var st = St;
|
||
bool on = st != null && WLImpactTierSettings.Enabled && st.ghostEnabled;
|
||
|
||
// ① 예약 스폰
|
||
if (on && s_pendingLeft > 0 && now >= s_nextSpawnAt)
|
||
{
|
||
Spawn(st, s_pendingActor);
|
||
s_pendingLeft--;
|
||
s_nextSpawnAt = now + Mathf.Max(0.005f, st.ghostIntervalSeconds);
|
||
if (s_pendingLeft <= 0) s_pendingActor = null;
|
||
}
|
||
else if (!on && s_pendingLeft > 0) { s_pendingLeft = 0; s_pendingActor = null; }
|
||
|
||
// ② 공격 전진(대시 없는 클래스) 폴링 — 원본·AttackStepDriver 무수정
|
||
if (on && st.ghostOnAttackStep)
|
||
{
|
||
int sc = WL.Combat.AttackStepDriver.StepCount;
|
||
if (s_lastStepCount < 0) s_lastStepCount = sc;
|
||
else if (sc != s_lastStepCount)
|
||
{
|
||
s_lastStepCount = sc;
|
||
if (s_pendingLeft <= 0) { Schedule(st, MyValue.MyPC, Mathf.Max(0, st.ghostAttackStepCount)); StepSpawnCount++; }
|
||
}
|
||
}
|
||
|
||
// ③ 페이드·반환
|
||
if (s_pool == null) return;
|
||
for (int i = 0; i < s_pool.Length; i++)
|
||
{
|
||
var g = s_pool[i];
|
||
if (g == null || !g.active) continue;
|
||
if (!on || now >= g.endAt) { Retire(g); continue; }
|
||
float k = Mathf.Clamp01((now - g.startAt) / Mathf.Max(0.01f, g.endAt - g.startAt));
|
||
SetAlpha(g, g.color.a * (1f - k));
|
||
}
|
||
}
|
||
|
||
// ───────────────────────────────────────── 스폰
|
||
static void Spawn(WLImpactTierSettings st, Actor actor)
|
||
{
|
||
if (actor == null) { SkippedNoRenderer++; return; }
|
||
if (st.ghostSkipOffscreen && !EffectBudget.IsInView(actor.Get_position())) { SkippedOffscreen++; return; }
|
||
|
||
var smr = GetRenderers(st, actor);
|
||
if (smr == null || smr.Length == 0) { SkippedNoRenderer++; return; }
|
||
if (!EnsureMaterial(st)) { SkippedNoShader++; return; }
|
||
|
||
var g = Rent(st);
|
||
if (g == null) { SkippedPoolFull++; return; }
|
||
|
||
bool budget = true;
|
||
if (st.ghostUseEffectBudget)
|
||
{
|
||
budget = EffectBudget.TryAcquire(EffectBudgetKind.AfterImage);
|
||
if (!budget) { SkippedBudget++; Return(g); return; }
|
||
}
|
||
g.budget = budget;
|
||
|
||
int n = Mathf.Min(smr.Length, g.parts.Length);
|
||
int used = 0;
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
var r = smr[i];
|
||
if (r == null || !r.enabled || r.sharedMesh == null) continue;
|
||
r.BakeMesh(g.meshes[used]);
|
||
BakeCount++;
|
||
g.filters[used].sharedMesh = g.meshes[used];
|
||
var rt = r.transform;
|
||
g.parts[used].SetPositionAndRotation(rt.position, rt.rotation);
|
||
g.parts[used].localScale = rt.lossyScale;
|
||
g.parts[used].gameObject.SetActive(true);
|
||
used++;
|
||
}
|
||
for (int i = used; i < g.parts.Length; i++) g.parts[i].gameObject.SetActive(false);
|
||
|
||
if (used == 0)
|
||
{
|
||
if (g.budget) EffectBudget.Release(EffectBudgetKind.AfterImage);
|
||
Return(g);
|
||
SkippedNoRenderer++;
|
||
return;
|
||
}
|
||
|
||
g.used = used;
|
||
g.color = ResolveColor(st, actor);
|
||
LastColor = g.color;
|
||
g.startAt = ImpactRunner.Now;
|
||
g.endAt = g.startAt + Mathf.Max(0.02f, st.ghostFadeSeconds);
|
||
g.active = true;
|
||
g.root.SetActive(true);
|
||
SetAlpha(g, g.color.a);
|
||
SpawnCount++;
|
||
LastInfo = "ghost " + used + " part(s)";
|
||
if (Verbose) Debug.Log("[DashAfterImage] 고스트 " + used + "개 파트 · color=" + g.color + " active=" + ActiveCount);
|
||
}
|
||
|
||
static void Retire(Ghost g)
|
||
{
|
||
g.active = false;
|
||
g.root.SetActive(false);
|
||
if (g.budget) { EffectBudget.Release(EffectBudgetKind.AfterImage); g.budget = false; }
|
||
ReleasedCount++;
|
||
}
|
||
|
||
static void Return(Ghost g) { g.active = false; g.root.SetActive(false); }
|
||
|
||
// ───────────────────────────────────────── 풀
|
||
static Ghost Rent(WLImpactTierSettings st)
|
||
{
|
||
EnsurePool(st);
|
||
for (int i = 0; i < s_pool.Length; i++) if (s_pool[i] != null && !s_pool[i].active) return s_pool[i];
|
||
return null;
|
||
}
|
||
|
||
static void EnsurePool(WLImpactTierSettings st)
|
||
{
|
||
int size = Mathf.Clamp(st.ghostPoolSize, 1, 24);
|
||
int parts = Mathf.Clamp(st.ghostMaxRenderers, 1, 8);
|
||
if (s_pool != null && s_pool.Length == size && s_pool[0] != null && s_pool[0].parts.Length == parts) return;
|
||
DestroyPool();
|
||
s_pool = new Ghost[size];
|
||
for (int i = 0; i < size; i++) s_pool[i] = CreateGhost(st, i, parts);
|
||
PoolCreated++;
|
||
}
|
||
|
||
static Ghost CreateGhost(WLImpactTierSettings st, int index, int parts)
|
||
{
|
||
var root = new GameObject("__WLDashGhost" + index);
|
||
root.hideFlags = HideFlags.DontSave;
|
||
if (Application.isPlaying) Object.DontDestroyOnLoad(root); // 에디트 모드(프로브)에서는 경고가 나므로 부르지 않는다
|
||
root.SetActive(false);
|
||
var g = new Ghost
|
||
{
|
||
root = root,
|
||
parts = new Transform[parts],
|
||
filters = new MeshFilter[parts],
|
||
renders = new MeshRenderer[parts],
|
||
meshes = new Mesh[parts],
|
||
mpb = new MaterialPropertyBlock()
|
||
};
|
||
for (int i = 0; i < parts; i++)
|
||
{
|
||
var go = new GameObject("part" + i);
|
||
go.transform.SetParent(root.transform, false);
|
||
go.SetActive(false);
|
||
g.parts[i] = go.transform;
|
||
g.filters[i] = go.AddComponent<MeshFilter>();
|
||
var mr = go.AddComponent<MeshRenderer>();
|
||
mr.sharedMaterial = s_material;
|
||
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||
mr.receiveShadows = false;
|
||
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
|
||
mr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off;
|
||
g.renders[i] = mr;
|
||
var mesh = new Mesh();
|
||
mesh.name = "__WLDashGhostMesh" + index + "_" + i;
|
||
mesh.hideFlags = HideFlags.DontSave;
|
||
mesh.MarkDynamic();
|
||
g.meshes[i] = mesh;
|
||
}
|
||
return g;
|
||
}
|
||
|
||
static void SetAlpha(Ghost g, float alpha)
|
||
{
|
||
if (!s_hasColorProp) return;
|
||
var c = g.color; c.a = alpha;
|
||
g.mpb.Clear();
|
||
g.mpb.SetColor(s_colorPropId, c);
|
||
for (int i = 0; i < g.used; i++) if (g.renders[i] != null) g.renders[i].SetPropertyBlock(g.mpb);
|
||
}
|
||
|
||
// ───────────────────────────────────────── 머티리얼(런타임 1개 · 에셋 0)
|
||
static bool EnsureMaterial(WLImpactTierSettings st)
|
||
{
|
||
if (s_material != null) return true;
|
||
if (s_materialTried) return false;
|
||
s_materialTried = true;
|
||
|
||
Shader sh = null;
|
||
var names = st.ghostShaderNames;
|
||
if (names != null)
|
||
for (int i = 0; i < names.Length && sh == null; i++)
|
||
if (!string.IsNullOrEmpty(names[i])) sh = Shader.Find(names[i]);
|
||
if (sh == null) { if (Verbose) Debug.Log("[DashAfterImage] 셰이더 후보를 못 찾음 — 잔상 비활성"); return false; }
|
||
|
||
var m = new Material(sh);
|
||
m.name = "__WLDashGhostMat";
|
||
m.hideFlags = HideFlags.DontSave;
|
||
// URP Unlit 반투명 세팅(런타임) — 이 값들은 렌더링 배관이라 SO 튜닝 값이 아니다.
|
||
if (m.HasProperty("_Surface")) m.SetFloat("_Surface", 1f);
|
||
if (m.HasProperty("_Blend")) m.SetFloat("_Blend", 0f);
|
||
if (m.HasProperty("_SrcBlend")) m.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha);
|
||
if (m.HasProperty("_DstBlend")) m.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
|
||
if (m.HasProperty("_ZWrite")) m.SetFloat("_ZWrite", 0f);
|
||
if (m.HasProperty("_AlphaClip")) m.SetFloat("_AlphaClip", 0f);
|
||
m.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
|
||
m.DisableKeyword("_ALPHATEST_ON");
|
||
m.SetOverrideTag("RenderType", "Transparent");
|
||
m.renderQueue = st.ghostRenderQueue > 0 ? st.ghostRenderQueue : 3000;
|
||
|
||
s_hasColorProp = false;
|
||
var props = st.ghostColorProperties;
|
||
if (props != null)
|
||
for (int i = 0; i < props.Length; i++)
|
||
if (!string.IsNullOrEmpty(props[i]) && sh.FindPropertyIndex(props[i]) >= 0)
|
||
{ s_colorPropId = Shader.PropertyToID(props[i]); s_hasColorProp = true; break; }
|
||
|
||
s_material = m;
|
||
// 풀이 먼저 만들어졌으면 머티리얼을 붙여 준다.
|
||
if (s_pool != null)
|
||
for (int i = 0; i < s_pool.Length; i++)
|
||
{
|
||
var g = s_pool[i];
|
||
if (g == null) continue;
|
||
for (int j = 0; j < g.renders.Length; j++) if (g.renders[j] != null) g.renders[j].sharedMaterial = s_material;
|
||
}
|
||
if (Verbose) Debug.Log("[DashAfterImage] 고스트 머티리얼 = " + sh.name + " colorProp=" + s_hasColorProp);
|
||
return true;
|
||
}
|
||
|
||
// ───────────────────────────────────────── 렌더러 캐시
|
||
static SkinnedMeshRenderer[] GetRenderers(WLImpactTierSettings st, Actor actor)
|
||
{
|
||
if (s_smrOwner == actor && s_smr != null)
|
||
{
|
||
bool ok = true;
|
||
for (int i = 0; i < s_smr.Length; i++) if (s_smr[i] == null) { ok = false; break; }
|
||
if (ok) return s_smr;
|
||
}
|
||
s_tmpSmr.Clear();
|
||
actor.GetComponentsInChildren<SkinnedMeshRenderer>(true, s_tmpSmr);
|
||
int max = Mathf.Clamp(st.ghostMaxRenderers, 1, 8);
|
||
int n = Mathf.Min(s_tmpSmr.Count, max);
|
||
s_smr = new SkinnedMeshRenderer[n];
|
||
for (int i = 0; i < n; i++) s_smr[i] = s_tmpSmr[i];
|
||
s_tmpSmr.Clear();
|
||
s_smrOwner = actor;
|
||
return s_smr;
|
||
}
|
||
|
||
// ───────────────────────────────────────── 색
|
||
static Color ResolveColor(WLImpactTierSettings st, Actor actor)
|
||
{
|
||
Color c = st.ghostColorDefault;
|
||
bool found = false;
|
||
|
||
if (st.ghostUseSkillElement && s_lastElement > 0 &&
|
||
ImpactRunner.Now - s_lastElementAt <= Mathf.Max(0f, st.ghostElementMemorySeconds))
|
||
{
|
||
var arr = st.ghostElementColors;
|
||
if (arr != null)
|
||
for (int i = 0; i < arr.Length; i++)
|
||
if (arr[i].extraType == s_lastElement) { c = arr[i].color; found = true; break; }
|
||
}
|
||
|
||
if (!found)
|
||
{
|
||
var arr = st.ghostClassColors;
|
||
if (arr != null)
|
||
{
|
||
int classId = actor.Get_ID();
|
||
for (int i = 0; i < arr.Length; i++)
|
||
if (arr[i].classId == classId) { c = arr[i].color; found = true; break; }
|
||
}
|
||
}
|
||
|
||
c.a = Mathf.Clamp01(st.ghostStartAlpha);
|
||
return c;
|
||
}
|
||
|
||
// ───────────────────────────────────────── 정리
|
||
/// <summary>진행 중인 고스트를 전부 끄고 예산을 돌려준다(러너 파괴 · 앱 종료 · 프로브 정리). 풀은 유지.</summary>
|
||
public static void RestoreAll()
|
||
{
|
||
s_pendingLeft = 0; s_pendingActor = null;
|
||
if (s_pool == null) return;
|
||
for (int i = 0; i < s_pool.Length; i++)
|
||
{
|
||
var g = s_pool[i];
|
||
if (g != null && g.active) Retire(g);
|
||
}
|
||
}
|
||
|
||
/// <summary>씬 전환: 고스트를 끄고 렌더러 캐시(파괴된 Actor 참조)를 버린다. 풀 오브젝트는 DontDestroyOnLoad 라 유지.</summary>
|
||
public static void ClearAll()
|
||
{
|
||
RestoreAll();
|
||
s_smr = null; s_smrOwner = null; s_lastStepCount = -1;
|
||
}
|
||
|
||
/// <summary>풀·머티리얼·메시를 전부 파괴한다(프로브 정리 · 도메인 리로드 전).</summary>
|
||
public static void DestroyPool()
|
||
{
|
||
if (s_pool != null)
|
||
{
|
||
for (int i = 0; i < s_pool.Length; i++)
|
||
{
|
||
var g = s_pool[i];
|
||
if (g == null) continue;
|
||
if (g.active && g.budget) EffectBudget.Release(EffectBudgetKind.AfterImage);
|
||
for (int j = 0; j < g.meshes.Length; j++) if (g.meshes[j] != null) DestroySafe(g.meshes[j]);
|
||
if (g.root != null) DestroySafe(g.root);
|
||
}
|
||
s_pool = null;
|
||
}
|
||
}
|
||
|
||
static void DestroySafe(Object o)
|
||
{
|
||
if (o == null) return;
|
||
if (Application.isPlaying) Object.Destroy(o); else Object.DestroyImmediate(o);
|
||
}
|
||
|
||
/// <summary>프로브용: 강제로 즉시 1개 남긴다(예약 없이).</summary>
|
||
public static void ForceSpawn(Actor actor)
|
||
{
|
||
var st = St;
|
||
if (st == null) return;
|
||
Spawn(st, actor != null ? actor : MyValue.MyPC);
|
||
}
|
||
|
||
/// <summary>프로브용: 예약 스폰 남은 수.</summary>
|
||
public static int PendingCount { get { return s_pendingLeft; } }
|
||
|
||
/// <summary>
|
||
/// 프로브용 — 가짜 Dash(began) 를 구독자 경로 그대로 흘린다.
|
||
/// 에디트 모드에서는 RuntimeInitializeOnLoadMethod 가 돌지 않아 구독이 없으므로 핸들러를 직접 부른다.
|
||
/// </summary>
|
||
public static void ProbeDashBegan(PCActor actor)
|
||
{
|
||
var e = new DashEvent { actor = actor, began = true, distance = 0f, endedInAttack = false, time = Time.unscaledTime, frame = Time.frameCount };
|
||
OnDash(in e);
|
||
}
|
||
|
||
/// <summary>프로브용 — 예약된 스폰을 간격 대기 없이 전부 즉시 처리한다(에디트 모드에는 프레임이 없다).</summary>
|
||
public static void ProbeFlushPending(Actor actor)
|
||
{
|
||
var st = St;
|
||
if (st == null) return;
|
||
int guard = 0;
|
||
while (s_pendingLeft > 0 && guard++ < 32)
|
||
{
|
||
Spawn(st, s_pendingActor != null ? s_pendingActor : actor);
|
||
s_pendingLeft--;
|
||
}
|
||
s_pendingActor = null;
|
||
}
|
||
|
||
/// <summary>프로브용 — 지금 살아 있는 고스트를 강제로 만료시킨다(페이드 끝 상태 확인).</summary>
|
||
public static void ProbeExpireAll()
|
||
{
|
||
if (s_pool == null) return;
|
||
for (int i = 0; i < s_pool.Length; i++)
|
||
{
|
||
var g = s_pool[i];
|
||
if (g != null && g.active) Retire(g);
|
||
}
|
||
}
|
||
|
||
/// <summary>프로브용 카운터 초기화.</summary>
|
||
public static void ResetDiagnostics()
|
||
{
|
||
SpawnCount = SkippedBudget = SkippedOffscreen = SkippedNoRenderer = SkippedNoShader = SkippedPoolFull = ReleasedCount = PoolCreated = BakeCount = StepSpawnCount = 0;
|
||
LastInfo = ""; s_lastStepCount = -1;
|
||
}
|
||
}
|
||
}
|