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

497 lines
23 KiB
C#
Raw Permalink Normal View History

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