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

497 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

// ─────────────────────────────────────────────────────────────────────────────
// 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;
}
}
}