[WL-811i] 스킬 스펙터클 (#813)
4클래스 × 4슬롯(813n 프리셋 16행) 스킬의 차지·본체·착탄 3단 VFX 레이어 — 보유 Res_Addr/Effect 프리팹 33종만(새 아트 0) · 클래스 원소 MPB 곱셈 틴트 · EffectBudget.Heavy 행당 1슬롯(동시 2) · Medium 로컬 상한 · 절두체 밖 스킵 · 자체 풀(GC 0). - Assets/WL/Combat/Reaction/WLSkillSpectacleSettings.cs (NEW · SO · 값 SOT · C8) - Assets/WL/Combat/Reaction/SkillSpectacle.cs (NEW · SkillCast/SkillFired/HitboxHit/HitConfirmed/AttackStarted 구독자 · 원본 훅 0) - Assets/WL/Combat/Reaction/SkillSpectacleRunner.cs (NEW · 틱 러너 + 풀 루트) - Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset (NEW · 16행 · 원소 4행) - AgentScripts/WL811i_Probe.cs (NEW · Dump/EditProbe/PlayProbe/Status) Actor.cs·PCActor.cs·Core 0 · Assets/Script 0 · 리터럴 4곳 SO 이관은 하지 않음(핫스팟 동결 · 보고서 목록만). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
1472316c37
commit
3d9c6f816e
|
|
@ -0,0 +1,546 @@
|
||||||
|
// WL811i_Probe.cs — #811i 스킬 스펙터클 검증 프로브 (읽기 전용 · 에셋 무수정 · 가짜 이벤트 발행)
|
||||||
|
//
|
||||||
|
// unity command run_script --file AgentScripts/WL811i_Probe.cs --entry WL811i_Probe.Dump // 설정 · 16행 매핑 · 프리팹 실존(AssetDatabase) · 파티클/렌더러 예산 표 (Edit/Play)
|
||||||
|
// unity command run_script --file AgentScripts/WL811i_Probe.cs --entry WL811i_Probe.EditProbe // Edit 모드: 로드 → 가짜 SkillCast/SkillFired/HitboxHit 16행 → 3단 스폰 · 절두체 밖 스킵 · Heavy 2 · Medium · C8 · GC 100
|
||||||
|
// unity command run_script --file AgentScripts/WL811i_Probe.cs --entry WL811i_Probe.PlayProbe // Title Play: 위 + 수명 실측(차지 1 s · 본체 자동 · Heavy 반납 시각) · 착탄 발동당 상한 · GC 100 · 폴백 착탄
|
||||||
|
// unity command run_script --file AgentScripts/WL811i_Probe.cs --entry WL811i_Probe.Status // 카운터
|
||||||
|
// 결과: Screenshots_WL/WL811i/<이름>_<HHmmss>.txt + Console
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.AI;
|
||||||
|
using WL.Combat.Core;
|
||||||
|
using WL.Combat.Reaction;
|
||||||
|
|
||||||
|
public static class WL811i_Probe
|
||||||
|
{
|
||||||
|
const string OutDir = "Screenshots_WL/WL811i";
|
||||||
|
const BindingFlags F = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────── Dump (Edit/Play)
|
||||||
|
public static object Dump()
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var st = WLSkillSpectacleSettings.Instance;
|
||||||
|
sb.AppendLine("# WL811i Dump " + DateTime.Now.ToString("HH:mm:ss") + " playing=" + Application.isPlaying + " core=" + WLCombatCoreSettings.Enabled + " spectacle=" + WLSkillSpectacleSettings.Enabled + " subscribed=" + SkillSpectacle.Subscribed);
|
||||||
|
if (st == null) { sb.AppendLine("asset NULL"); Save("dump", sb); return sb.ToString(); }
|
||||||
|
var core = WLCombatCoreSettings.Instance;
|
||||||
|
sb.AppendLine("core heavyEffectMax=" + (core != null ? core.heavyEffectMax.ToString() : "?") + " viewMargin=" + (core != null ? core.viewMargin.ToString() : "?"));
|
||||||
|
sb.AppendLine("subs: AttackStarted=" + CombatEvents.AttackStarted.Count + " SkillCast=" + CombatEvents.SkillCast.Count + " SkillFired=" + CombatEvents.SkillFired.Count + " HitboxHit=" + CombatEvents.HitboxHit.Count + " HitConfirmed=" + CombatEvents.HitConfirmed.Count);
|
||||||
|
sb.AppendLine("## 값");
|
||||||
|
sb.AppendLine("enabled=" + st.enabled + " requireMainPC=" + st.requireMainPC + " requireInView=" + st.requireInView + " preloadOnFirstAttack=" + st.preloadOnFirstAttack + " warm=" + st.warmInstanceOnLoad
|
||||||
|
+ " chargeFlash=" + st.chargeFlashSeconds + " chargeMax=" + st.chargeMaxLifetime + " heavyUsesBudget=" + st.heavyUsesEffectBudget + " mediumMax=" + st.mediumMax
|
||||||
|
+ " impactMaxPerFire=" + st.impactMaxPerFire + " impactMinInterval=" + st.impactMinIntervalSeconds + " autoLifetimeMax=" + st.autoLifetimeMax + " loopingLifetime=" + st.loopingLifetime
|
||||||
|
+ " poolMaxPerPrefab=" + st.poolMaxPerPrefab + " trackCapacity=" + st.trackCapacity + " tint=" + st.tintEnabled + "×" + st.tintStrength);
|
||||||
|
sb.AppendLine("## 클래스 원소");
|
||||||
|
for (int i = 0; i < st.classElements.Length; i++)
|
||||||
|
{
|
||||||
|
var c = st.classElements[i];
|
||||||
|
sb.AppendLine(c.classId + " " + c.label + " " + c.element + " tint=" + c.tint.ToString("F2"));
|
||||||
|
}
|
||||||
|
sb.AppendLine("## 매핑 16행 (실존 = AssetDatabase.LoadAssetAtPath)");
|
||||||
|
sb.AppendLine("| 클래스 | 슬롯 | SkillID | 이름 | charge | body | impact | budget | 실존 |");
|
||||||
|
int missing = 0, total = 0;
|
||||||
|
var uniq = new List<string>();
|
||||||
|
for (int i = 0; i < st.rows.Length; i++)
|
||||||
|
{
|
||||||
|
var r = st.rows[i];
|
||||||
|
string ex = "";
|
||||||
|
foreach (var v in new[] { r.charge, r.body, r.impact })
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(v.prefab)) { ex += "-"; continue; }
|
||||||
|
total++;
|
||||||
|
bool ok = Exists(v.prefab);
|
||||||
|
if (!ok) missing++;
|
||||||
|
ex += ok ? "Y" : "N";
|
||||||
|
if (!uniq.Contains(v.prefab)) uniq.Add(v.prefab);
|
||||||
|
}
|
||||||
|
sb.AppendLine("| " + r.classId + " | " + r.slot + " | " + r.skillId + " | " + r.label + " | " + V(r.charge) + " | " + V(r.body) + " | " + V(r.impact) + " | " + r.budgetClass + " | " + ex + " |");
|
||||||
|
}
|
||||||
|
sb.AppendLine("실존: 사용 " + total + " 건 · 고유 " + uniq.Count + " · 누락 " + missing + " → " + (missing == 0 ? "100 %" : "FAIL"));
|
||||||
|
|
||||||
|
sb.AppendLine("## 프리팹 예산 표 (프리팹 에셋 계층 실측 · 파티클 수 = maxParticles 합 · 예상 = Σ min(max, rate×life+burst) 「추정」 · SetPass ≈ 고유 머티리얼 수 「추정」)");
|
||||||
|
sb.AppendLine(string.Format("{0,-36} {1,3} {2,5} {3,7} {4,5} {5,4} {6,4} {7,5} {8,5} {9}", "prefab", "ps", "maxP", "estP", "dur", "loop", "rend", "mats", "tint", "shaders / TurnOff"));
|
||||||
|
var totalEst = 0f; var totalMax = 0;
|
||||||
|
foreach (var name in uniq)
|
||||||
|
{
|
||||||
|
var go = Load(name);
|
||||||
|
if (go == null) { sb.AppendLine(string.Format("{0,-36} MISSING", name)); continue; }
|
||||||
|
var pss = go.GetComponentsInChildren<ParticleSystem>(true);
|
||||||
|
int maxP = 0, loops = 0; float dur = 0f, est = 0f;
|
||||||
|
foreach (var ps in pss)
|
||||||
|
{
|
||||||
|
var m = ps.main; maxP += m.maxParticles;
|
||||||
|
if (m.loop) loops++;
|
||||||
|
float life = m.startLifetime.constantMax;
|
||||||
|
float d = m.duration + life + m.startDelay.constantMax;
|
||||||
|
if (d > dur) dur = d;
|
||||||
|
var em = ps.emission;
|
||||||
|
float burst = 0f;
|
||||||
|
for (int b = 0; b < em.burstCount; b++) burst += em.GetBurst(b).maxCount;
|
||||||
|
float rate = em.rateOverTimeMultiplier * (m.loop ? life : Mathf.Min(m.duration, life) + 0f);
|
||||||
|
est += Mathf.Min(m.maxParticles, rate + burst);
|
||||||
|
}
|
||||||
|
var rends = go.GetComponentsInChildren<Renderer>(true);
|
||||||
|
var mats = new List<Material>(); var shaders = new List<string>(); int tintable = 0;
|
||||||
|
foreach (var r in rends)
|
||||||
|
{
|
||||||
|
var mat = r.sharedMaterial;
|
||||||
|
if (mat == null) continue;
|
||||||
|
if (!mats.Contains(mat)) mats.Add(mat);
|
||||||
|
if (mat.shader != null)
|
||||||
|
{
|
||||||
|
string sn = mat.shader.name;
|
||||||
|
bool has = false;
|
||||||
|
foreach (var p in st.tintProperties) if (mat.shader.FindPropertyIndex(p) >= 0) { has = true; break; }
|
||||||
|
if (has) tintable++;
|
||||||
|
if (!shaders.Contains(sn + (has ? "" : "(no-tint)"))) shaders.Add(sn + (has ? "" : "(no-tint)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var off = go.GetComponent<TurnOff_GO>();
|
||||||
|
string offs = off != null ? ("TurnOff OffTime=" + off.OffTime + (off.NoUseParticleTime ? " noPS" : "")) : "no-TurnOff";
|
||||||
|
totalEst += est; totalMax += maxP;
|
||||||
|
sb.AppendLine(string.Format("{0,-36} {1,3} {2,5} {3,7:F0} {4,5:F2} {5,4} {6,4} {7,5} {8,5} {9} · {10}", name, pss.Length, maxP, est, dur, loops, rends.Length, mats.Count, tintable, string.Join(",", shaders.ToArray()), offs));
|
||||||
|
}
|
||||||
|
sb.AppendLine("합계(33종 1회씩) maxParticles=" + totalMax + " 예상 파티클=" + totalEst.ToString("F0") + " 「추정」");
|
||||||
|
Save("dump", sb); return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string V(SpectacleVfx v)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(v.prefab)) return "—";
|
||||||
|
return v.prefab + "(" + (v.scale > 0f ? v.scale : 1f) + "×" + v.anchor + (v.forwardOffset != 0f ? " +" + v.forwardOffset + "m" : "") + (v.useCasterRotation ? " rot" : "") + (v.tint ? " tint" : "") + (v.lifetime > 0f ? " " + v.lifetime + "s" : "") + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
static string PathOf(string name) { return WLSkillSpectacleSettings.EffectPathPrefix + name + WLSkillSpectacleSettings.EffectPathSuffix; }
|
||||||
|
static bool Exists(string name) { return Load(name) != null; }
|
||||||
|
static GameObject Load(string name) { return UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>(PathOf(name)); } // 에디터 전용 프로브
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────── Status
|
||||||
|
public static object Status()
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("# WL811i Status " + DateTime.Now.ToString("HH:mm:ss") + " " + Counters());
|
||||||
|
Save("status", sb); return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Counters()
|
||||||
|
{
|
||||||
|
return "cast=" + SkillSpectacle.CastSeen + " fired=" + SkillSpectacle.FiredSeen + " hit=" + SkillSpectacle.HitSeen
|
||||||
|
+ " | spawn charge/body/impact=" + SkillSpectacle.ChargeSpawned + "/" + SkillSpectacle.BodySpawned + "/" + SkillSpectacle.ImpactSpawned + " fallback=" + SkillSpectacle.ImpactFallback
|
||||||
|
+ " | skip disabled/noRow/notMain/outOfView/heavy/medium/impactCap/interval/noDmg/notLoaded/noPrefab/poolFull/trackFull="
|
||||||
|
+ SkillSpectacle.SkippedDisabled + "/" + SkillSpectacle.SkippedNoRow + "/" + SkillSpectacle.SkippedNotMainPC + "/" + SkillSpectacle.SkippedOutOfView + "/" + SkillSpectacle.SkippedHeavyBudget + "/" + SkillSpectacle.SkippedMediumBudget + "/"
|
||||||
|
+ SkillSpectacle.SkippedImpactCap + "/" + SkillSpectacle.SkippedImpactInterval + "/" + SkillSpectacle.SkippedNoDamage + "/" + SkillSpectacle.SkippedNotLoaded + "/" + SkillSpectacle.SkippedNoPrefab + "/" + SkillSpectacle.SkippedPoolFull + "/" + SkillSpectacle.SkippedTrackFull
|
||||||
|
+ " | load req/done/fail=" + SkillSpectacle.LoadRequested + "/" + SkillSpectacle.LoadCompleted + "/" + SkillSpectacle.LoadFailed + " inst=" + SkillSpectacle.Instantiated + " expired=" + SkillSpectacle.Expired
|
||||||
|
+ " heavy acq/rel/held=" + SkillSpectacle.HeavyAcquired + "/" + SkillSpectacle.HeavyReleased + "/" + SkillSpectacle.HeavyHeldRows + " budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + "/" + EffectBudget.Limit(EffectBudgetKind.Heavy)
|
||||||
|
+ " active=" + SkillSpectacle.ActiveCount + " medium=" + SkillSpectacle.MediumActive + " tint=" + SkillSpectacle.TintApplied + " preload=" + SkillSpectacle.PreloadCalls + " loading=" + SkillSpectacle.LoadingCount + " entries=" + SkillSpectacle.EntryCount + " runner=" + SkillSpectacleRunner.Exists + " last=" + SkillSpectacle.LastInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────── 공용
|
||||||
|
static void Save(string name, StringBuilder sb)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.IO.Directory.CreateDirectory(OutDir);
|
||||||
|
string path = System.IO.Path.Combine(OutDir, name + "_" + DateTime.Now.ToString("HHmmss") + ".txt");
|
||||||
|
System.IO.File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
|
||||||
|
Debug.Log("[WL811i] 저장 " + path);
|
||||||
|
}
|
||||||
|
catch (Exception e) { Debug.LogWarning("[WL811i] 저장 실패 " + e.Message); }
|
||||||
|
Debug.Log(sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>가짜 PC: m_Role=PC(public) · m_ID(protected → 리플렉션) · Play 면 Awake 가 돌고(arr_Renderer·tf_Top) Edit 면 돌지 않는다(앵커 Bottom/Mid 만 씀).</summary>
|
||||||
|
static PCActor MakePC(string name, Vector3 pos, int classId, Quaternion rot)
|
||||||
|
{
|
||||||
|
var go = new GameObject(name);
|
||||||
|
go.SetActive(false);
|
||||||
|
go.transform.position = pos; go.transform.rotation = rot;
|
||||||
|
go.AddComponent<Rigidbody>().isKinematic = true;
|
||||||
|
go.AddComponent<CapsuleCollider>();
|
||||||
|
go.AddComponent<Animator>();
|
||||||
|
var agent = go.AddComponent<NavMeshAgent>(); agent.enabled = false;
|
||||||
|
var pc = go.AddComponent<PCActor>(); pc.enabled = false;
|
||||||
|
pc.m_Role = eRole.PC;
|
||||||
|
var f = typeof(Actor).GetField("m_ID", F); if (f != null) f.SetValue(pc, classId);
|
||||||
|
go.SetActive(true);
|
||||||
|
if (Application.isPlaying) InitCC(pc);
|
||||||
|
return pc;
|
||||||
|
}
|
||||||
|
|
||||||
|
static MobActor MakeMob(string name, Vector3 pos)
|
||||||
|
{
|
||||||
|
var go = new GameObject(name);
|
||||||
|
go.SetActive(false);
|
||||||
|
go.transform.position = pos;
|
||||||
|
go.AddComponent<Rigidbody>().isKinematic = true;
|
||||||
|
go.AddComponent<BoxCollider>();
|
||||||
|
go.AddComponent<Animator>();
|
||||||
|
var agent = go.AddComponent<NavMeshAgent>(); agent.enabled = false;
|
||||||
|
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||||
|
cube.name = "Body"; cube.transform.SetParent(go.transform, false);
|
||||||
|
if (Application.isPlaying) UnityEngine.Object.Destroy(cube.GetComponent<Collider>()); else UnityEngine.Object.DestroyImmediate(cube.GetComponent<Collider>());
|
||||||
|
var mob = go.AddComponent<MobActor>(); mob.enabled = false;
|
||||||
|
mob.m_Role = eRole.Mob;
|
||||||
|
go.SetActive(true);
|
||||||
|
if (Application.isPlaying) InitCC(mob);
|
||||||
|
return mob;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void InitCC(Actor a)
|
||||||
|
{
|
||||||
|
var m = typeof(Actor).GetMethod("Init_CC", F);
|
||||||
|
if (m != null) { try { m.Invoke(a, null); } catch (Exception e) { Debug.LogWarning("[WL811i] Init_CC " + e.Message); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
static SkillListTableData Skill(int id, int classId, float castTime, eSkillTarget target)
|
||||||
|
{
|
||||||
|
return new SkillListTableData { n_SkillID = id, n_SkillClassID = classId, f_CastingTime = castTime, e_SkillTarget = target, e_SkillType = eSkillType.Active };
|
||||||
|
}
|
||||||
|
|
||||||
|
static UseMagicInfo Magic(SkillListTableData d, int slot) { return new UseMagicInfo { m_SkillTableData = d, m_uiIndex = slot - 1 }; }
|
||||||
|
|
||||||
|
static ProjectileData Pd(Actor shooter, SkillListTableData d) { return new ProjectileData { shooter = shooter, m_SkillListTableData = d }; }
|
||||||
|
|
||||||
|
static Camera InstallCamera(Vector3 pos, Vector3 lookAt)
|
||||||
|
{
|
||||||
|
var go = new GameObject("__wl811i_cam");
|
||||||
|
go.tag = "MainCamera";
|
||||||
|
var cam = go.AddComponent<Camera>();
|
||||||
|
go.transform.position = pos; go.transform.LookAt(lookAt);
|
||||||
|
return cam;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Kill(GameObject go) { if (go == null) return; if (Application.isPlaying) UnityEngine.Object.Destroy(go); else UnityEngine.Object.DestroyImmediate(go); }
|
||||||
|
|
||||||
|
struct RowDef { public int id, cls, slot; public float cast; public eSkillTarget target; public SpectacleBudgetClass budget; }
|
||||||
|
static RowDef[] Rows(WLSkillSpectacleSettings st)
|
||||||
|
{
|
||||||
|
var list = new RowDef[st.rows.Length];
|
||||||
|
for (int i = 0; i < st.rows.Length; i++)
|
||||||
|
{
|
||||||
|
var r = st.rows[i];
|
||||||
|
list[i] = new RowDef { id = r.skillId, cls = r.classId, slot = r.slot, cast = r.slot == 4 ? 1f : 0f, target = eSkillTarget.Enemy, budget = r.budgetClass };
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>로드 완료까지 폴링(Edit 모드는 Tick 을 직접 · Addressables AssetDatabase 모드는 보통 즉시 완료).</summary>
|
||||||
|
static int PumpLoads(int maxIter)
|
||||||
|
{
|
||||||
|
int it = 0;
|
||||||
|
while (SkillSpectacle.LoadingCount > 0 && it < maxIter) { SkillSpectacle.Tick(Time.time); it++; System.Threading.Thread.Sleep(5); }
|
||||||
|
if (SkillSpectacle.LoadingCount > 0 && !Application.isPlaying) it += 1000 * SkillSpectacle.CompleteLoadsNow(); // Edit 모드: 프레임 펌프가 없어 동기 완료
|
||||||
|
return it;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string EntryRow(string name)
|
||||||
|
{
|
||||||
|
bool loaded, failed, looping; int inst, ps, maxP, rend, tintable; float life;
|
||||||
|
if (!SkillSpectacle.TryGetEntryInfo(name, out loaded, out failed, out inst, out ps, out maxP, out life, out looping, out rend, out tintable)) return name + ": (no entry)";
|
||||||
|
return name + ": loaded=" + loaded + " failed=" + failed + " inst=" + inst + " ps=" + ps + " maxP=" + maxP + " life=" + life.ToString("F2") + " loop=" + looping + " rend=" + rend + " tintable=" + tintable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────── Edit 모드 프로브
|
||||||
|
public static object EditProbe()
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var st = WLSkillSpectacleSettings.Instance;
|
||||||
|
sb.AppendLine("# WL811i EditProbe " + DateTime.Now.ToString("HH:mm:ss") + " playing=" + Application.isPlaying + " core=" + WLCombatCoreSettings.Enabled + " spectacle=" + WLSkillSpectacleSettings.Enabled);
|
||||||
|
if (st == null || !WLCombatCoreSettings.Enabled) { sb.AppendLine("asset/core 없음 → 중단"); Save("edit", sb); return sb.ToString(); }
|
||||||
|
SkillSpectacle.EnsureSubscribed();
|
||||||
|
SkillSpectacle.ResetDiagnostics();
|
||||||
|
var trash = new List<GameObject>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// ① 로드: 16행 전부
|
||||||
|
SkillSpectacle.PreloadAll();
|
||||||
|
int it = PumpLoads(20);
|
||||||
|
sb.AppendLine("① PreloadAll → req=" + SkillSpectacle.LoadRequested + " done=" + SkillSpectacle.LoadCompleted + " fail=" + SkillSpectacle.LoadFailed + " loading=" + SkillSpectacle.LoadingCount + " iter=" + it + " inst(warm)=" + SkillSpectacle.Instantiated + " entries=" + SkillSpectacle.EntryCount);
|
||||||
|
var names = new List<string>();
|
||||||
|
foreach (var r in st.rows) foreach (var v in new[] { r.charge, r.body, r.impact }) if (!string.IsNullOrEmpty(v.prefab) && !names.Contains(v.prefab)) names.Add(v.prefab);
|
||||||
|
foreach (var n in names) sb.AppendLine(" " + EntryRow(n));
|
||||||
|
if (SkillSpectacle.LoadingCount > 0) sb.AppendLine(" ⚠ 로드 미완료 — Edit 모드 Addressables 비동기 → Play 프로브에서 재확인");
|
||||||
|
|
||||||
|
// ② 카메라(절두체) + 가짜 PC 4클래스 + 몹
|
||||||
|
var cam = InstallCamera(new Vector3(0f, 6f, -6f), Vector3.zero); trash.Add(cam.gameObject);
|
||||||
|
var pcs = new Dictionary<int, PCActor>();
|
||||||
|
foreach (var cid in new[] { 10101, 10102, 10501, 10502 }) { var pc = MakePC("__wl811i_pc" + cid, Vector3.zero, cid, Quaternion.identity); pcs[cid] = pc; trash.Add(pc.gameObject); }
|
||||||
|
var mobIn = MakeMob("__wl811i_mobIn", new Vector3(1.5f, 0f, 0f)); trash.Add(mobIn.gameObject);
|
||||||
|
var mobOut = MakeMob("__wl811i_mobOut", new Vector3(0f, 0f, -40f)); trash.Add(mobOut.gameObject); // 카메라 뒤 = 절두체 밖
|
||||||
|
sb.AppendLine("② cam main=" + (Camera.main != null ? Camera.main.name : "NULL") + " inView(0,0,0)=" + EffectBudget.IsInView(Vector3.zero) + " inView(mobOut)=" + EffectBudget.IsInView(mobOut.Get_position()) + " pc10101.IsMainPC=" + pcs[10101].IsMainPC() + " id=" + pcs[10101].Get_ID());
|
||||||
|
|
||||||
|
// ③ 16행 × (SkillCast → SkillFired → HitboxHit×2) · 매 행 뒤 만료
|
||||||
|
sb.AppendLine("③ 16행 가짜 발행 (row: charge/body/impact 스폰 여부 · 스킵 사유)");
|
||||||
|
var rows = Rows(st);
|
||||||
|
int c0, b0, i0;
|
||||||
|
for (int i = 0; i < rows.Length; i++)
|
||||||
|
{
|
||||||
|
var d = Skill(rows[i].id, rows[i].cls == 0 ? 0 : rows[i].cls, rows[i].cast, rows[i].target);
|
||||||
|
var pc = pcs[rows[i].cls];
|
||||||
|
c0 = SkillSpectacle.ChargeSpawned; b0 = SkillSpectacle.BodySpawned; i0 = SkillSpectacle.ImpactSpawned;
|
||||||
|
CombatEvents.RaiseSkillCast(pc, Magic(d, rows[i].slot));
|
||||||
|
string sc = SkillSpectacle.LastInfo;
|
||||||
|
CombatEvents.RaiseSkillFired(pc, 0, Magic(d, rows[i].slot));
|
||||||
|
string sf = SkillSpectacle.LastInfo;
|
||||||
|
CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false);
|
||||||
|
string si = SkillSpectacle.LastInfo;
|
||||||
|
CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false); // 같은 시각 → 간격 스킵
|
||||||
|
int heavyNow = EffectBudget.Count(EffectBudgetKind.Heavy);
|
||||||
|
sb.AppendLine(" " + st.rows[i].classId + "/" + st.rows[i].slot + " " + st.rows[i].skillId + " " + st.rows[i].label + ": charge=" + (SkillSpectacle.ChargeSpawned - c0) + "(" + sc + ") body=" + (SkillSpectacle.BodySpawned - b0) + "(" + sf + ") impact=" + (SkillSpectacle.ImpactSpawned - i0) + "(" + si + ") active=" + SkillSpectacle.ActiveCount + " heavyHeld=" + SkillSpectacle.RowHeavyHeld(i) + " budgetHeavy=" + heavyNow + " medium=" + SkillSpectacle.MediumActive + " interval-skip=" + SkillSpectacle.SkippedImpactInterval);
|
||||||
|
SkillSpectacle.Tick(Time.time + 100f); // 만료
|
||||||
|
}
|
||||||
|
sb.AppendLine(" 만료 후 active=" + SkillSpectacle.ActiveCount + " budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + " heavy acq/rel=" + SkillSpectacle.HeavyAcquired + "/" + SkillSpectacle.HeavyReleased + " medium=" + SkillSpectacle.MediumActive);
|
||||||
|
|
||||||
|
// ④ 절두체 밖: 카메라 뒤 몹에 착탄 → 스킵
|
||||||
|
{
|
||||||
|
var d = Skill(703101, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101];
|
||||||
|
CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1));
|
||||||
|
int v0 = SkillSpectacle.SkippedOutOfView, i1 = SkillSpectacle.ImpactSpawned;
|
||||||
|
CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobOut, false);
|
||||||
|
sb.AppendLine("④ 절두체 밖 착탄 → outOfView +" + (SkillSpectacle.SkippedOutOfView - v0) + " impact +" + (SkillSpectacle.ImpactSpawned - i1) + " (기대 1 / 0)");
|
||||||
|
pc.transform.position = new Vector3(0f, 0f, -40f);
|
||||||
|
int v1 = SkillSpectacle.SkippedOutOfView, c1 = SkillSpectacle.ChargeSpawned;
|
||||||
|
CombatEvents.RaiseSkillCast(pc, Magic(Skill(703102, 10101, 0f, eSkillTarget.Enemy), 2));
|
||||||
|
sb.AppendLine(" 절두체 밖 시전(차지) → outOfView +" + (SkillSpectacle.SkippedOutOfView - v1) + " charge +" + (SkillSpectacle.ChargeSpawned - c1) + " (기대 1 / 0)");
|
||||||
|
pc.transform.position = Vector3.zero;
|
||||||
|
SkillSpectacle.Tick(Time.time + 100f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑤ Heavy 2: 슬롯 4 3개 동시 → 2 스폰 + 1 heavy 스킵 · 만료 후 반납
|
||||||
|
{
|
||||||
|
int h0 = SkillSpectacle.SkippedHeavyBudget, c1 = SkillSpectacle.ChargeSpawned;
|
||||||
|
CombatEvents.RaiseSkillCast(pcs[10101], Magic(Skill(702006, 0, 1f, eSkillTarget.Enemy), 4));
|
||||||
|
CombatEvents.RaiseSkillCast(pcs[10102], Magic(Skill(702009, 0, 1f, eSkillTarget.Self), 4));
|
||||||
|
CombatEvents.RaiseSkillCast(pcs[10501], Magic(Skill(702005, 0, 1f, eSkillTarget.Enemy), 4));
|
||||||
|
sb.AppendLine("⑤ Heavy 3 동시 시전 → charge +" + (SkillSpectacle.ChargeSpawned - c1) + " heavy-skip +" + (SkillSpectacle.SkippedHeavyBudget - h0) + " budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + "/" + EffectBudget.Limit(EffectBudgetKind.Heavy) + " heldRows=" + SkillSpectacle.HeavyHeldRows + " (기대 2 / 1 / 2)");
|
||||||
|
// 같은 행의 본체는 이미 잡은 슬롯을 재사용(추가 획득 0)
|
||||||
|
int a0 = SkillSpectacle.HeavyAcquired, b1 = SkillSpectacle.BodySpawned;
|
||||||
|
CombatEvents.RaiseSkillFired(pcs[10101], 0, Magic(Skill(702006, 0, 1f, eSkillTarget.Enemy), 4));
|
||||||
|
sb.AppendLine(" 같은 행 본체 → body +" + (SkillSpectacle.BodySpawned - b1) + " heavy 추가 획득 +" + (SkillSpectacle.HeavyAcquired - a0) + " budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + " (기대 1 / 0 / 2)");
|
||||||
|
SkillSpectacle.Tick(Time.time + 100f);
|
||||||
|
sb.AppendLine(" 만료 → budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + " heldRows=" + SkillSpectacle.HeavyHeldRows + " active=" + SkillSpectacle.ActiveCount + " (기대 0 / 0 / 0)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑥ Medium 상한: Medium 본체 5개 → mediumMax 까지만
|
||||||
|
{
|
||||||
|
int m0 = SkillSpectacle.SkippedMediumBudget, b1 = SkillSpectacle.BodySpawned;
|
||||||
|
var meds = new[] { new[] { 703102, 10101 }, new[] { 703103, 10101 }, new[] { 703105, 10102 }, new[] { 703503, 10501 }, new[] { 703506, 10502 } };
|
||||||
|
foreach (var m in meds) CombatEvents.RaiseSkillFired(pcs[m[1]], 0, Magic(Skill(m[0], m[1], 0f, eSkillTarget.Enemy), 2));
|
||||||
|
sb.AppendLine("⑥ Medium 본체 5 → body +" + (SkillSpectacle.BodySpawned - b1) + " medium-skip +" + (SkillSpectacle.SkippedMediumBudget - m0) + " mediumActive=" + SkillSpectacle.MediumActive + " (기대 " + st.mediumMax + " / " + (5 - st.mediumMax) + " / " + st.mediumMax + ")");
|
||||||
|
SkillSpectacle.Tick(Time.time + 100f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑦ C8: RuntimeDisabled → 전부 0
|
||||||
|
{
|
||||||
|
WLSkillSpectacleSettings.RuntimeDisabled = true;
|
||||||
|
int c1 = SkillSpectacle.ChargeSpawned, b1 = SkillSpectacle.BodySpawned, i1 = SkillSpectacle.ImpactSpawned;
|
||||||
|
var d = Skill(703101, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101];
|
||||||
|
CombatEvents.RaiseSkillCast(pc, Magic(d, 1)); CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1)); CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false);
|
||||||
|
sb.AppendLine("⑦ RuntimeDisabled → spawn +" + (SkillSpectacle.ChargeSpawned - c1 + SkillSpectacle.BodySpawned - b1 + SkillSpectacle.ImpactSpawned - i1) + " (기대 0) active=" + SkillSpectacle.ActiveCount);
|
||||||
|
WLSkillSpectacleSettings.RuntimeDisabled = false;
|
||||||
|
CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1));
|
||||||
|
sb.AppendLine(" 재활성 → body +" + (SkillSpectacle.BodySpawned - b1) + " (기대 1)");
|
||||||
|
SkillSpectacle.Tick(Time.time + 100f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑧ GC 100회 (풀 워밍 후 · SkillCast+SkillFired+HitboxHit+Tick)
|
||||||
|
{
|
||||||
|
var d = Skill(703102, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101];
|
||||||
|
for (int w = 0; w < 5; w++) { CombatEvents.RaiseSkillCast(pc, Magic(d, 2)); CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 2)); CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false); SkillSpectacle.Tick(Time.time + 100f); }
|
||||||
|
var magic = Magic(d, 2); var pd = Pd(pc, d);
|
||||||
|
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||||
|
long m0 = GC.GetTotalMemory(false); long mono0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
|
||||||
|
for (int n = 0; n < 100; n++) { CombatEvents.RaiseSkillCast(pc, magic); CombatEvents.RaiseSkillFired(pc, 0, magic); CombatEvents.RaiseHitboxHit(null, pd, mobIn, false); SkillSpectacle.Tick(Time.time + 100f); }
|
||||||
|
long m1 = GC.GetTotalMemory(false); long mono1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
|
||||||
|
sb.AppendLine("⑧ GC 100회(cast+fire+hit+tick · 페이로드 재사용) → GetTotalMemory Δ=" + (m1 - m0) + " B · MonoUsed Δ=" + (mono1 - mono0) + " B (기대 0)");
|
||||||
|
// 이벤트 없이 Tick 만 100회(기준선)
|
||||||
|
m0 = GC.GetTotalMemory(false);
|
||||||
|
for (int n = 0; n < 100; n++) SkillSpectacle.Tick(Time.time + 100f);
|
||||||
|
sb.AppendLine(" 기준선 Tick 100회 Δ=" + (GC.GetTotalMemory(false) - m0) + " B");
|
||||||
|
}
|
||||||
|
sb.AppendLine("# 종료 " + Counters());
|
||||||
|
}
|
||||||
|
catch (Exception e) { sb.AppendLine("EXCEPTION " + e); }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SkillSpectacle.ClearAll();
|
||||||
|
foreach (var g in trash) Kill(g);
|
||||||
|
if (!Application.isPlaying) SkillSpectacle.DestroyPools();
|
||||||
|
SkillSpectacle.ResetDiagnostics();
|
||||||
|
if (!Application.isPlaying) { var r = GameObject.Find("__WLSkillSpectacle"); if (r != null) UnityEngine.Object.DestroyImmediate(r); }
|
||||||
|
}
|
||||||
|
Save("edit", sb); return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────── Title Play 프로브
|
||||||
|
public static void PlayProbe()
|
||||||
|
{
|
||||||
|
if (!Application.isPlaying) { Debug.LogWarning("[WL811i] Play 모드에서만(Title Play). Edit 모드는 Dump/EditProbe."); return; }
|
||||||
|
var old = GameObject.Find("__wl811i_probe");
|
||||||
|
if (old != null) UnityEngine.Object.Destroy(old);
|
||||||
|
var go = new GameObject("__wl811i_probe");
|
||||||
|
var h = go.AddComponent<Host>();
|
||||||
|
h.StartCoroutine(h.Run());
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Host : MonoBehaviour
|
||||||
|
{
|
||||||
|
StringBuilder _sb; float _t0;
|
||||||
|
readonly List<GameObject> _trash = new List<GameObject>();
|
||||||
|
string Pre() { return string.Format("{0,7:F0} ms | f{1,-5} | ", (Time.unscaledTime - _t0) * 1000f, Time.frameCount); }
|
||||||
|
IEnumerator Wait(float s) { float t = Time.unscaledTime; while (Time.unscaledTime - t < s) yield return null; }
|
||||||
|
|
||||||
|
public IEnumerator Run()
|
||||||
|
{
|
||||||
|
_sb = new StringBuilder(); _t0 = Time.unscaledTime;
|
||||||
|
var st = WLSkillSpectacleSettings.Instance;
|
||||||
|
_sb.AppendLine("# WL811i PlayProbe " + DateTime.Now.ToString("HH:mm:ss") + " scene=" + UnityEngine.SceneManagement.SceneManager.GetActiveScene().name + " core=" + WLCombatCoreSettings.Enabled + " spectacle=" + WLSkillSpectacleSettings.Enabled + " subscribed=" + SkillSpectacle.Subscribed + " ts=" + Time.timeScale);
|
||||||
|
if (st == null || !WLCombatCoreSettings.Enabled) { _sb.AppendLine("asset/core 없음 → 중단"); Save("play", _sb); Destroy(gameObject); yield break; }
|
||||||
|
SkillSpectacle.ResetDiagnostics();
|
||||||
|
var cam = InstallCamera(new Vector3(0f, 6f, -6f), Vector3.zero); _trash.Add(cam.gameObject);
|
||||||
|
yield return null;
|
||||||
|
|
||||||
|
// ① 로드(런타임 Addressables) — 프레임 폴링
|
||||||
|
SkillSpectacle.PreloadAll();
|
||||||
|
float tl = Time.unscaledTime;
|
||||||
|
while (SkillSpectacle.LoadingCount > 0 && Time.unscaledTime - tl < 20f) yield return null;
|
||||||
|
_sb.AppendLine(Pre() + "① PreloadAll → req=" + SkillSpectacle.LoadRequested + " done=" + SkillSpectacle.LoadCompleted + " fail=" + SkillSpectacle.LoadFailed + " loading=" + SkillSpectacle.LoadingCount + " " + ((Time.unscaledTime - tl) * 1000f).ToString("F0") + " ms · warm inst=" + SkillSpectacle.Instantiated + " runner=" + SkillSpectacleRunner.Exists);
|
||||||
|
var names = new List<string>();
|
||||||
|
foreach (var r in st.rows) foreach (var v in new[] { r.charge, r.body, r.impact }) if (!string.IsNullOrEmpty(v.prefab) && !names.Contains(v.prefab)) names.Add(v.prefab);
|
||||||
|
foreach (var n in names) _sb.AppendLine(" " + EntryRow(n));
|
||||||
|
|
||||||
|
var pcs = new Dictionary<int, PCActor>();
|
||||||
|
foreach (var cid in new[] { 10101, 10102, 10501, 10502 }) { var pc = MakePC("__wl811i_pc" + cid, Vector3.zero, cid, Quaternion.identity); pcs[cid] = pc; _trash.Add(pc.gameObject); }
|
||||||
|
var mobIn = MakeMob("__wl811i_mobIn", new Vector3(1.5f, 0f, 0f)); _trash.Add(mobIn.gameObject);
|
||||||
|
var mobOut = MakeMob("__wl811i_mobOut", new Vector3(0f, 0f, -40f)); _trash.Add(mobOut.gameObject);
|
||||||
|
yield return null;
|
||||||
|
_sb.AppendLine(Pre() + "② cam main=" + (Camera.main != null ? Camera.main.name : "NULL") + " inView(0)=" + EffectBudget.IsInView(Vector3.zero) + " inView(out)=" + EffectBudget.IsInView(mobOut.Get_position()) + " pc.IsMainPC=" + pcs[10101].IsMainPC() + " id=" + pcs[10101].Get_ID());
|
||||||
|
|
||||||
|
// ③ 16행 실스폰 + 수명 실측(활성 수가 0 이 되는 시각)
|
||||||
|
_sb.AppendLine(Pre() + "③ 16행 (charge/body/impact 스폰 · 활성 0 도달 ms · Heavy 반납)");
|
||||||
|
var rows = Rows(st);
|
||||||
|
for (int i = 0; i < rows.Length; i++)
|
||||||
|
{
|
||||||
|
var d = Skill(rows[i].id, rows[i].cls, rows[i].cast, rows[i].target);
|
||||||
|
var pc = pcs[rows[i].cls];
|
||||||
|
int c0 = SkillSpectacle.ChargeSpawned, b0 = SkillSpectacle.BodySpawned, i0 = SkillSpectacle.ImpactSpawned;
|
||||||
|
float t = Time.unscaledTime;
|
||||||
|
CombatEvents.RaiseSkillCast(pc, Magic(d, rows[i].slot)); string sc = SkillSpectacle.LastInfo;
|
||||||
|
yield return null;
|
||||||
|
CombatEvents.RaiseSkillFired(pc, 0, Magic(d, rows[i].slot)); string sf = SkillSpectacle.LastInfo;
|
||||||
|
yield return null;
|
||||||
|
CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false); string si = SkillSpectacle.LastInfo;
|
||||||
|
int peak = SkillSpectacle.ActiveCount;
|
||||||
|
float tz = -1f; float tEnd = t + 6f;
|
||||||
|
while (Time.unscaledTime < tEnd) { yield return null; if (SkillSpectacle.ActiveCount > peak) peak = SkillSpectacle.ActiveCount; if (SkillSpectacle.ActiveCount == 0) { tz = Time.unscaledTime - t; break; } }
|
||||||
|
_sb.AppendLine(Pre() + " " + st.rows[i].classId + "/" + st.rows[i].slot + " " + st.rows[i].skillId + " " + st.rows[i].label + ": charge=" + (SkillSpectacle.ChargeSpawned - c0) + "(" + sc + ") body=" + (SkillSpectacle.BodySpawned - b0) + "(" + sf + ") impact=" + (SkillSpectacle.ImpactSpawned - i0) + "(" + si + ") peakActive=" + peak + " zeroAt=" + (tz >= 0 ? (tz * 1000f).ToString("F0") + " ms" : "미도달(6 s)") + " budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + " heldRows=" + SkillSpectacle.HeavyHeldRows);
|
||||||
|
if (tz < 0f) SkillSpectacle.ClearAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 착탄 발동당 상한: 0.05 s 간격 6타 → impactMaxPerFire 까지
|
||||||
|
{
|
||||||
|
var d = Skill(703101, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101];
|
||||||
|
CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1));
|
||||||
|
int i0 = SkillSpectacle.ImpactSpawned, cap0 = SkillSpectacle.SkippedImpactCap;
|
||||||
|
for (int k = 0; k < 6; k++) { CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false); yield return Wait(0.06f); }
|
||||||
|
_sb.AppendLine(Pre() + "④ 착탄 6타(0.06 s 간격) → impact +" + (SkillSpectacle.ImpactSpawned - i0) + " cap-skip +" + (SkillSpectacle.SkippedImpactCap - cap0) + " (기대 " + st.impactMaxPerFire + " / " + (6 - st.impactMaxPerFire) + ") thisFire=" + SkillSpectacle.RowImpactsThisFire(st.FindRow(703101, 10101)));
|
||||||
|
yield return Wait(3.2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑤ 절두체 밖
|
||||||
|
{
|
||||||
|
var d = Skill(703101, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101];
|
||||||
|
CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1));
|
||||||
|
int v0 = SkillSpectacle.SkippedOutOfView, i1 = SkillSpectacle.ImpactSpawned;
|
||||||
|
CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobOut, false);
|
||||||
|
_sb.AppendLine(Pre() + "⑤ 절두체 밖 착탄 → outOfView +" + (SkillSpectacle.SkippedOutOfView - v0) + " impact +" + (SkillSpectacle.ImpactSpawned - i1) + " (기대 1 / 0)");
|
||||||
|
yield return Wait(3.2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑥ Heavy 2 동시 + 차지 1 s 수명 + 반납 시각
|
||||||
|
{
|
||||||
|
int h0 = SkillSpectacle.SkippedHeavyBudget, c1 = SkillSpectacle.ChargeSpawned;
|
||||||
|
float t = Time.unscaledTime;
|
||||||
|
CombatEvents.RaiseSkillCast(pcs[10101], Magic(Skill(702006, 0, 1f, eSkillTarget.Enemy), 4));
|
||||||
|
CombatEvents.RaiseSkillCast(pcs[10502], Magic(Skill(702012, 0, 1f, eSkillTarget.Enemy), 4));
|
||||||
|
CombatEvents.RaiseSkillCast(pcs[10501], Magic(Skill(702005, 0, 1f, eSkillTarget.Enemy), 4));
|
||||||
|
_sb.AppendLine(Pre() + "⑥ Heavy 3 동시 시전 → charge +" + (SkillSpectacle.ChargeSpawned - c1) + " heavy-skip +" + (SkillSpectacle.SkippedHeavyBudget - h0) + " budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + "/" + EffectBudget.Limit(EffectBudgetKind.Heavy) + " (기대 2 / 1 / 2)");
|
||||||
|
float tRel = -1f;
|
||||||
|
while (Time.unscaledTime - t < 3f) { yield return null; if (EffectBudget.Count(EffectBudgetKind.Heavy) == 0) { tRel = Time.unscaledTime - t; break; } }
|
||||||
|
_sb.AppendLine(Pre() + " 차지 만료 → Heavy 0 도달 " + (tRel >= 0 ? (tRel * 1000f).ToString("F0") + " ms (기대 ≈1000 = f_CastingTime)" : "미도달") + " active=" + SkillSpectacle.ActiveCount + " rel=" + SkillSpectacle.HeavyReleased);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑦ 폴백 착탄(HitConfirmed · Beater_pd = 스킬 · HitboxHit 없이)
|
||||||
|
{
|
||||||
|
var d = Skill(703505, 10502, 0f, eSkillTarget.Enemy); var pc = pcs[10502];
|
||||||
|
CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1));
|
||||||
|
int fb0 = SkillSpectacle.ImpactFallback, i1 = SkillSpectacle.ImpactSpawned;
|
||||||
|
var pd = Pd(pc, d);
|
||||||
|
var dinfo = new DamageInfo { Beater = pc, Beater_pd = pd, Critical = eStat.None }; dinfo.Damage = 10; dinfo.BaseDmg = 10;
|
||||||
|
yield return null;
|
||||||
|
CombatEvents.RaiseHitConfirmed(mobIn, dinfo);
|
||||||
|
_sb.AppendLine(Pre() + "⑦ HitConfirmed 폴백 → fallback +" + (SkillSpectacle.ImpactFallback - fb0) + " impact +" + (SkillSpectacle.ImpactSpawned - i1) + " (기대 1 / 1)");
|
||||||
|
// HitboxHit 직후 같은 프레임 HitConfirmed → 중복 0
|
||||||
|
yield return Wait(0.1f);
|
||||||
|
fb0 = SkillSpectacle.ImpactFallback; i1 = SkillSpectacle.ImpactSpawned;
|
||||||
|
CombatEvents.RaiseHitboxHit(null, pd, mobIn, false);
|
||||||
|
CombatEvents.RaiseHitConfirmed(mobIn, dinfo);
|
||||||
|
_sb.AppendLine(Pre() + " HitboxHit+HitConfirmed 같은 프레임 → fallback +" + (SkillSpectacle.ImpactFallback - fb0) + " impact +" + (SkillSpectacle.ImpactSpawned - i1) + " (기대 0 / 1)");
|
||||||
|
yield return Wait(3.2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑧ C8
|
||||||
|
{
|
||||||
|
WLSkillSpectacleSettings.RuntimeDisabled = true;
|
||||||
|
int c1 = SkillSpectacle.ChargeSpawned, b1 = SkillSpectacle.BodySpawned, i1 = SkillSpectacle.ImpactSpawned;
|
||||||
|
var d = Skill(703101, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101];
|
||||||
|
CombatEvents.RaiseSkillCast(pc, Magic(d, 1)); CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1)); CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false);
|
||||||
|
yield return null;
|
||||||
|
_sb.AppendLine(Pre() + "⑧ RuntimeDisabled → spawn +" + (SkillSpectacle.ChargeSpawned - c1 + SkillSpectacle.BodySpawned - b1 + SkillSpectacle.ImpactSpawned - i1) + " (기대 0) active=" + SkillSpectacle.ActiveCount);
|
||||||
|
WLSkillSpectacleSettings.RuntimeDisabled = false;
|
||||||
|
CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1));
|
||||||
|
_sb.AppendLine(Pre() + " 재활성 → body +" + (SkillSpectacle.BodySpawned - b1) + " (기대 1)");
|
||||||
|
yield return Wait(3.2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑨ GC 100회 (프레임마다 cast+fire+hit · 만료는 러너 Update · 페이로드 재사용)
|
||||||
|
{
|
||||||
|
var d = Skill(703102, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101];
|
||||||
|
var magic = Magic(d, 2); var pd = Pd(pc, d);
|
||||||
|
for (int w = 0; w < 5; w++) { CombatEvents.RaiseSkillCast(pc, magic); CombatEvents.RaiseSkillFired(pc, 0, magic); CombatEvents.RaiseHitboxHit(null, pd, mobIn, false); yield return null; }
|
||||||
|
yield return Wait(3.2f);
|
||||||
|
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||||
|
yield return null;
|
||||||
|
long m0 = GC.GetTotalMemory(false); long mono0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
|
||||||
|
int sk0 = SkillSpectacle.SkippedPoolFull + SkillSpectacle.SkippedMediumBudget;
|
||||||
|
for (int n = 0; n < 100; n++) { CombatEvents.RaiseSkillCast(pc, magic); CombatEvents.RaiseSkillFired(pc, 0, magic); CombatEvents.RaiseHitboxHit(null, pd, mobIn, false); }
|
||||||
|
long m1 = GC.GetTotalMemory(false); long mono1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
|
||||||
|
_sb.AppendLine(Pre() + "⑨ GC 100회(같은 프레임 · cast+fire+hit) → GetTotalMemory Δ=" + (m1 - m0) + " B · MonoUsed Δ=" + (mono1 - mono0) + " B (기대 0) · 풀/상한 스킵 +" + (SkillSpectacle.SkippedPoolFull + SkillSpectacle.SkippedMediumBudget - sk0));
|
||||||
|
// 프레임 분산 100회(만료가 끼어드는 경로)
|
||||||
|
yield return Wait(3.2f);
|
||||||
|
GC.Collect(); yield return null;
|
||||||
|
m0 = GC.GetTotalMemory(false);
|
||||||
|
for (int n = 0; n < 100; n++) { CombatEvents.RaiseSkillCast(pc, magic); CombatEvents.RaiseSkillFired(pc, 0, magic); CombatEvents.RaiseHitboxHit(null, pd, mobIn, false); yield return null; }
|
||||||
|
m1 = GC.GetTotalMemory(false);
|
||||||
|
_sb.AppendLine(Pre() + " 프레임 분산 100회 → Δ=" + (m1 - m0) + " B (러너 Update 포함 · 다른 시스템 잡음 가능)");
|
||||||
|
}
|
||||||
|
_sb.AppendLine(Pre() + "# 종료 " + Counters());
|
||||||
|
Save("play", _sb);
|
||||||
|
Cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Cleanup()
|
||||||
|
{
|
||||||
|
SkillSpectacle.ClearAll();
|
||||||
|
foreach (var g in _trash) if (g != null) Destroy(g);
|
||||||
|
_trash.Clear();
|
||||||
|
SkillSpectacle.ResetDiagnostics();
|
||||||
|
Destroy(gameObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,621 @@
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────── 스폰
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
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>프로브용 카운터 초기화(구독·풀 유지).</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 = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bf22bf7b3250488d844b96760a32199b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SkillSpectacleRunner.cs — 스킬 스펙터클 전용 틱 러너 + 풀 루트(숨은 오브젝트 1개 · Play 는 DontDestroyOnLoad · 에디트 모드는 DontSave)
|
||||||
|
//
|
||||||
|
// 발주서 WL-811i · 새 Manager/Singleton 을 만들지 않는다(WLReactionRunner · LootBurstRunner 선례 · 발주서 밖 파일 무수정 원칙으로 별도 러너).
|
||||||
|
// Pool(비활성) 아래에서 Instantiate 해 Awake/OnEnable 없이 캐시한 뒤 Live(활성) 로 옮긴다. 씬 전환 · 비활성 · 앱 종료 시 활성 이펙트를 끄고 예산을 돌려준다.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.SceneManagement;
|
||||||
|
|
||||||
|
namespace WL.Combat.Reaction
|
||||||
|
{
|
||||||
|
public sealed class SkillSpectacleRunner : MonoBehaviour
|
||||||
|
{
|
||||||
|
static SkillSpectacleRunner s_runner;
|
||||||
|
static bool s_sceneHooked;
|
||||||
|
|
||||||
|
/// <summary>비활성 부모(여기서 Instantiate → Awake 안 돎).</summary>
|
||||||
|
public Transform PoolRoot;
|
||||||
|
/// <summary>활성 부모(재생 중 인스턴스).</summary>
|
||||||
|
public Transform LiveRoot;
|
||||||
|
|
||||||
|
public static bool Exists { get { return s_runner != null; } }
|
||||||
|
|
||||||
|
public static SkillSpectacleRunner Ensure()
|
||||||
|
{
|
||||||
|
if (s_runner != null) return s_runner;
|
||||||
|
bool playing = Application.isPlaying;
|
||||||
|
var go = new GameObject("__WLSkillSpectacle");
|
||||||
|
if (playing) DontDestroyOnLoad(go); else go.hideFlags = HideFlags.DontSave;
|
||||||
|
s_runner = go.AddComponent<SkillSpectacleRunner>();
|
||||||
|
|
||||||
|
var pool = new GameObject("Pool");
|
||||||
|
pool.transform.SetParent(go.transform, false);
|
||||||
|
if (!playing) pool.hideFlags = HideFlags.DontSave;
|
||||||
|
pool.SetActive(false);
|
||||||
|
s_runner.PoolRoot = pool.transform;
|
||||||
|
|
||||||
|
var live = new GameObject("Live");
|
||||||
|
live.transform.SetParent(go.transform, false);
|
||||||
|
if (!playing) live.hideFlags = HideFlags.DontSave;
|
||||||
|
s_runner.LiveRoot = live.transform;
|
||||||
|
|
||||||
|
if (!s_sceneHooked) { SceneManager.activeSceneChanged += OnSceneChanged; s_sceneHooked = true; }
|
||||||
|
return s_runner;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void OnSceneChanged(Scene from, Scene to) { SkillSpectacle.ClearAll(); }
|
||||||
|
|
||||||
|
void Update() { SkillSpectacle.Tick(Time.time); }
|
||||||
|
|
||||||
|
void OnDisable() { SkillSpectacle.ClearAll(); }
|
||||||
|
void OnApplicationQuit() { SkillSpectacle.ClearAll(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 081ba2dfc6054f3a9465d570e886d829
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// WLSkillSpectacleSettings.cs — 스킬 스펙터클(4클래스 × 4슬롯 차지·본체·착탄 VFX 매핑 + 동시 상한) 값의 단일 출처(SOT · C45)
|
||||||
|
//
|
||||||
|
// PD 지시 #813(1순위 축 "화려한 전투 액션") · 발주서 WL-811i §1 · 설계안 WL-811a §B-3 `vfxSlots` · §B-5-2 · §C 811i 행 ·
|
||||||
|
// 기준서 v1 §B 요소 3(대형 동시 2 · 화면 밖 스킵 · fps 저하 ≤3) · 기획안 v2 슬라이드 11(F-0B 클래스 원소 표) · 813n 입력 열 (2026-09-09)
|
||||||
|
//
|
||||||
|
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset
|
||||||
|
// → Resources.Load<WLSkillSpectacleSettings>("WL/WLSkillSpectacleSettings") 로 1회 로드 후 캐시(기존 WL 설정 SO 와 같은 관례).
|
||||||
|
// 에셋이 없거나 enabled = false 또는 RuntimeDisabled 면 SkillSpectacle 이 아무 것도 하지 않는다 = 원본 스킬 이펙트만(C8).
|
||||||
|
//
|
||||||
|
// ■ 행(rows) = 813n 프리셋 16행(클래스 4 × 슬롯 4). 프리팹명 = Assets/Res_Addr/Effect/<이름>.prefab 의 이름(원본 문자열 로딩 규약 그대로 · 새 아트 0).
|
||||||
|
// charge = SkillCast(시전) · f_CastingTime>0 이면 그 시간 동안(슬롯 4 광역) · 0 이면 chargeFlashSeconds 동안의 발동 플래시
|
||||||
|
// body = SkillFired(실체 발사 직전) · 원본 스킬 프리팹 이펙트는 그대로 두고 **추가 레이어**로 얹는다
|
||||||
|
// impact = HitboxHit(스킬 히트박스 명중 · 피격자 위치) · 발동 1회당 impactMaxPerFire 까지 · HitConfirmed(Beater_pd = 스킬)은 같은 프레임 중복이 아닐 때만 폴백
|
||||||
|
// ■ 클래스 원소(classElements) = 기획안 F-0B 표(원핸드 10101 Nature · 스펠블레이드 10102 Dark · 블런트실드 10501 / 팔라딘 10502 Holy)
|
||||||
|
// → 공용 프리팹(차지 · 슬롯 4 본체/착탄)에 MaterialPropertyBlock 곱셈 틴트(원본 머티리얼 색 × 클래스 색 · 머티리얼 인스턴스 0 · 새 머티리얼 0).
|
||||||
|
// 클래스 전용 프리팹(Effect_Slash_<ID>_N · Effect_Hit_<ID> · Effect_Hit_Skill_*)은 tint = false.
|
||||||
|
// ■ 예산: budgetClass Heavy 행은 EffectBudget.Heavy(코어 SO heavyEffectMax = 2) 슬롯을 차지/본체가 잡고 둘 다 끝나면 돌려준다 ·
|
||||||
|
// Medium 은 이 에셋의 mediumMax 로 로컬 상한 · 화면 밖(EffectBudget.IsInView)이면 스킵 · 초과분은 큐잉 없이 스킵(설계안 §B-5-2).
|
||||||
|
//
|
||||||
|
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace WL.Combat.Reaction
|
||||||
|
{
|
||||||
|
/// <summary>스펙터클 3단(설계안 §B-3 VfxSlot.phase).</summary>
|
||||||
|
public enum SpectaclePhase { Charge = 0, Body = 1, Impact = 2 }
|
||||||
|
|
||||||
|
/// <summary>동시 상한 계수(설계안 §B-3 budgetClass). Heavy = EffectBudget.Heavy · Medium = 로컬 mediumMax · Light = 상한 없음.</summary>
|
||||||
|
public enum SpectacleBudgetClass { Light = 0, Medium = 1, Heavy = 2 }
|
||||||
|
|
||||||
|
/// <summary>클래스 원소(기획안 F-0B 표 · 설계안 elementBurst 와 같은 이름 집합).</summary>
|
||||||
|
public enum SpectacleElement { None = 0, Fire = 1, Dark = 2, Blood = 3, Holy = 4, Ice = 5, Nature = 6 }
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
public struct SpectacleClassElement
|
||||||
|
{
|
||||||
|
[Tooltip("ClassConfig.n_ClassID (근접 4클래스 10101/10102/10501/10502).")]
|
||||||
|
public int classId;
|
||||||
|
[Tooltip("표시용 라벨(데이터 · 코드가 읽지 않는다).")]
|
||||||
|
public string label;
|
||||||
|
[Tooltip("클래스 원소(기획안 F-0B 표).")]
|
||||||
|
public SpectacleElement element;
|
||||||
|
[Tooltip("틴트 색. 원본 머티리얼 색에 곱한다(각 채널 1 = 원본 밝기 그대로 · tintStrength 로 흰색과 보간).")]
|
||||||
|
public Color tint;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
public struct SpectacleVfx
|
||||||
|
{
|
||||||
|
[Tooltip("Assets/Res_Addr/Effect/<이름>.prefab 의 이름. 비우면 이 단은 스킵.")]
|
||||||
|
public string prefab;
|
||||||
|
[Tooltip("스케일 배율(프리팹 localScale × 이 값). 0 이하 = 1.")]
|
||||||
|
public float scale;
|
||||||
|
[Tooltip("앵커: Bottom = 발밑(Get_position) · Mid = 몸 중심 · Top = 머리 위. 차지/본체는 시전자 · 착탄은 피격자 기준.")]
|
||||||
|
public eEffectLocation anchor;
|
||||||
|
[Tooltip("시전자 전방(transform.forward)으로 미는 거리(m). 착탄에는 적용되지 않는다.")]
|
||||||
|
public float forwardOffset;
|
||||||
|
[Tooltip("시전자의 회전(바라보는 방향)을 이펙트에 준다(방향이 있는 본체용). 착탄에는 적용되지 않는다.")]
|
||||||
|
public bool useCasterRotation;
|
||||||
|
[Tooltip("클래스 원소 틴트를 얹는다(공용 프리팹용 · 클래스 전용 프리팹은 false).")]
|
||||||
|
public bool tint;
|
||||||
|
[Tooltip("수명(초). 0 = 자동(파티클 duration+startLifetime · autoLifetimeMax 로 캡 · 루프 프리팹은 loopingLifetime · 차지는 f_CastingTime 또는 chargeFlashSeconds).")]
|
||||||
|
public float lifetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
public struct SpectacleRow
|
||||||
|
{
|
||||||
|
[Tooltip("SkillList.n_SkillID (813n 프리셋 16행).")]
|
||||||
|
public int skillId;
|
||||||
|
[Tooltip("이 행이 속한 클래스(ClassConfig.n_ClassID). 슬롯 4 광역 스킬은 n_SkillClassID = 0 이라 여기서 클래스를 붙인다.")]
|
||||||
|
public int classId;
|
||||||
|
[Tooltip("슬롯 번호 1~4(표시용 · PCActor.arr_magicData index + 1).")]
|
||||||
|
public int slot;
|
||||||
|
[Tooltip("스킬 이름(표시용).")]
|
||||||
|
public string label;
|
||||||
|
public SpectacleVfx charge;
|
||||||
|
public SpectacleVfx body;
|
||||||
|
public SpectacleVfx impact;
|
||||||
|
[Tooltip("차지+본체의 동시 상한 계수. Heavy = EffectBudget.Heavy(동시 2 · 행당 슬롯 1개) · Medium = mediumMax · Light = 없음.")]
|
||||||
|
public SpectacleBudgetClass budgetClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
[CreateAssetMenu(fileName = "WLSkillSpectacleSettings", menuName = "WL/Skill Spectacle Settings", order = 324)]
|
||||||
|
public sealed class WLSkillSpectacleSettings : ScriptableObject
|
||||||
|
{
|
||||||
|
public const string ResourcesPath = "WL/WLSkillSpectacleSettings";
|
||||||
|
/// <summary>원본 문자열 로딩 경로 규약(InGameInfo.Load_Effect 와 동일 · 프리팹당 1회만 결합해 캐시한다).</summary>
|
||||||
|
public const string EffectPathPrefix = "Assets/Res_Addr/Effect/";
|
||||||
|
public const string EffectPathSuffix = ".prefab";
|
||||||
|
|
||||||
|
static WLSkillSpectacleSettings s_cached;
|
||||||
|
static bool s_lookupDone;
|
||||||
|
|
||||||
|
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
|
||||||
|
public static WLSkillSpectacleSettings Instance
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (!s_lookupDone)
|
||||||
|
{
|
||||||
|
s_cached = Resources.Load<WLSkillSpectacleSettings>(ResourcesPath);
|
||||||
|
s_lookupDone = true;
|
||||||
|
}
|
||||||
|
return s_cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 스펙터클만 끈다. Play 종료 시 자동 false.</summary>
|
||||||
|
public static bool RuntimeDisabled;
|
||||||
|
|
||||||
|
public static bool Enabled
|
||||||
|
{
|
||||||
|
get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────── 전체 스위치
|
||||||
|
[Header("전체 스위치")]
|
||||||
|
[Tooltip("끄면 차지·본체·착탄 레이어가 전부 비활성 = 원본 스킬 이펙트만. 롤백 1순위.")]
|
||||||
|
public bool enabled = true;
|
||||||
|
|
||||||
|
[Tooltip("스폰·스킵·상한 결정을 Console 에 남긴다(검증용 · GC 발생 · 서비스 빌드 off).")]
|
||||||
|
public bool verboseLog = false;
|
||||||
|
|
||||||
|
[Tooltip("메인 PC(IsMainPC)의 스킬만 꾸민다. off 면 적 PC 등 같은 스킬 ID 도 꾸민다.")]
|
||||||
|
public bool requireMainPC = true;
|
||||||
|
|
||||||
|
[Tooltip("스폰 위치가 카메라 화면 밖(EffectBudget.IsInView)이면 스킵(기준서 §B 요소 3).")]
|
||||||
|
public bool requireInView = true;
|
||||||
|
|
||||||
|
[Tooltip("메인 PC 의 첫 공격(AttackStarted) 때 그 클래스 4행의 프리팹을 미리 로드한다(첫 스킬 전에 로드가 끝나게).")]
|
||||||
|
public bool preloadOnFirstAttack = true;
|
||||||
|
|
||||||
|
[Tooltip("첫 SkillCast 때 그 클래스의 4행 프리팹 3단을 미리 로드한다.")]
|
||||||
|
public bool preloadClassOnFirstCast = true;
|
||||||
|
|
||||||
|
[Tooltip("프리팹 로드가 끝나면 풀 인스턴스 1개를 미리 만들어 둔다(첫 스폰의 Instantiate 스파이크 제거).")]
|
||||||
|
public bool warmInstanceOnLoad = true;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────── 차지
|
||||||
|
[Header("차지 (SkillCast · f_CastingTime>0 이면 그 시간 · 0 이면 발동 플래시)")]
|
||||||
|
[Tooltip("f_CastingTime = 0 인 슬롯 1~3 의 차지 플래시 수명(초). 기획안 F-0B '차지 0.3 s'.")]
|
||||||
|
public float chargeFlashSeconds = 0.35f;
|
||||||
|
|
||||||
|
[Tooltip("차지 이펙트가 시전자를 따라간다(매 틱 앵커 갱신).")]
|
||||||
|
public bool chargeFollowCaster = true;
|
||||||
|
|
||||||
|
[Tooltip("차지 수명 상한(초 · f_CastingTime 이 비정상적으로 길어도 이 값에 캡).")]
|
||||||
|
public float chargeMaxLifetime = 2f;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────── 본체 · 상한
|
||||||
|
[Header("본체 · 동시 상한 (Heavy = EffectBudget.Heavy 코어 SO heavyEffectMax · Medium = 아래 로컬 상한)")]
|
||||||
|
[Tooltip("budgetClass = Heavy 행이 EffectBudget.Heavy 슬롯을 잡는다(차지+본체가 모두 끝나면 반납). off 면 Heavy 도 상한 없이 스폰.")]
|
||||||
|
public bool heavyUsesEffectBudget = true;
|
||||||
|
|
||||||
|
[Tooltip("budgetClass = Medium 차지+본체의 동시 상한(이 시스템 안에서만 센다).")]
|
||||||
|
public int mediumMax = 4;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────── 착탄
|
||||||
|
[Header("착탄 (HitboxHit · 피격자 앵커 · 발동 1회당 상한)")]
|
||||||
|
[Tooltip("스킬 발동(SkillFired) 1회당 착탄 이펙트 최대 수(광역 다중 명중 과밀 방지).")]
|
||||||
|
public int impactMaxPerFire = 4;
|
||||||
|
|
||||||
|
[Tooltip("착탄 최소 간격(초 · 같은 행). 같은 프레임 다중 명중은 이 간격으로 1개만 남는다. 0 = 제한 없음.")]
|
||||||
|
public float impactMinIntervalSeconds = 0.04f;
|
||||||
|
|
||||||
|
[Tooltip("noDmg(피해 0 통과) 명중에는 착탄을 내지 않는다.")]
|
||||||
|
public bool impactSkipNoDamage = true;
|
||||||
|
|
||||||
|
[Tooltip("HitboxHit 없이 온 스킬 피해(HitConfirmed · Beater_pd = 스킬)에도 착탄을 낸다(같은 프레임·같은 피격자 중복은 제외).")]
|
||||||
|
public bool impactFallbackHitConfirmed = true;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────── 수명 · 풀
|
||||||
|
[Header("수명 · 풀 (인스턴스는 이 시스템 전용 풀 · 원본 InGameInfo 풀과 분리 · TurnOff_GO 코루틴 대신 자체 틱)")]
|
||||||
|
[Tooltip("자동 수명 상한(초). 파티클 duration+startLifetime 이 이보다 길어도 여기서 끈다.")]
|
||||||
|
public float autoLifetimeMax = 2.5f;
|
||||||
|
|
||||||
|
[Tooltip("루프 파티클(looping)이 있는 프리팹의 자동 수명(초).")]
|
||||||
|
public float loopingLifetime = 1.5f;
|
||||||
|
|
||||||
|
[Tooltip("프리팹 1종당 동시 인스턴스 상한(풀 크기 · 초과 요청은 스킵). 첫 사용 때 1개씩 늘어난다.")]
|
||||||
|
public int poolMaxPerPrefab = 4;
|
||||||
|
|
||||||
|
[Tooltip("동시에 추적할 수 있는 활성 이펙트 수(배열 사전 할당 · 초과 요청은 스킵).")]
|
||||||
|
public int trackCapacity = 48;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────── 틴트
|
||||||
|
[Header("클래스 원소 틴트 (MaterialPropertyBlock · 원본 머티리얼 색 × 클래스 색 · 인스턴스 생성 때 1회 실측)")]
|
||||||
|
[Tooltip("틴트 사용 여부(각 단의 tint 플래그와 AND).")]
|
||||||
|
public bool tintEnabled = true;
|
||||||
|
|
||||||
|
[Tooltip("틴트 강도 0~1(0 = 원본색 · 1 = 원본색 × 클래스 색).")]
|
||||||
|
[Range(0f, 1f)] public float tintStrength = 0.85f;
|
||||||
|
|
||||||
|
[Tooltip("틴트를 쓸 색 프로퍼티 후보(앞에서부터 셰이더에 있는 첫 것 1개).")]
|
||||||
|
public string[] tintProperties = new string[] { "_TintColor", "_Color", "_BaseColor" };
|
||||||
|
|
||||||
|
[Tooltip("클래스 원소 표(4행).")]
|
||||||
|
public SpectacleClassElement[] classElements = new SpectacleClassElement[0];
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────── 매핑 16행
|
||||||
|
[Header("매핑 16행 (813n 프리셋 · 클래스 4 × 슬롯 4)")]
|
||||||
|
public SpectacleRow[] rows = new SpectacleRow[0];
|
||||||
|
|
||||||
|
/// <summary>skillId(+classId 우선) 로 행 인덱스를 찾는다(선형 16 · alloc 0). 없으면 −1.</summary>
|
||||||
|
public int FindRow(int skillId, int classId)
|
||||||
|
{
|
||||||
|
if (rows == null) return -1;
|
||||||
|
int any = -1;
|
||||||
|
for (int i = 0; i < rows.Length; i++)
|
||||||
|
{
|
||||||
|
if (rows[i].skillId != skillId) continue;
|
||||||
|
if (rows[i].classId == classId) return i;
|
||||||
|
if (any < 0) any = i;
|
||||||
|
}
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>클래스 원소 행 인덱스. 없으면 −1.</summary>
|
||||||
|
public int FindClassElement(int classId)
|
||||||
|
{
|
||||||
|
if (classElements == null) return -1;
|
||||||
|
for (int i = 0; i < classElements.Length; i++) if (classElements[i].classId == classId) return i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ea537e51338f46f98f753b6116d9c24b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,522 @@
|
||||||
|
%YAML 1.1
|
||||||
|
%TAG !u! tag:unity3d.com,2011:
|
||||||
|
--- !u!114 &11400000
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 0}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: ea537e51338f46f98f753b6116d9c24b, type: 3}
|
||||||
|
m_Name: WLSkillSpectacleSettings
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Reaction.WLSkillSpectacleSettings
|
||||||
|
enabled: 1
|
||||||
|
verboseLog: 0
|
||||||
|
requireMainPC: 1
|
||||||
|
requireInView: 1
|
||||||
|
preloadOnFirstAttack: 1
|
||||||
|
preloadClassOnFirstCast: 1
|
||||||
|
warmInstanceOnLoad: 1
|
||||||
|
chargeFlashSeconds: 0.35
|
||||||
|
chargeFollowCaster: 1
|
||||||
|
chargeMaxLifetime: 2
|
||||||
|
heavyUsesEffectBudget: 1
|
||||||
|
mediumMax: 4
|
||||||
|
impactMaxPerFire: 4
|
||||||
|
impactMinIntervalSeconds: 0.04
|
||||||
|
impactSkipNoDamage: 1
|
||||||
|
impactFallbackHitConfirmed: 1
|
||||||
|
autoLifetimeMax: 2.5
|
||||||
|
loopingLifetime: 1.5
|
||||||
|
poolMaxPerPrefab: 4
|
||||||
|
trackCapacity: 48
|
||||||
|
tintEnabled: 1
|
||||||
|
tintStrength: 0.85
|
||||||
|
tintProperties:
|
||||||
|
- _TintColor
|
||||||
|
- _Color
|
||||||
|
- _BaseColor
|
||||||
|
classElements:
|
||||||
|
- classId: 10101
|
||||||
|
label: onehand · Nature
|
||||||
|
element: 6
|
||||||
|
tint: {r: 0.55, g: 1, b: 0.45, a: 1}
|
||||||
|
- classId: 10102
|
||||||
|
label: spellblade · Dark
|
||||||
|
element: 2
|
||||||
|
tint: {r: 0.72, g: 0.45, b: 1, a: 1}
|
||||||
|
- classId: 10501
|
||||||
|
label: bluntshield · Holy
|
||||||
|
element: 4
|
||||||
|
tint: {r: 1, g: 0.92, b: 0.55, a: 1}
|
||||||
|
- classId: 10502
|
||||||
|
label: paladin · Holy
|
||||||
|
element: 4
|
||||||
|
tint: {r: 1, g: 0.92, b: 0.55, a: 1}
|
||||||
|
rows:
|
||||||
|
- skillId: 703101
|
||||||
|
classId: 10101
|
||||||
|
slot: 1
|
||||||
|
label: 연속 베기
|
||||||
|
charge:
|
||||||
|
prefab:
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_Slash_10101_1
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0.6
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: Effect_Hit_10101
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 0
|
||||||
|
- skillId: 703102
|
||||||
|
classId: 10101
|
||||||
|
slot: 2
|
||||||
|
label: 회심의 일격
|
||||||
|
charge:
|
||||||
|
prefab: Effect_Berserk
|
||||||
|
scale: 1
|
||||||
|
anchor: 2
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_Slash_10101_2
|
||||||
|
scale: 1.3
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0.8
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: Effect_Hit_10101
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 1
|
||||||
|
- skillId: 703103
|
||||||
|
classId: 10101
|
||||||
|
slot: 3
|
||||||
|
label: 돌진 베기
|
||||||
|
charge:
|
||||||
|
prefab: Effect_PCDash
|
||||||
|
scale: 1
|
||||||
|
anchor: 2
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_Slash_10101_3
|
||||||
|
scale: 1.4
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 1
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: Effect_Hit_SweepAttack
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 1
|
||||||
|
- skillId: 702006
|
||||||
|
classId: 10101
|
||||||
|
slot: 4
|
||||||
|
label: 돌개 바람
|
||||||
|
charge:
|
||||||
|
prefab: Effect_Howling
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 2
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_WLSwingArc
|
||||||
|
scale: 2
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0.5
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: WindBullet_Hit
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 2
|
||||||
|
- skillId: 703106
|
||||||
|
classId: 10102
|
||||||
|
slot: 1
|
||||||
|
label: 무자비한 일격
|
||||||
|
charge:
|
||||||
|
prefab:
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_Slash_10102_1
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0.6
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: Effect_Hit_10102
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 0
|
||||||
|
- skillId: 703105
|
||||||
|
classId: 10102
|
||||||
|
slot: 2
|
||||||
|
label: 검기 분출
|
||||||
|
charge:
|
||||||
|
prefab: Effect_Casting_Energyball
|
||||||
|
scale: 1
|
||||||
|
anchor: 2
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_Slash_10102_2
|
||||||
|
scale: 1.3
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0.8
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: Effect_Hit_Skill_EnergySlashFlash
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 1
|
||||||
|
- skillId: 703104
|
||||||
|
classId: 10102
|
||||||
|
slot: 3
|
||||||
|
label: 칼날 방패
|
||||||
|
charge:
|
||||||
|
prefab: Effect_Casting_ManaShield
|
||||||
|
scale: 1
|
||||||
|
anchor: 2
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_SwordShield
|
||||||
|
scale: 1.35
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0.8
|
||||||
|
impact:
|
||||||
|
prefab:
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 1
|
||||||
|
- skillId: 702009
|
||||||
|
classId: 10102
|
||||||
|
slot: 4
|
||||||
|
label: 마나 폭발
|
||||||
|
charge:
|
||||||
|
prefab: Effect_Casting_Meteor
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 2
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_STR_EXTRA_SPLASH
|
||||||
|
scale: 1.6
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: Effect_Hit_Energyball
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 2
|
||||||
|
- skillId: 703502
|
||||||
|
classId: 10501
|
||||||
|
slot: 1
|
||||||
|
label: 방패 치기
|
||||||
|
charge:
|
||||||
|
prefab:
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_Slash_10501_1
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0.6
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: Effect_Hit_10501
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 0
|
||||||
|
- skillId: 703503
|
||||||
|
classId: 10501
|
||||||
|
slot: 2
|
||||||
|
label: 강력한 일격
|
||||||
|
charge:
|
||||||
|
prefab: Effect_PCDash
|
||||||
|
scale: 1
|
||||||
|
anchor: 2
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_Slash_10501_2
|
||||||
|
scale: 1.4
|
||||||
|
anchor: 2
|
||||||
|
forwardOffset: 0.8
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: Effect_Hit_SweepAttack
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 1
|
||||||
|
- skillId: 703501
|
||||||
|
classId: 10501
|
||||||
|
slot: 3
|
||||||
|
label: 간식 먹기
|
||||||
|
charge:
|
||||||
|
prefab:
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_Heal
|
||||||
|
scale: 1.3
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab:
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 0
|
||||||
|
- skillId: 702005
|
||||||
|
classId: 10501
|
||||||
|
slot: 4
|
||||||
|
label: 휘몰아치는 파도
|
||||||
|
charge:
|
||||||
|
prefab: Effect_Howling
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 2
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_Slash_10501_3
|
||||||
|
scale: 1.8
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 1
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: WaveShotHit
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 2
|
||||||
|
- skillId: 703505
|
||||||
|
classId: 10502
|
||||||
|
slot: 1
|
||||||
|
label: 십자 가르기
|
||||||
|
charge:
|
||||||
|
prefab:
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_Slash_10502_1
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0.6
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: Effect_Hit_Skill_CrossSlash
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 0
|
||||||
|
- skillId: 703506
|
||||||
|
classId: 10502
|
||||||
|
slot: 2
|
||||||
|
label: 회전 베기
|
||||||
|
charge:
|
||||||
|
prefab:
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_Slash_10502_2
|
||||||
|
scale: 1.5
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 1
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: Effect_Hit_Skill_SpinSlash
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 1
|
||||||
|
- skillId: 703504
|
||||||
|
classId: 10502
|
||||||
|
slot: 3
|
||||||
|
label: 도발
|
||||||
|
charge:
|
||||||
|
prefab: Effect_Howling
|
||||||
|
scale: 1
|
||||||
|
anchor: 2
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: Effect_Aggro
|
||||||
|
scale: 1.3
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 1
|
||||||
|
impact:
|
||||||
|
prefab:
|
||||||
|
scale: 1
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 0
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 1
|
||||||
|
- skillId: 702012
|
||||||
|
classId: 10502
|
||||||
|
slot: 4
|
||||||
|
label: 심판의 빛
|
||||||
|
charge:
|
||||||
|
prefab: Effect_Casting_Meteor
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 2
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
body:
|
||||||
|
prefab: ShineBulletFlash
|
||||||
|
scale: 2
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
impact:
|
||||||
|
prefab: ShineBulletHit
|
||||||
|
scale: 1.2
|
||||||
|
anchor: 1
|
||||||
|
forwardOffset: 0
|
||||||
|
useCasterRotation: 0
|
||||||
|
tint: 1
|
||||||
|
lifetime: 0
|
||||||
|
budgetClass: 2
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bbf70752becd435681b8e555ed827c50
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Loading…
Reference in New Issue