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

690 lines
37 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// SkillSpectacle.cs — 스킬 스펙터클: 4슬롯 스킬의 차지 → 본체 → 착탄 3단 VFX 레이어 (보유 프리팹 · 클래스 원소 틴트 · 동시 상한 · GC 0)
//
// PD 지시 #813(1순위 축 "화려한 전투 액션 — 스킬이 쿨마다 화면에서 터진다") · 발주서 WL-811i §1-2 · 설계안 WL-811a §B-3 vfxSlots · §B-5-2 ·
// §C 811i 행 · 기준서 v1 §B 요소 3 · 기획안 v2 슬라이드 11(F-0B) (2026-09-09)
//
// ■ 무엇을 하나 (코어 이벤트 구독만 · 원본 훅 0 · Actor.cs/PCActor.cs/Core 무수정)
// SkillCast → 행(skillId + 시전자 클래스)의 charge 프리팹을 시전자 앵커에(따라감) · f_CastingTime>0 이면 그 시간(슬롯 4) · 0 이면 플래시
// SkillFired → body 프리팹을 시전자 앵커 + 전방 오프셋에(회전 옵션) — 원본 스킬 프리팹(Res_Addr/Skill)은 그대로 · **추가 레이어**
// HitboxHit → isSkill 명중마다 impact 프리팹을 피격자 앵커에 · 발동 1회당 impactMaxPerFire · 최소 간격 · noDmg 스킵
// HitConfirmed(Beater_pd = 스킬) → 같은 프레임·같은 피격자 HitboxHit 이 없었을 때만 폴백 착탄
// AttackStarted(메인 PC) → 그 클래스 4행 프리팹 프리로드(첫 스킬 전에 로드가 끝나게)
// ■ 예산(설계안 §B-5-2 · 기준서 요소 3)
// budgetClass Heavy 행 = EffectBudget.Heavy 슬롯 1개(코어 SO heavyEffectMax 2)를 차지/본체가 잡고 둘 다 끝나면 반납 · 초과 = 스킵(큐잉 없음)
// Medium = 이 시스템 로컬 mediumMax · Light = 없음 · 화면 밖(EffectBudget.IsInView) = 스킵 · 착탄은 발동당 상한만
// ■ GC 0
// 프리팹 = Addressables 경로 로드 1회(경로 문자열은 프리팹당 1회 결합) → 참조 캐시 · 인스턴스 = 자체 풀(프리팹당 poolMaxPerPrefab · 첫 사용 때 1개씩)
// · 활성 추적 = 고정 배열(trackCapacity) · 원본 TurnOff_GO 는 인스턴스에서 비활성화(매 활성화마다 코루틴을 만들던 경로 제거) → 수명은 자체 틱
// · 틴트 = 렌더러당 MaterialPropertyBlock 1개 캐시(원본 머티리얼 색 × 클래스 색 · 머티리얼 인스턴스 0) · 로그 문자열은 verboseLog 때만
// ■ 롤백(C8): WLSkillSpectacleSettings 에셋이 없거나 enabled=false / RuntimeDisabled 면 모든 구독자가 첫 줄에서 반환 = 원본 스킬 이펙트만.
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using WL.Combat.Core;
namespace WL.Combat.Reaction
{
public static class SkillSpectacle
{
// ───────────────────────────────────────── 내부 자료구조 (첫 사용 때만 할당)
/// <summary>프리팹 1종 = 엔트리 1개(이름 · 경로 · 로드 핸들 · 풀).</summary>
sealed class Entry
{
public string name, path;
public GameObject prefab;
public AsyncOperationHandle<GameObject> handle;
public bool loading, loaded, failed;
public Instance[] instances; public int count;
public Vector3 prefabScale = Vector3.one;
public float baseLifetime; public bool looping, lifetimeResolved;
public int particleSystems, maxParticles, renderers, tintableRenderers; // 진단(모바일 예산 추정)
}
/// <summary>풀 인스턴스 1개(렌더러·MPB·틴트 프로퍼티 캐시).</summary>
sealed class Instance
{
public GameObject go; public Transform tf;
public Renderer[] renderers; public MaterialPropertyBlock[] blocks; public int[] tintIds; public Color[] baseColors;
public bool busy, tinted;
}
struct Active
{
public Entry entry; public Instance inst; public int row; public SpectaclePhase phase;
public float expiresAt; public Actor follow; public eEffectLocation anchor; public bool countsRow, medium;
}
struct RowState
{
public bool resolved; public Entry charge, body, impact;
public int active; public bool heavyHeld;
public int impactsThisFire; public float lastImpactAt;
}
static readonly Dictionary<string, Entry> s_entries = new Dictionary<string, Entry>(48);
static readonly List<Entry> s_loading = new List<Entry>(48);
static readonly Dictionary<Shader, int> s_tintIdByShader = new Dictionary<Shader, int>(16);
static RowState[] s_rows;
static Active[] s_active;
static int s_activeCount, s_mediumActive;
static Actor s_lastHitVictim; static int s_lastHitFrame = -1;
static bool[] s_preloadedRow;
// ───────────────────────────────────────── 진단(프로브가 읽는다)
public static bool Subscribed;
public static int CastSeen, FiredSeen, HitSeen, ChargeSpawned, BodySpawned, ImpactSpawned, ImpactFallback,
SkippedDisabled, SkippedNoRow, SkippedNotMainPC, SkippedOutOfView, SkippedHeavyBudget, SkippedMediumBudget,
SkippedImpactCap, SkippedImpactInterval, SkippedNoDamage, SkippedNotLoaded, SkippedNoPrefab, SkippedPoolFull,
SkippedTrackFull, LoadRequested, LoadCompleted, LoadFailed, Instantiated, Expired, HeavyAcquired, HeavyReleased,
TintApplied, PreloadCalls,
// 811q: 워밍 인스턴스를 다음 프레임으로 미룬 횟수(= 완료가 한 프레임에 몰렸다는 증거)
DeferredWarms;
public static string LastInfo = "";
public static int ActiveCount { get { return s_activeCount; } }
public static int MediumActive { get { return s_mediumActive; } }
public static int LoadingCount { get { return s_loading.Count; } }
public static int EntryCount { get { return s_entries.Count; } }
public static int HeavyHeldRows
{
get { int n = 0; if (s_rows != null) for (int i = 0; i < s_rows.Length; i++) if (s_rows[i].heavyHeld) n++; return n; }
}
// ───────────────────────────────────────── 구독
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register() { EnsureSubscribed(); }
/// <summary>구독 보장(중복은 CombatEventList 가 막는다). 에디트 모드 프로브도 이걸 부른다.</summary>
public static void EnsureSubscribed()
{
CombatEvents.AttackStarted.Add(OnAttackStarted);
CombatEvents.SkillCast.Add(OnSkillCast);
CombatEvents.SkillFired.Add(OnSkillFired);
CombatEvents.HitboxHit.Add(OnHitboxHit);
CombatEvents.HitConfirmed.Add(OnHitConfirmed);
Subscribed = true;
}
public static void Unsubscribe()
{
CombatEvents.AttackStarted.Remove(OnAttackStarted);
CombatEvents.SkillCast.Remove(OnSkillCast);
CombatEvents.SkillFired.Remove(OnSkillFired);
CombatEvents.HitboxHit.Remove(OnHitboxHit);
CombatEvents.HitConfirmed.Remove(OnHitConfirmed);
Subscribed = false;
}
static WLSkillSpectacleSettings St { get { return WLSkillSpectacleSettings.Instance; } }
static bool Verbose { get { var st = St; return st != null && st.verboseLog; } }
static void Log(string msg) { Debug.Log("[SkillSpectacle] " + msg); } // 호출측이 Verbose 로 막는다(결합 자체를 안 만든다)
// ───────────────────────────────────────── 이벤트
static void OnAttackStarted(in AttackStartedEvent e)
{
if (!WLSkillSpectacleSettings.Enabled) return;
var st = St;
if (!st.preloadOnFirstAttack || !e.isMainPC || e.actor == null) return;
PreloadClass(e.actor.Get_ID(), st);
}
static void OnSkillCast(in SkillCastEvent e)
{
if (!WLSkillSpectacleSettings.Enabled) { SkippedDisabled++; return; }
var st = St;
if (e.actor == null || e.skill == null) return;
CastSeen++;
if (st.requireMainPC && !e.actor.IsMainPC()) { SkippedNotMainPC++; return; }
int classId = e.actor.Get_ID();
int ri = ResolveRow(e.skill.n_SkillID, classId, st);
if (ri < 0) { SkippedNoRow++; return; }
if (st.preloadClassOnFirstCast) PreloadClass(classId, st);
float life = e.skill.f_CastingTime > 0f ? Mathf.Min(e.skill.f_CastingTime, st.chargeMaxLifetime) : st.chargeFlashSeconds;
bool ok = Spawn(ri, SpectaclePhase.Charge, s_rows[ri].charge, in st.rows[ri].charge, e.actor, e.actor, life,
st.chargeFollowCaster ? e.actor : null, st);
if (ok) ChargeSpawned++;
if (Verbose) Log("SkillCast " + e.skill.n_SkillID + " slot=" + (e.slot + 1) + " class=" + classId + " charge=" + ok + " life=" + life.ToString("F2") + " " + LastInfo);
}
static void OnSkillFired(in SkillFiredEvent e)
{
if (!WLSkillSpectacleSettings.Enabled) { SkippedDisabled++; return; }
var st = St;
if (e.actor == null || e.skill == null) return;
FiredSeen++;
if (st.requireMainPC && !e.actor.IsMainPC()) { SkippedNotMainPC++; return; }
int classId = e.actor.Get_ID();
int ri = ResolveRow(e.skill.n_SkillID, classId, st);
if (ri < 0) { SkippedNoRow++; return; }
s_rows[ri].impactsThisFire = 0;
s_rows[ri].lastImpactAt = -999f;
bool ok = Spawn(ri, SpectaclePhase.Body, s_rows[ri].body, in st.rows[ri].body, e.actor, e.actor, 0f, null, st);
if (ok) BodySpawned++;
if (Verbose) Log("SkillFired " + e.skill.n_SkillID + " class=" + classId + " body=" + ok + " " + LastInfo);
}
static void OnHitboxHit(in HitboxHitEvent e)
{
if (!WLSkillSpectacleSettings.Enabled) return;
if (!e.isSkill || e.skill == null || e.shooter == null || e.victim == null) return;
s_lastHitVictim = e.victim; s_lastHitFrame = e.frame;
Impact(e.shooter, e.victim, e.skill, e.noDmg, false);
}
static void OnHitConfirmed(in HitConfirmedEvent e)
{
if (!WLSkillSpectacleSettings.Enabled) return;
var st = St;
if (!st.impactFallbackHitConfirmed) return;
var pd = e.projectile;
if (pd == null || !pd.IsSkill() || e.attacker == null || e.victim == null) return;
if (e.victim == s_lastHitVictim && e.frame == s_lastHitFrame) return; // HitboxHit 이 이미 처리한 명중
Impact(e.attacker, e.victim, pd.m_SkillListTableData, e.damage <= 0d, true);
}
static void Impact(Actor shooter, Actor victim, SkillListTableData skill, bool noDmg, bool fallback)
{
var st = St;
HitSeen++;
if (st.requireMainPC && !shooter.IsMainPC()) { SkippedNotMainPC++; return; }
int ri = ResolveRow(skill.n_SkillID, shooter.Get_ID(), st);
if (ri < 0) { SkippedNoRow++; return; }
if (st.impactSkipNoDamage && noDmg) { SkippedNoDamage++; return; }
ref RowState rs = ref s_rows[ri];
if (rs.impactsThisFire >= st.impactMaxPerFire) { SkippedImpactCap++; return; }
float now = Time.time;
if (st.impactMinIntervalSeconds > 0f && now - rs.lastImpactAt < st.impactMinIntervalSeconds) { SkippedImpactInterval++; return; }
bool ok = Spawn(ri, SpectaclePhase.Impact, rs.impact, in st.rows[ri].impact, shooter, victim, 0f, null, st);
if (ok)
{
rs.impactsThisFire++; rs.lastImpactAt = now; ImpactSpawned++;
if (fallback) ImpactFallback++;
}
if (Verbose) Log("Impact " + skill.n_SkillID + " victim=" + victim.name + " ok=" + ok + " n=" + rs.impactsThisFire + " fallback=" + fallback + " " + LastInfo);
}
// ───────────────────────────────────────── 행 · 엔트리
static void EnsureTables(WLSkillSpectacleSettings st)
{
int n = st.rows != null ? st.rows.Length : 0;
if (s_rows == null || s_rows.Length != n) { s_rows = new RowState[n]; s_preloadedRow = new bool[n]; }
if (s_active == null) s_active = new Active[Mathf.Max(8, st.trackCapacity)];
}
/// <summary>행 인덱스(skillId + 클래스 우선). 처음 볼 때 3단 엔트리를 만들고 로드를 건다.</summary>
static int ResolveRow(int skillId, int classId, WLSkillSpectacleSettings st)
{
EnsureTables(st);
int ri = st.FindRow(skillId, classId);
if (ri < 0) return -1;
ref RowState rs = ref s_rows[ri];
if (!rs.resolved)
{
rs.resolved = true;
rs.charge = GetEntry(st.rows[ri].charge.prefab, st);
rs.body = GetEntry(st.rows[ri].body.prefab, st);
rs.impact = GetEntry(st.rows[ri].impact.prefab, st);
rs.lastImpactAt = -999f;
}
RequestLoad(rs.charge); RequestLoad(rs.body); RequestLoad(rs.impact);
return ri;
}
static Entry GetEntry(string name, WLSkillSpectacleSettings st)
{
if (string.IsNullOrEmpty(name)) return null;
Entry e;
if (s_entries.TryGetValue(name, out e)) return e;
e = new Entry
{
name = name,
path = WLSkillSpectacleSettings.EffectPathPrefix + name + WLSkillSpectacleSettings.EffectPathSuffix, // 프리팹당 1회
instances = new Instance[Mathf.Max(1, st.poolMaxPerPrefab)]
};
s_entries.Add(name, e);
return e;
}
static void RequestLoad(Entry e)
{
if (e == null || e.loaded || e.loading || e.failed) return;
try
{
e.handle = Addressables.LoadAssetAsync<GameObject>(e.path);
e.loading = true;
s_loading.Add(e);
LoadRequested++;
SkillSpectacleRunner.Ensure();
}
catch (Exception ex)
{
e.failed = true; LoadFailed++;
Debug.LogWarning("[SkillSpectacle] 로드 요청 실패 " + e.path + " : " + ex.Message);
}
}
/// <summary>클래스의 모든 행(3단)을 프리로드한다(행당 1회).</summary>
public static void PreloadClass(int classId, WLSkillSpectacleSettings st)
{
if (st == null || st.rows == null) return;
EnsureTables(st);
PreloadCalls++;
for (int i = 0; i < st.rows.Length; i++)
{
if (st.rows[i].classId != classId || s_preloadedRow[i]) continue;
s_preloadedRow[i] = true;
ResolveRow(st.rows[i].skillId, classId, st);
}
}
/// <summary>프로브용(에디트 모드): 진행 중인 로드를 동기로 끝낸다(WaitForCompletion). 런타임 경로는 쓰지 않는다(비동기 + Tick 폴링).</summary>
public static int CompleteLoadsNow()
{
int n = 0;
for (int i = 0; i < s_loading.Count; i++)
{
var e = s_loading[i];
try { if (e.handle.IsValid() && !e.handle.IsDone) { e.handle.WaitForCompletion(); n++; } }
catch (Exception ex) { Debug.LogWarning("[SkillSpectacle] WaitForCompletion 실패 " + e.path + " : " + ex.Message); }
}
PollLoads();
return n;
}
/// <summary>프로브용: 16행 전부 엔트리 생성 + 로드 요청.</summary>
public static void PreloadAll()
{
var st = St;
if (st == null || st.rows == null) return;
EnsureTables(st);
for (int i = 0; i < st.rows.Length; i++) ResolveRow(st.rows[i].skillId, st.rows[i].classId, st);
}
// ───────────────────────────────────────── WL-811q §1-2 ⓑ 사전 워밍(프레임 분산)
//
// 🔴 왜 필요한가: 811i 의 33종은 원본 `EffectList.json` 프리로드 표(90행 · 인스턴스 514)에
// **한 종도 들어 있지 않다**(실측). 지금은 첫 `AttackStarted` 에 그 클래스 4행(프리팹 최대 12개)이
// 한꺼번에 로드되고, 완료 콜백이 몰린 프레임에 `warmInstanceOnLoad` 인스턴스가 같이 만들어진다.
// 맵 진입(로딩 구간)으로 앞당기고, 그때도 **프레임당 N 행**으로 나눈다.
/// <summary>아직 프리로드하지 않은 행 수(워밍 진행률).</summary>
public static int PreloadRemaining(int classId)
{
var st = St;
if (st == null || st.rows == null) return 0;
EnsureTables(st);
int n = 0;
for (int i = 0; i < st.rows.Length; i++)
if (!s_preloadedRow[i] && (classId < 0 || st.rows[i].classId == classId)) n++;
return n;
}
/// <summary>
/// 아직 프리로드하지 않은 행을 최대 <paramref name="maxRows"/> 개만 프리로드한다.
/// <paramref name="classId"/> &lt; 0 이면 클래스 무관 전 행. 반환 = 이번에 건 행 수.
/// 🔴 할당 = 행당 엔트리 3개(프리팹당 1회 · 이미 있으면 0) — 프레임당 할당은 maxRows 로 제한된다.
/// </summary>
public static int PreloadRows(int maxRows, int classId)
{
var st = St;
if (st == null || st.rows == null || maxRows == 0) return 0;
EnsureTables(st);
int done = 0;
for (int i = 0; i < st.rows.Length; i++)
{
if (s_preloadedRow[i]) continue;
if (classId >= 0 && st.rows[i].classId != classId) continue;
s_preloadedRow[i] = true;
ResolveRow(st.rows[i].skillId, st.rows[i].classId, st);
PreloadCalls++;
if (maxRows > 0 && ++done >= maxRows) break;
}
return done;
}
// ───────────────────────────────────────── 스폰
static Vector3 Anchor(Actor a, eEffectLocation loc)
{
switch (loc)
{
case eEffectLocation.Top: return a.Get_Top_postion();
case eEffectLocation.Bottom: return a.Get_position();
default: return a.Get_Center_position();
}
}
static bool Spawn(int ri, SpectaclePhase phase, Entry entry, in SpectacleVfx vfx, Actor caster, Actor at, float lifetimeOverride, Actor follow, WLSkillSpectacleSettings st)
{
if (entry == null) { SkippedNoPrefab++; LastInfo = "noprefab"; return false; }
if (!entry.loaded) { RequestLoad(entry); SkippedNotLoaded++; LastInfo = entry.failed ? "loadfailed" : "notloaded"; return false; }
if (at == null) return false;
Vector3 pos = Anchor(at, vfx.anchor);
bool isImpact = phase == SpectaclePhase.Impact;
if (!isImpact && vfx.forwardOffset != 0f) pos += caster.transform.forward * vfx.forwardOffset;
if (st.requireInView && !EffectBudget.IsInView(pos)) { SkippedOutOfView++; LastInfo = "outofview"; return false; }
if (s_activeCount >= s_active.Length) { SkippedTrackFull++; LastInfo = "trackfull"; return false; }
var inst = Acquire(entry, st);
if (inst == null) { SkippedPoolFull++; LastInfo = "poolfull"; return false; }
// 예산(차지·본체만 · 착탄은 발동당 상한으로만 제한)
bool medium = false, countsRow = false;
if (!isImpact)
{
ref RowState rs = ref s_rows[ri];
var bc = st.rows[ri].budgetClass;
if (bc == SpectacleBudgetClass.Heavy && st.heavyUsesEffectBudget)
{
if (!rs.heavyHeld)
{
if (!EffectBudget.TryAcquire(EffectBudgetKind.Heavy)) { inst.busy = false; SkippedHeavyBudget++; LastInfo = "heavy"; return false; }
rs.heavyHeld = true; HeavyAcquired++;
}
}
else if (bc == SpectacleBudgetClass.Medium)
{
if (s_mediumActive >= st.mediumMax) { inst.busy = false; SkippedMediumBudget++; LastInfo = "medium"; return false; }
medium = true;
}
countsRow = true;
rs.active++;
}
if (medium) s_mediumActive++;
Quaternion rot = (!isImpact && vfx.useCasterRotation) ? caster.transform.rotation : Quaternion.identity;
float sc = vfx.scale > 0f ? vfx.scale : 1f;
inst.tf.SetPositionAndRotation(pos, rot);
inst.tf.localScale = entry.prefabScale * sc;
ApplyTint(inst, st, vfx.tint && st.tintEnabled, caster.Get_ID());
inst.go.SetActive(true);
float life = lifetimeOverride > 0f ? lifetimeOverride
: vfx.lifetime > 0f ? vfx.lifetime
: entry.looping ? st.loopingLifetime
: Mathf.Min(entry.baseLifetime > 0f ? entry.baseLifetime : st.autoLifetimeMax, st.autoLifetimeMax);
s_active[s_activeCount++] = new Active
{
entry = entry, inst = inst, row = ri, phase = phase, expiresAt = Time.time + life,
follow = follow, anchor = vfx.anchor, countsRow = countsRow, medium = medium
};
SkillSpectacleRunner.Ensure();
LastInfo = "ok";
return true;
}
static Instance Acquire(Entry e, WLSkillSpectacleSettings st)
{
for (int i = 0; i < e.count; i++)
{
var inst = e.instances[i];
if (inst.busy) continue;
if (inst.go == null) { Instantiate(e, inst, st); if (inst.go == null) return null; } // 외부 파괴(StopAction 등) → 재생성
inst.busy = true;
return inst;
}
if (e.count >= e.instances.Length) return null;
var n = new Instance();
Instantiate(e, n, st);
if (n.go == null) return null;
e.instances[e.count++] = n;
n.busy = true;
return n;
}
/// <summary>풀 인스턴스 생성(비활성 부모 아래에 만들어 Awake/OnEnable 없이 캐시 · TurnOff_GO 비활성 · 렌더러/MPB/틴트 프로퍼티 실측).</summary>
static void Instantiate(Entry e, Instance inst, WLSkillSpectacleSettings st)
{
var runner = SkillSpectacleRunner.Ensure();
if (runner == null || e.prefab == null) return;
GameObject go;
try { go = UnityEngine.Object.Instantiate(e.prefab, runner.PoolRoot); }
catch (Exception ex) { Debug.LogWarning("[SkillSpectacle] Instantiate 실패 " + e.name + " : " + ex.Message); return; }
go.name = e.name;
if (!Application.isPlaying) go.hideFlags = HideFlags.DontSave; // 에디트 모드 프로브: 씬을 더럽히지 않는다
go.SetActive(false);
go.transform.SetParent(runner.LiveRoot, false);
if (!e.lifetimeResolved) e.prefabScale = e.prefab.transform.localScale;
var offs = go.GetComponentsInChildren<TurnOff_GO>(true);
TurnOff_GO rootOff = null;
for (int i = 0; i < offs.Length; i++) { if (offs[i].transform == go.transform) rootOff = offs[i]; offs[i].enabled = false; }
var pss = go.GetComponentsInChildren<ParticleSystem>(true);
if (!e.lifetimeResolved)
{
e.lifetimeResolved = true;
float maxLife = 0f; bool anyNonLoop = false;
for (int i = 0; i < pss.Length; i++)
{
var m = pss[i].main;
e.maxParticles += m.maxParticles;
if (m.loop) { e.looping = true; continue; }
anyNonLoop = true;
float l = m.duration + m.startLifetime.constantMax + m.startDelay.constantMax;
if (l > maxLife) maxLife = l;
}
e.particleSystems = pss.Length;
// 수명 1순위 = 원본 TurnOff_GO 규약(NoUseParticleTime 또는 루트 PS 없음 → OffTime · 아니면 루트 PS duration) — 작가가 정한 값.
// 없을 때만 파티클 계산(비루프 duration+lifetime 최대) · 그것도 없으면 autoLifetimeMax. 루프 판정은 원본 규약이 없고 비루프 PS 도 없을 때만.
var rootPs = go.GetComponent<ParticleSystem>();
float offLife = 0f;
if (rootOff != null) offLife = (rootOff.NoUseParticleTime || rootPs == null) ? rootOff.OffTime : rootPs.main.duration;
if (offLife > 0f) { e.baseLifetime = offLife; e.looping = false; }
else if (anyNonLoop) { e.baseLifetime = maxLife; e.looping = false; }
else { e.baseLifetime = st.autoLifetimeMax; e.looping = pss.Length > 0; }
}
var rends = go.GetComponentsInChildren<Renderer>(true);
inst.go = go; inst.tf = go.transform;
inst.renderers = rends;
inst.blocks = new MaterialPropertyBlock[rends.Length];
inst.tintIds = new int[rends.Length];
inst.baseColors = new Color[rends.Length];
int tintable = 0;
for (int i = 0; i < rends.Length; i++)
{
inst.blocks[i] = new MaterialPropertyBlock();
var mat = rends[i] != null ? rends[i].sharedMaterial : null;
int id = ResolveTintId(mat != null ? mat.shader : null, st);
inst.tintIds[i] = id;
inst.baseColors[i] = id >= 0 && mat.HasProperty(id) ? mat.GetColor(id) : Color.white;
if (id >= 0) tintable++;
}
e.renderers = rends.Length; e.tintableRenderers = tintable;
Instantiated++;
if (Verbose) Log("인스턴스 " + e.name + " ps=" + pss.Length + " maxParticles=" + e.maxParticles + " life=" + e.baseLifetime.ToString("F2") + " loop=" + e.looping + " renderers=" + rends.Length + " tintable=" + tintable);
}
static int ResolveTintId(Shader shader, WLSkillSpectacleSettings st)
{
if (shader == null) return -1;
int id;
if (s_tintIdByShader.TryGetValue(shader, out id)) return id;
id = -1;
var names = st.tintProperties;
if (names != null)
for (int i = 0; i < names.Length; i++)
if (!string.IsNullOrEmpty(names[i]) && shader.FindPropertyIndex(names[i]) >= 0) { id = Shader.PropertyToID(names[i]); break; }
s_tintIdByShader.Add(shader, id);
return id;
}
static void ApplyTint(Instance inst, WLSkillSpectacleSettings st, bool tint, int classId)
{
Color c = Color.white;
bool apply = false;
if (tint)
{
int ci = st.FindClassElement(classId);
if (ci >= 0) { c = Color.Lerp(Color.white, st.classElements[ci].tint, st.tintStrength); apply = true; }
}
if (!apply && !inst.tinted) return;
for (int i = 0; i < inst.renderers.Length; i++)
{
var r = inst.renderers[i];
if (r == null) continue;
if (apply && inst.tintIds[i] >= 0)
{
var b = inst.baseColors[i];
inst.blocks[i].SetColor(inst.tintIds[i], new Color(b.r * c.r, b.g * c.g, b.b * c.b, b.a));
r.SetPropertyBlock(inst.blocks[i]);
}
else if (inst.tinted) { inst.blocks[i].Clear(); r.SetPropertyBlock(null); }
}
inst.tinted = apply;
if (apply) TintApplied++;
}
// ───────────────────────────────────────── 틱 · 정리
/// <summary>러너가 매 프레임 부른다(scaled time · 파티클 시간축과 동일). 프로브는 직접 부른다.</summary>
public static void Tick(float now)
{
if (s_loading.Count > 0) PollLoads();
if (s_activeCount == 0) return;
if (!WLSkillSpectacleSettings.Enabled) { ClearAll(); return; }
for (int i = s_activeCount - 1; i >= 0; i--)
{
ref Active a = ref s_active[i];
bool gone = a.inst.go == null;
if (!gone && a.follow != null && a.phase == SpectaclePhase.Charge)
a.inst.tf.position = Anchor(a.follow, a.anchor);
if (gone || now >= a.expiresAt) Expire(i);
}
}
static void PollLoads()
{
var st = St;
// 🔴 WL-811q §1-2 ⓑ — 한 틱에 만들 워밍 인스턴스 수를 제한한다.
// Addressables 완료 콜백은 같은 프레임 끝에 몰려 들어온다(33종 중 여러 개가 한 프레임에 끝난다).
// 예산을 다 쓰면 남은 완료는 **다음 프레임으로 미룬다**(entry 는 s_loading 에 그대로 = 재요청 0).
// 0 = 무제한 = 811i 원래 동작(C8 롤백 값).
int budget = st != null ? st.warmInstancesPerTick : 0;
for (int i = s_loading.Count - 1; i >= 0; i--)
{
var e = s_loading[i];
if (!e.handle.IsValid()) { e.loading = false; e.failed = true; LoadFailed++; s_loading.RemoveAt(i); continue; }
if (!e.handle.IsDone) continue;
bool willWarm = st != null && st.warmInstanceOnLoad && e.count == 0
&& e.handle.Status == AsyncOperationStatus.Succeeded && e.handle.Result != null;
if (willWarm && st.warmInstancesPerTick > 0)
{
if (budget <= 0) { DeferredWarms++; break; } // 예산 소진 — 다음 프레임에 마저 만든다
budget--;
}
e.loading = false;
if (e.handle.Status == AsyncOperationStatus.Succeeded && e.handle.Result != null)
{
e.prefab = e.handle.Result; e.loaded = true; LoadCompleted++;
if (st != null && st.warmInstanceOnLoad && e.count == 0)
{
var n = new Instance();
Instantiate(e, n, st);
if (n.go != null) e.instances[e.count++] = n;
}
}
else { e.failed = true; LoadFailed++; Debug.LogWarning("[SkillSpectacle] 프리팹 로드 실패 " + e.path); }
s_loading.RemoveAt(i);
}
}
static void Expire(int i)
{
ref Active a = ref s_active[i];
if (a.inst.go != null) a.inst.go.SetActive(false);
a.inst.busy = false;
if (a.countsRow)
{
ref RowState rs = ref s_rows[a.row];
if (rs.active > 0) rs.active--;
if (rs.active == 0 && rs.heavyHeld) { rs.heavyHeld = false; EffectBudget.Release(EffectBudgetKind.Heavy); HeavyReleased++; }
}
if (a.medium && s_mediumActive > 0) s_mediumActive--;
Expired++;
int last = --s_activeCount;
s_active[i] = s_active[last];
s_active[last] = default(Active);
}
/// <summary>씬 전환 · 러너 파괴 · 프로브 정리: 활성 이펙트를 전부 끄고 예산을 돌려준다(풀·로드는 유지).</summary>
public static void ClearAll()
{
if (s_active != null) for (int i = s_activeCount - 1; i >= 0; i--) Expire(i);
s_activeCount = 0; s_mediumActive = 0;
if (s_rows != null)
for (int i = 0; i < s_rows.Length; i++)
{
if (s_rows[i].heavyHeld) { s_rows[i].heavyHeld = false; EffectBudget.Release(EffectBudgetKind.Heavy); HeavyReleased++; }
s_rows[i].active = 0; s_rows[i].impactsThisFire = 0; s_rows[i].lastImpactAt = -999f;
}
s_lastHitVictim = null; s_lastHitFrame = -1;
}
/// <summary>프로브용: 풀 인스턴스까지 파괴하고 테이블을 비운다(로드 핸들은 Addressables 가 참조 카운트로 관리 · 여기서 Release 하지 않는다).</summary>
public static void DestroyPools()
{
ClearAll();
bool playing = Application.isPlaying;
foreach (var kv in s_entries)
{
var e = kv.Value;
for (int i = 0; i < e.count; i++)
{
var go = e.instances[i].go;
if (go == null) continue;
if (playing) UnityEngine.Object.Destroy(go); else UnityEngine.Object.DestroyImmediate(go);
}
e.count = 0;
}
}
/// <summary>
/// 프로브용(811q) — 「이 행은 이미 프리로드했다」 표시를 지운다. 다음 <c>PreloadRows</c> 가 처음부터 다시 센다.
/// 🔴 게임 경로에서는 부르지 않는다(같은 프리팹을 두 번 로드할 이유가 없다 — Addressables 참조 카운트만 는다).
/// </summary>
public static void ResetPreloadStateForProbe()
{
if (s_preloadedRow != null) System.Array.Clear(s_preloadedRow, 0, s_preloadedRow.Length);
if (s_rows != null) for (int i = 0; i < s_rows.Length; i++) s_rows[i].resolved = false;
}
/// <summary>프로브용 카운터 초기화(구독·풀 유지).</summary>
public static void ResetDiagnostics()
{
CastSeen = FiredSeen = HitSeen = ChargeSpawned = BodySpawned = ImpactSpawned = ImpactFallback = 0;
SkippedDisabled = SkippedNoRow = SkippedNotMainPC = SkippedOutOfView = SkippedHeavyBudget = SkippedMediumBudget = 0;
SkippedImpactCap = SkippedImpactInterval = SkippedNoDamage = SkippedNotLoaded = SkippedNoPrefab = SkippedPoolFull = SkippedTrackFull = 0;
Instantiated = Expired = HeavyAcquired = HeavyReleased = TintApplied = PreloadCalls = DeferredWarms = 0;
LastInfo = "";
}
// ───────────────────────────────────────── 프로브 조회(문자열은 프로브 쪽에서만 만든다)
public static bool IsRowResolved(int ri) { return s_rows != null && ri >= 0 && ri < s_rows.Length && s_rows[ri].resolved; }
public static bool RowHeavyHeld(int ri) { return s_rows != null && ri >= 0 && ri < s_rows.Length && s_rows[ri].heavyHeld; }
public static int RowActive(int ri) { return s_rows != null && ri >= 0 && ri < s_rows.Length ? s_rows[ri].active : 0; }
public static int RowImpactsThisFire(int ri) { return s_rows != null && ri >= 0 && ri < s_rows.Length ? s_rows[ri].impactsThisFire : 0; }
/// <summary>엔트리 상태(프리팹명으로). 없으면 false.</summary>
public static bool TryGetEntryInfo(string name, out bool loaded, out bool failed, out int instances, out int particleSystems, out int maxParticles, out float baseLifetime, out bool looping, out int renderers, out int tintable)
{
loaded = failed = looping = false; instances = particleSystems = maxParticles = renderers = tintable = 0; baseLifetime = 0f;
Entry e;
if (string.IsNullOrEmpty(name) || !s_entries.TryGetValue(name, out e)) return false;
loaded = e.loaded; failed = e.failed; instances = e.count; particleSystems = e.particleSystems; maxParticles = e.maxParticles;
baseLifetime = e.baseLifetime; looping = e.looping; renderers = e.renderers; tintable = e.tintableRenderers;
return true;
}
}
}