Project_WL/Assets/WL/Combat/Weapon/WLGhostStrike.cs

770 lines
54 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.

// ─────────────────────────────────────────────────────────────────────────────
// WLGhostStrike.cs — 스킬 타격 지점마다 PC 공격 자세 실루엣(고스트)을 잇달아 띄운다 — 순간이동하며 마구 베는 연출
//
// PD 원문(#943-⑦ · 2026-10-19): 「단검 궁극 스킬 시전 시 타격 이펙트가 위치한 지점에 캐릭터의 공격 장면이 실루엣처럼 등장시켜서
// 마치 순간이동하며 마구 공격하는 느낌으로 연출해줘.」
//
// ■ 대상 — 단검(비수 1043 · Dagger 10202) 각성 「환영 난무」 Skill_DarkAmbush (rules[].skill = 원본 스킬 클래스 이름 · 다른 스킬은 표에 줄만 더한다)
// ■ 실측(2026-10-19 · 프리팹 YAML · 코드) — 원본 시전 = PC 1.5 초 숨김(Set_Invisible · 애니메이터 정지) · 이펙트 FX_Continuous_Collision(배율 0.7 · 월드 축 고정)의
// 폭발 Exp_01 ~ Exp_11 이 시작 지연 0 ~ 1.0 초(0.1 초 간격)로 PC 둘레 약 3 × 7 m 에 지그재그로 터지고 · 판정 상자(약 6.3 × 10 m)가 0.1 초마다 10 번 켜진다.
// 단검은 WLImpactFx skillHitFx 에 칸이 없어(10201 · 10202 없음) 적 몸에 뜨는 스킬 피격 이펙트가 없다 → 화면의 「타격 이펙트」 = 이 폭발들.
// ■ 자리(anchor) — "fx" = 그 폭발이 터지는 시각 · 자리마다(이름 앞부분 fxPointPrefix · 파티클 startDelay) · "hit" = 이 스킬에 맞은 적 둘레 hitDistance m ·
// 타격마다 직전 방위에서 hitTurnMin ~ Max° 돌려 다른 방위 · 적을 바라봄 · "both" = 둘 다.
// fx 방향 = faceRadius 안 가장 가까운 적 · 없으면 직전 자리 → 이번 자리(순간이동 방향).
// ■ 자세 — poseSource "clips" = 숨은 꼭두각시(PC 복제 → 스크립트 · 충돌체 · 내비 · 소리 제거 → 남은 스크립트 0 확인 뒤에만 켬 · K-DEV-020: 러너 Update 에서만)에
// poses("상태@정규화 시각")를 Animator.Play + Update(0)(이벤트 끔 · 루트 모션 끔)로 걸어 BakeMesh → 자세마다 합친 메시 1 개를 굽고 보관
// (PC · 컨트롤러 · 메시 · poses 가 바뀔 때만 다시 굽는다) · 실패하거나 "live" 면 그 시전 순간 PC 자세 1 개(원본 숨김 중 정지 자세).
// 부품 = 훅 순간(원본 숨김 전) 켜져 있던 SkinnedMeshRenderer + 무기(PCActor.tfs_weapon 아래 MeshFilter) — 경로로 꼭두각시에서 찾는다.
// BakeMesh(useScale false) = 렌더러 위치 · 회전 기준 월드 크기(편집 모드 실측: 루트 배율 2 → 메시 경계 × 2) → 부품 배율 1 로 합친다.
// ■ 그리기 — 공유 재질 1 장(URP Unlit 반투명 · 색 · 알파 = MaterialPropertyBlock) · 풀(maxAlive) · 그림자 · 프로브 끔 · hold 초 유지 뒤 fade 초 동안 알파 → 0.
// ■ 값 = Resources/WL/WLGhostStrike.json (C45) · 끄기 = enabled 0 · 원본 훅 = Skill_DarkAmbush.Set 1줄(OnSet) · 원본 진행 · 피해 그대로
// 🔴 Debug.LogError 금지(원본 ErrorLogHook 팝업) — 실패는 LastLog · 타격 콜백(물리) · 애니메이션 이벤트 안에서는 기록 · 요청만 쌓는다(K-DEV-020)
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Weapon
{
[System.Serializable]
public class WLGhostStrikeRule
{
public string skill = ""; // 원본 스킬 클래스 이름(eEffect) — 원본 Set 훅이 이 이름으로 찾는다
public string anchor = "fx"; // fx = 원본 이펙트 폭발 자리 · hit = 맞은 적 둘레 · both
public string fxPointPrefix = "Exp_"; // fx: 원본 스킬 프리팹 안 폭발 자식 이름 앞부분(시각 = 그 파티클 startDelay)
public float fxLead = 0f; // fx: 폭발보다 이만큼 먼저(+) · 늦게(−) 띄운다(초)
public float faceRadius = 4f; // 이 반경(m) 안 가장 가까운 적을 바라봄 · 없으면 직전 자리 → 이번 자리 방향
public float hitDistance = 0.8f; // hit: 적 위치에서 떨어진 거리(m)
public int hitFromBody = 0; // hit: 1 = 거리를 적 몸 반지름 바깥부터 잰다(WLCombatMotionSettings.BodyRadius)
public float hitTurnMin = 70f; // hit: 같은 적 직전 방위에서 돌리는 각 최소(°) · 좌우 무작위
public float hitTurnMax = 150f; // hit: 최대(°)
public float hitMinInterval = 0.08f; // hit: 같은 적 최소 간격(초)
public float castSeconds = 2f; // 시전 기록 유지(초) — 이 안의 타격만 hit 고스트 · fx 예약도 이 안에서만
// #951 PD 「시전 시 뛰어오르는 잔상 → 사라진 채 공격 → 잔상과 함께 등장 · 낮은 공중에서 떨어져 착지 포즈 · 0.5 m 미끄러지다 정지 · 흙먼지」
public int jumpGhosts = 0; // 시전 순간 위·앞으로 남기는 잔상 수(0 = 없음)
public float jumpHeight = 1f, jumpForward = 0.4f, jumpSeconds = 0.25f;
public string landState = ""; // 착지 애니메이터 상태 이름(비우면 착지 연출 없음)
public float landHeight = 1.2f, landFallSeconds = 0.25f, slideDistance = 0.5f, slideSeconds = 0.3f, landTotalSeconds = 1f;
public float landAtSeconds = 0f; // 공중 클립 종료를 못 잡았을 때 이 시각(시전 뒤 초)에 착지 시작(0 = 클립 감지만)
public string airClip = ""; // 원본 스킬의 공중 클립 이름 조각(예 Skill8_Air) — 이 클립이 보였다가 다른 클립으로 바뀌는 순간 = 공격 끝 → 착지 시작
public int landGhosts = 2; // 떨어지는 동안 잔상 수
public string dustPrefab = ""; public float dustScale = 1f;
public int keepCamera = 0; // #953 1 = 원본 Skill_DarkAmbush 의 탑뷰 전환(RealCamera.Set_TopView)을 건너뛴다 — 카메라 각도 유지
public float fallForward = 0f; // #953 낙하하는 동안 앞으로 가는 거리(m · 대각선으로 미끄러지며 낙하) · 착지 뒤 미끄러짐(slideDistance)은 별도
// #954 PD 「0.5 m 정도 짧은 높이에서 반투명 상태로 갑자기 이펙트와 함께 튀어나오며 랜딩 · 랜딩 시간 짧게 · 랜딩 중 공격 불가」
public string landPose = ""; // 착지 자세("상태@정규화 시각") — 떨어지는 동안 반투명 캐릭터(잔상 재질)로 그리고 실제 모델은 숨긴다(landHideModel)
public int landHideModel = 0; // 1 = 떨어지는 동안 실제 모델 숨김 → 착지 순간 보임(튀어나옴)
public float[] landGhostColor; // 떨어지는 반투명 캐릭터 색(r,g,b,a) · 비우면 잔상 색
public float landGhostHold = 0.02f, landGhostFade = 0.08f; // 매 프레임 띄우는 반투명 캐릭터의 유지·소멸(초)
public int popGhosts = 0; // 착지 순간 착지 자세로 터뜨리는 잔상 수(잔상 색 · 기본 유지·소멸)
public string poseSource = "clips"; // clips = 숨은 꼭두각시에 poses 를 걸어 굽기 · live = 시전 순간 PC 자세 1 개
public string[] poses = new string[0]; // "애니메이터 상태 이름@정규화 시각" — 차례로 돌려 쓴다(시작은 무작위)
}
[System.Serializable]
public class WLGhostStrikeJson
{
public int enabled = 0;
public int maxAlive = 6; // 동시에 보이는 고스트 최대(= 풀 크기)
public float hold = 0.05f; // 나타난 뒤 알파 유지(초)
public float fade = 0.3f; // 그 뒤 알파 → 0(초)
public float[] color = new float[0]; // 실루엣 색 RGBA(A = 시작 알파)
public int includeWeapons = 1; // 1 = 무기(PCActor.tfs_weapon 아래 메시)도 실루엣에
public int zWrite = 1; // prepass 0 일 때만: 1 = 색 패스가 깊이를 씀
public int renderQueue = 3000;
public int prepass = 1; // 1 = 깊이만 쓰는 앞 패스(색 그대로) → 색 패스는 앞면 한 겹만 칠한다(몸 · 팔 겹침이 진해지지 않는 고른 실루엣 · 편집 렌더 확인)
public float prepassMinAlpha = 0.35f; // 알파가 이보다 낮아지면 앞 패스를 끈다(거의 투명한 고스트가 뒤 이펙트를 가리지 않게)
public int prepassQueue = 2990; // 앞 패스 렌더 큐(색 패스보다 먼저)
public string[] shaderNames = new string[0];
public string[] colorProperties = new string[0];
public WLGhostStrikeRule[] rules = new WLGhostStrikeRule[0];
}
public static class WLGhostStrike
{
public const string ResourcesPath = "WL/WLGhostStrike";
// ── 진단(프로브가 읽는다)
public static int Casts, FxSpawned, HitSpawned, SkippedCap, PuppetBuilt, PuppetFailed, PosesBaked, LiveBaked, Errors;
public static float LastBakeMs;
public static string LastLog = "";
public static int Spawned { get { return FxSpawned + HitSpawned; } }
public static int ActiveCount { get { int n = 0; for (int i = 0; i < s_pool.Count; i++) if (s_pool[i].busy) n++; return n; } }
public static int PoseCount { get { return s_poses.Count; } }
struct FxPoint { public Transform t; public float delay; }
struct HitReq { public Actor victim; public float time; }
sealed class Cast
{
public WLGhostStrikeRule rule; public ProjectileBase skill; public Actor shooter;
public float t0, groundY; public List<FxPoint> fx; public int next, poseCursor;
public Vector3 lastPos; public bool hasLast, posesReady; public string sig = "";
// #951 도약 잔상 · 착지
public int jumpDone; public float jumpNext = -1f; public bool wasHidden, sawAir, landing, landed, dustDone, hideDuringFall; public float landT0, modelBaseY, modelY = float.NaN; public int landGhostsDone, hideUntilFrame;
public Vector3 landPos, landFwd; public Transform model; public UnityEngine.AI.NavMeshAgent agent; public object stopWas;
public readonly List<SkinnedMeshRenderer> smrs = new List<SkinnedMeshRenderer>();
public readonly List<MeshFilter> weapons = new List<MeshFilter>();
public readonly List<string> smrPaths = new List<string>();
public readonly List<string> weaponPaths = new List<string>();
}
sealed class Pose { public Mesh body; public readonly List<Mesh> extraMesh = new List<Mesh>(); public readonly List<Matrix4x4> extraM = new List<Matrix4x4>(); }
sealed class Ghost
{
public GameObject root; public MeshFilter bodyF; public MeshRenderer bodyR;
public readonly List<MeshFilter> exF = new List<MeshFilter>(); public readonly List<MeshRenderer> exR = new List<MeshRenderer>();
public readonly List<bool> exSingle = new List<bool>();
public readonly MaterialPropertyBlock mpb = new MaterialPropertyBlock(); public float t0; public bool busy, pre;
public float[] color; public float hold = -1f, fade = -1f; // #954 잔상별 색·수명 덮기(착지 중 반투명 캐릭터)
}
static WLGhostStrikeJson s_cfg;
static readonly Dictionary<string, WLGhostStrikeRule> s_rules = new Dictionary<string, WLGhostStrikeRule>();
static readonly Dictionary<int, List<FxPoint>> s_fxCache = new Dictionary<int, List<FxPoint>>();
static Cast s_cast;
static readonly List<HitReq> s_hitQ = new List<HitReq>(16);
static readonly Dictionary<Actor, float> s_bearing = new Dictionary<Actor, float>(), s_lastHitAt = new Dictionary<Actor, float>();
static readonly List<Pose> s_poses = new List<Pose>();
static string s_poseSig = "";
static readonly List<Ghost> s_pool = new List<Ghost>();
static Material s_mat, s_depthMat; static bool s_matTried;
static Material[] s_matsPre, s_matsOne;
static readonly List<int> s_colorIds = new List<int>();
static WLGhostStrikeRunner s_runner;
static readonly List<CombineInstance> s_combine = new List<CombineInstance>(32);
static readonly List<Mesh> s_tmp = new List<Mesh>(8);
static WLGhostStrikeJson Cfg
{
get
{
if (s_cfg != null) return s_cfg;
s_cfg = new WLGhostStrikeJson();
s_rules.Clear();
var ta = Resources.Load<TextAsset>(ResourcesPath);
if (ta == null) { LastLog = "설정 없음 " + ResourcesPath; return s_cfg; }
try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; }
if (s_cfg.rules != null) foreach (var r in s_cfg.rules) if (r != null && !string.IsNullOrEmpty(r.skill)) s_rules[r.skill] = r;
return s_cfg;
}
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
s_cfg = null; s_cast = null; s_hitQ.Clear(); s_bearing.Clear(); s_lastHitAt.Clear(); s_fxCache.Clear();
s_poses.Clear(); s_poseSig = ""; s_pool.Clear(); s_mat = null; s_depthMat = null; s_matsPre = s_matsOne = null; s_matTried = false; s_runner = null;
Casts = FxSpawned = HitSpawned = SkippedCap = PuppetBuilt = PuppetFailed = PosesBaked = LiveBaked = Errors = 0; LastBakeMs = 0f; LastLog = ""; JumpSpawned = Landings = 0; LandLog = "";
CombatEvents.HitConfirmed.Remove(OnHit);
if (Cfg.enabled == 0) return;
foreach (var r in s_rules.Values) if (r.anchor != "fx") { CombatEvents.HitConfirmed.Add(OnHit); break; }
}
public static string Describe()
{
return "casts " + Casts + " · fx " + FxSpawned + " · hit " + HitSpawned + " · active " + ActiveCount + " · cap-skip " + SkippedCap
+ " · poses " + s_poses.Count + " (puppet " + PuppetBuilt + " fail " + PuppetFailed + " baked " + PosesBaked + " live " + LiveBaked
+ " · " + LastBakeMs.ToString("F1") + " ms) · err " + Errors + " · " + LastLog;
}
// ───────────────────────────────────────── 원본 훅
/// <summary>원본 스킬 Set 훅(1줄) — 표에 있는 스킬이면 시전을 기록하고 부품 경로를 잡는다(이 순간 PC 는 아직 보인다 · 원본 숨김은 훅 뒤 Co_Update).</summary>
/// <summary>#953 PD 「비수 공격 시 왜 카메라 각도가 달라지는가 — 각도를 바꾸지 말고 현재 상태로 착지」 — 원본 Skill_DarkAmbush 가 Set_TopView 전에 묻는다(규칙 keepCamera 1 = 탑뷰 전환 건너뜀 · 원본 훅 2줄).</summary>
public static bool KeepCamera(ProjectileBase skill)
{
try { var c = Cfg; if (skill == null || c.enabled == 0) return false; WLGhostStrikeRule r; return s_rules.TryGetValue(skill.GetType().Name, out r) && r != null && r.keepCamera != 0; }
catch (System.Exception) { return false; }
}
public static void OnSet(ProjectileBase skill)
{
try
{
var c = Cfg;
if (skill == null || c.enabled == 0) return;
WLGhostStrikeRule r;
if (!s_rules.TryGetValue(skill.GetType().Name, out r)) return;
var pd = skill.Get_ProjectTileData();
var shooter = pd != null ? pd.shooter : null;
if (DSUtil.CheckNull(shooter)) return;
var cast = new Cast { rule = r, skill = skill, shooter = shooter, t0 = Time.time, groundY = shooter.Get_position().y, poseCursor = Random.Range(0, 1024), jumpNext = Time.time };
if (r.anchor != "hit") cast.fx = FxPoints(skill, r);
Capture(cast, c);
s_cast = cast;
s_hitQ.Clear(); s_bearing.Clear(); s_lastHitAt.Clear();
Casts++;
if (s_runner == null)
{
var go = new GameObject("[WL943] GhostStrike"); go.hideFlags = HideFlags.HideInHierarchy; Object.DontDestroyOnLoad(go);
s_runner = go.AddComponent<WLGhostStrikeRunner>();
}
LastLog = "시전 " + r.skill + " · fx " + (cast.fx != null ? cast.fx.Count : 0) + " · 부품 " + cast.smrs.Count + "+" + cast.weapons.Count;
}
catch (System.Exception ex) { Errors++; LastLog = "OnSet 실패 — " + ex.Message; }
}
/// <summary>타격 확정(물리 콜백 안) — 이번 시전 스킬이 시전자로 맞힌 것만 요청으로 쌓는다(생성은 러너 Update).</summary>
static void OnHit(in HitConfirmedEvent e)
{
try
{
var cast = s_cast;
if (cast == null || cast.rule.anchor == "fx" || DSUtil.CheckNull(e.victim) || !ReferenceEquals(e.attacker, cast.shooter)) return;
var pd = e.projectile;
var sk = pd != null ? pd.m_SkillListTableData : null;
if (sk == null || sk.e_Skill.ToString() != cast.rule.skill) return;
if (s_hitQ.Count < Mathf.Max(1, Cfg.maxAlive) * 4) s_hitQ.Add(new HitReq { victim = e.victim, time = Time.time });
}
catch (System.Exception ex) { Errors++; LastLog = "hit 실패 — " + ex.Message; }
}
// ───────────────────────────────────────── 러너 틱
internal static void Tick()
{
try
{
var c = Cfg;
float now = Time.time;
var cast = s_cast;
if (cast != null && DSUtil.CheckNull(cast.shooter)) { s_cast = cast = null; }
if (cast != null)
{
if (!cast.posesReady) { cast.posesReady = true; PreparePoses(cast, c); }
// #951 ① 도약 잔상(시전 직후 jumpSeconds 동안 위·앞으로)
var rl = cast.rule;
if (rl.jumpGhosts > 0 && cast.jumpDone < rl.jumpGhosts && now >= cast.jumpNext && cast.jumpNext >= 0f)
{
float k = (cast.jumpDone + 1f) / rl.jumpGhosts; var sp = cast.shooter.Get_position(); var fw = cast.shooter.transform.forward; fw.y = 0f; if (fw.sqrMagnitude < 1e-4f) fw = Vector3.forward; fw.Normalize();
if (Spawn(c, cast, sp + Vector3.up * (rl.jumpHeight * k) + fw * (rl.jumpForward * k), fw)) JumpSpawned++;
cast.jumpDone++; cast.jumpNext = now + rl.jumpSeconds / rl.jumpGhosts;
}
// #951 ② 착지 연출 — 시작 판정은 LateTick(#952-② 다시 보이는 그 프레임에 즉시 · 서 있는 자세가 한 프레임도 안 보이게) · 진행은 여기
if (cast.landing) UpdateLanding(c, cast, now);
if (cast.fx != null)
while (cast.next < cast.fx.Count && now >= cast.t0 + cast.fx[cast.next].delay - cast.rule.fxLead)
{
var p = cast.fx[cast.next++];
if (p.t == null || !p.t.gameObject.activeInHierarchy) continue; // 원본 이펙트가 이미 꺼짐(Off)
var pos = p.t.position; pos.y = cast.groundY;
if (Spawn(c, cast, pos, FaceDir(cast, pos))) FxSpawned++;
cast.lastPos = pos; cast.hasLast = true;
}
for (int i = 0; i < s_hitQ.Count; i++) HitSpawn(c, cast, s_hitQ[i]);
bool landPending = !string.IsNullOrEmpty(cast.rule.landState) && !cast.landed && now - cast.t0 < Mathf.Max(0.1f, cast.rule.castSeconds) + 4f; // #951 착지가 끝날 때까지(상한 +4 s) 시전 기록 유지
if (now - cast.t0 > Mathf.Max(0.1f, cast.rule.castSeconds) && !cast.landing && !landPending) s_cast = null;
}
s_hitQ.Clear();
for (int i = 0; i < s_pool.Count; i++)
{
var g = s_pool[i];
if (!g.busy || g.root == null) continue;
float t = now - g.t0; float hold = g.hold >= 0f ? g.hold : c.hold, fade = g.fade >= 0f ? g.fade : c.fade;
if (t >= hold + fade) { g.busy = false; g.root.SetActive(false); g.color = null; g.hold = g.fade = -1f; continue; }
SetAlpha(g, t <= hold ? 1f : 1f - (t - hold) / Mathf.Max(0.01f, fade));
}
}
catch (System.Exception ex) { Errors++; LastLog = "틱 실패 — " + ex.Message; }
}
// ───────────────────────────────────────── #951 도약 잔상 · 착지 연출
public static int JumpSpawned, Landings, LandGhostFrames; public static string LandLog = "";
/// <summary>#954 「랜딩 중 공격할 수 없어야」 — 이 시전자가 착지 연출 중인가(자동 전투는 IsStop 으로 이미 멈춤 · 수동 공격 키는 이걸로 막는다).</summary>
public static bool IsLanding(Actor a) { var cast = s_cast; return cast != null && cast.landing && a != null && ReferenceEquals(cast.shooter, a); }
static System.Reflection.PropertyInfo s_isStop;
static string CurrentClip(Actor a)
{
var an = a != null ? a.m_animation : null; if (an == null) return "";
var ci = an.GetCurrentAnimatorClipInfo(0); return ci.Length > 0 && ci[0].clip != null ? ci[0].clip.name : "";
}
/// <summary>러너 LateUpdate — 애니메이터가 모델 위치를 쓴 뒤에 낙하 높이를 덮는다(Update 에서 쓰면 되돌아간다 · 실측).
/// #952-② PD 「이펙트가 끝나는 시점에 잠시 캐릭터가 서 있는 상태가 보였다가 착지 → 끝나는 시점에 즉시 착지」 — 착지 시작 판정도 여기서: 원본 Co_Invisible 이 모델을 다시 켜는 순간(코루틴 = Update 뒤 · LateUpdate 앞)을
/// 같은 프레임에 잡아 착지 클립·높이를 적용하고 그 한 프레임은 모델을 숨긴다(이미 그려질 자세 = 서 있는 원본 자세) → 다음 프레임부터 공중 착지 자세. 공중 클립 끝·시각 조건은 예비.</summary>
internal static void LateTick()
{
var cast = s_cast; if (cast == null || DSUtil.CheckNull(cast.shooter)) return;
var rl = cast.rule;
if (!string.IsNullOrEmpty(rl.landState) && !cast.landing && !cast.landed)
{
float now = Time.time; bool hidden = IsHidden(cast); string clip = CurrentClip(cast.shooter);
if (hidden) cast.wasHidden = true;
bool inAir = !string.IsNullOrEmpty(rl.airClip) && clip.Contains(rl.airClip);
if (inAir) cast.sawAir = true;
bool reappeared = cast.wasHidden && !hidden;
bool airEnded = cast.sawAir && !inAir && !hidden && now - cast.t0 > 0.3f;
if (reappeared || airEnded || (rl.landAtSeconds > 0f && now - cast.t0 >= rl.landAtSeconds))
{
StartLanding(cast, now);
cast.modelY = cast.modelBaseY + rl.landHeight;
SetVisible(cast, false); cast.hideUntilFrame = Time.frameCount + 1;
LandLog += reappeared ? " · 보이는 순간" : airEnded ? " · 공중 클립 끝" : " · 시각";
}
}
if (cast.landing && cast.hideUntilFrame > 0 && Time.frameCount >= cast.hideUntilFrame) { cast.hideUntilFrame = 0; if (!cast.hideDuringFall) SetVisible(cast, true); } // #954 landHideModel 이면 착지 순간까지 숨긴 채
if (!cast.landing || cast.model == null || float.IsNaN(cast.modelY)) return;
var lp = cast.model.localPosition; cast.model.localPosition = new Vector3(lp.x, cast.modelY, lp.z);
}
/// <summary>시전자 모델(살갗 메시 + 무기 메시) 보이기·숨기기 — 원본 Model_OnOff 와 같은 렌더러를 쓰되 한 프레임용.</summary>
static void SetVisible(Cast cast, bool on)
{
for (int i = 0; i < cast.smrs.Count; i++) { var r = cast.smrs[i]; if (r != null) r.enabled = on; }
for (int i = 0; i < cast.weapons.Count; i++) { var w = cast.weapons[i]; var r = w != null ? w.GetComponent<Renderer>() : null; if (r != null) r.enabled = on; }
}
/// <summary>#952-④ 직업(무기) 전환 등으로 끊길 때 — 이 시전자의 착지 연출을 접고 멈춤 상태·모델 높이·보이기를 되돌린다.</summary>
public static void CancelFor(Actor a)
{
var cast = s_cast; if (cast == null || a == null || !ReferenceEquals(cast.shooter, a)) return;
if (cast.landing)
{
if (cast.model != null) { var lp = cast.model.localPosition; cast.model.localPosition = new Vector3(lp.x, cast.modelBaseY, lp.z); }
SetVisible(cast, true); SetStop(a, cast.stopWas);
}
cast.landing = false; cast.landed = true; cast.modelY = float.NaN; cast.hideUntilFrame = 0; cast.hideDuringFall = false; s_cast = null; LandLog += " · 취소(직업 전환)";
}
static bool IsHidden(Cast cast)
{
for (int i = 0; i < cast.smrs.Count; i++) { var r = cast.smrs[i]; if (r == null) continue; return !r.enabled || !r.gameObject.activeInHierarchy; }
return false;
}
static void SetStop(Actor a, object v)
{
try
{
if (s_isStop == null) s_isStop = typeof(Actor).GetProperty("IsStop", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public);
if (s_isStop != null && s_isStop.CanWrite && v != null) s_isStop.SetValue(a, v, null);
}
catch (System.Exception) { }
}
static void StartLanding(Cast cast, float now)
{
var pc = cast.shooter as PCActor; var r = cast.rule;
cast.landing = true; cast.landT0 = now; cast.landGhostsDone = 0; cast.dustDone = false; cast.hideDuringFall = r.landHideModel != 0 && s_landPose != null; // #954 착지 자세를 구웠을 때만 실제 모델을 숨긴다
cast.landPos = cast.shooter.Get_position(); cast.landFwd = cast.shooter.transform.forward; cast.landFwd.y = 0f; if (cast.landFwd.sqrMagnitude < 1e-4f) cast.landFwd = Vector3.forward; cast.landFwd.Normalize();
cast.model = cast.shooter.m_animation != null ? cast.shooter.m_animation.transform : null; cast.modelBaseY = cast.model != null ? cast.model.localPosition.y : 0f;
cast.agent = cast.shooter.GetComponent<UnityEngine.AI.NavMeshAgent>();
if (pc != null) { try { if (s_isStop == null) s_isStop = typeof(Actor).GetProperty("IsStop", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); cast.stopWas = s_isStop != null ? s_isStop.GetValue(pc, null) : null; } catch (System.Exception) { } SetStop(pc, true); pc.WL_StopNavMove(); }
try { if (cast.shooter.m_animation != null) { cast.shooter.m_animation.speed = 1f; cast.shooter.m_animation.CrossFade(r.landState, 0f, 0, 0f); } } catch (System.Exception ex) { LandLog = "착지 클립 실패 — " + ex.Message; } // #952-② 섞임 0 = 서 있는 자세에서 번지지 않게
if (cast.model != null) cast.model.localPosition = new Vector3(cast.model.localPosition.x, cast.modelBaseY + r.landHeight, cast.model.localPosition.z);
Landings++; LandLog = "착지 시작 t+" + (now - cast.t0).ToString("F2") + " @" + cast.landPos.ToString("F2") + (cast.agent != null ? " agent" : " agent 없음");
}
static void UpdateLanding(WLGhostStrikeJson c, Cast cast, float now)
{
var r = cast.rule; float t = now - cast.landT0; float fall = Mathf.Max(0.05f, r.landFallSeconds);
if (DSUtil.CheckNull(cast.shooter)) { cast.landing = false; cast.landed = true; return; }
if (t < fall)
{
float k = 1f - t / fall; float y = r.landHeight * k * k; // 가속 낙하(실제 적용은 LateTick)
cast.modelY = cast.modelBaseY + y;
if (cast.landGhostsDone < r.landGhosts && t >= fall * (cast.landGhostsDone + 1f) / (r.landGhosts + 1f)) { cast.landGhostsDone++; if (Spawn(c, cast, cast.landPos + Vector3.up * y, cast.landFwd)) JumpSpawned++; }
if (cast.hideDuringFall && s_landPose != null) { if (Spawn(c, cast, cast.shooter.Get_position() + Vector3.up * y, cast.landFwd, s_landPose, r.landGhostColor, r.landGhostHold, r.landGhostFade)) LandGhostFrames++; } // #954 떨어지는 동안 = 반투명 캐릭터(매 프레임)
if (r.fallForward > 0f) // #953 대각선 낙하 — 떨어지는 동안 앞으로 fallForward(등속) · 착지 뒤엔 slide 감속
{
float step = r.fallForward * Time.deltaTime / fall;
if (cast.agent != null && cast.agent.enabled && cast.agent.isOnNavMesh) cast.agent.Move(cast.landFwd * step); else cast.shooter.transform.position += cast.landFwd * step;
}
return;
}
cast.modelY = cast.modelBaseY;
if (!cast.dustDone)
{
cast.dustDone = true;
if (cast.hideDuringFall) { cast.hideDuringFall = false; SetVisible(cast, true); } // #954 착지 순간 실제 모델이 튀어나온다
if (r.popGhosts > 0 && s_landPose != null) for (int i = 0; i < r.popGhosts; i++) { var jitter = Quaternion.Euler(0f, (i - (r.popGhosts - 1) * 0.5f) * 18f, 0f) * cast.landFwd; if (Spawn(c, cast, cast.shooter.Get_position() + jitter * 0.12f * (i + 1), jitter, s_landPose)) JumpSpawned++; }
if (!string.IsNullOrEmpty(r.dustPrefab))
{
var p = Resources.Load<GameObject>(r.dustPrefab);
if (p != null) { var go = Object.Instantiate(p, cast.landPos, Quaternion.LookRotation(cast.landFwd, Vector3.up)); go.transform.localScale = p.transform.localScale * r.dustScale; foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; ps.Play(true); } Object.Destroy(go, 3f); }
else LandLog = "먼지 프리팹 없음 " + r.dustPrefab;
}
}
float slideT = t - fall;
if (slideT < r.slideSeconds && r.slideDistance > 0f)
{
// #952-② PD 「떨어진 후 미끄러지듯 살짝 앞으로 전진하다 멈추게」 — 속도를 처음 2D/T 에서 0 으로 선형 감속(총 이동 = slideDistance · 등속 뒤 급정지였음)
float T = Mathf.Max(0.05f, r.slideSeconds); float k = Mathf.Clamp01(slideT / T);
float step = r.slideDistance * (2f / T) * (1f - k) * Time.deltaTime;
if (cast.agent != null && cast.agent.enabled && cast.agent.isOnNavMesh) cast.agent.Move(cast.landFwd * step);
else cast.shooter.transform.position += cast.landFwd * step;
}
if (t >= r.landTotalSeconds)
{
cast.landing = false; cast.landed = true; cast.modelY = float.NaN;
if (cast.model != null) { var lp = cast.model.localPosition; cast.model.localPosition = new Vector3(lp.x, cast.modelBaseY, lp.z); }
var pc = cast.shooter as PCActor;
SetStop(cast.shooter, cast.stopWas);
try { if (pc != null) pc.Play_Idle(); } catch (System.Exception) { }
LandLog += " · 끝 " + t.ToString("F2") + " s · 이동 " + Vector3.Distance(cast.landPos, cast.shooter.Get_position()).ToString("F2") + " m";
}
}
static List<FxPoint> FxPoints(ProjectileBase skill, WLGhostStrikeRule r)
{
List<FxPoint> list;
int id = skill.GetInstanceID();
if (s_fxCache.TryGetValue(id, out list) && (list.Count == 0 || list[0].t != null)) return list;
list = new List<FxPoint>();
if (!string.IsNullOrEmpty(r.fxPointPrefix))
foreach (var t in skill.GetComponentsInChildren<Transform>(true))
{
if (!t.name.StartsWith(r.fxPointPrefix, System.StringComparison.Ordinal)) continue;
var ps = t.GetComponent<ParticleSystem>();
list.Add(new FxPoint { t = t, delay = ps != null ? ps.main.startDelay.constantMax : 0f });
}
list.Sort((a, b) => a.delay.CompareTo(b.delay));
s_fxCache[id] = list;
return list;
}
static Vector3 FaceDir(Cast cast, Vector3 pos)
{
var best = Vector3.zero; float bestD = float.MaxValue;
var info = ActorInfo.Ins;
if (info != null && cast.rule.faceRadius > 0f)
{
var list = info.Get_EnemyActors(cast.shooter.IsEnemy(), pos, cast.rule.faceRadius);
try
{
for (int i = 0; i < list.Count; i++)
{
var a = list[i]; if (a == null) continue;
var d = a.Get_position() - pos; d.y = 0f; float m = d.sqrMagnitude;
if (m > 1e-4f && m < bestD) { bestD = m; best = d; }
}
}
finally { info.Release_Actors(list); }
}
if (best.sqrMagnitude < 1e-4f && cast.hasLast) { best = pos - cast.lastPos; best.y = 0f; }
if (best.sqrMagnitude < 1e-4f) { best = cast.shooter.transform.forward; best.y = 0f; }
return best.sqrMagnitude < 1e-4f ? Vector3.forward : best.normalized;
}
static void HitSpawn(WLGhostStrikeJson c, Cast cast, HitReq q)
{
var r = cast.rule; var v = q.victim;
if (DSUtil.CheckNull(v) || v.IsDead()) return;
float last;
if (s_lastHitAt.TryGetValue(v, out last) && q.time - last < r.hitMinInterval) return;
s_lastHitAt[v] = q.time;
float b;
if (!s_bearing.TryGetValue(v, out b)) b = Random.Range(0f, 360f);
else b += Random.Range(r.hitTurnMin, r.hitTurnMax) * (Random.value < 0.5f ? -1f : 1f);
s_bearing[v] = b;
var dir = Quaternion.Euler(0f, b, 0f) * Vector3.forward;
var center = v.Get_position();
float dist = r.hitDistance + (r.hitFromBody != 0 ? WL.Combat.WLCombatMotionSettings.BodyRadius(v) : 0f);
var pos = center + dir * dist; pos.y = center.y;
if (Spawn(c, cast, pos, -dir)) HitSpawned++;
}
// ───────────────────────────────────────── 부품 · 자세
/// <summary>훅 순간(PC 가 보일 때) 켜져 있는 부품의 경로 · 서명(PC · 컨트롤러 · 메시 · poses).</summary>
static void Capture(Cast cast, WLGhostStrikeJson c)
{
var pc = cast.shooter; var root = pc.transform;
var sig = new System.Text.StringBuilder();
sig.Append(pc.GetInstanceID()).Append('|').Append(pc.m_animation != null && pc.m_animation.runtimeAnimatorController != null ? pc.m_animation.runtimeAnimatorController.GetInstanceID() : 0);
foreach (var r in pc.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (r.sharedMesh == null || !r.enabled || !r.gameObject.activeInHierarchy) continue;
var path = RelPath(root, r.transform); if (path == null) continue;
cast.smrs.Add(r); cast.smrPaths.Add(path); sig.Append('|').Append(path).Append(':').Append(r.sharedMesh.GetInstanceID());
}
var pca = pc as PCActor;
if (c.includeWeapons != 0 && pca != null && pca.tfs_weapon != null)
foreach (var sock in pca.tfs_weapon)
{
if (sock == null) continue;
foreach (var mf in sock.GetComponentsInChildren<MeshFilter>(true))
{
var mr = mf.GetComponent<MeshRenderer>();
if (mf.sharedMesh == null || mr == null || !mr.enabled || !mf.gameObject.activeInHierarchy) continue;
var path = RelPath(root, mf.transform); if (path == null) continue;
cast.weapons.Add(mf); cast.weaponPaths.Add(path); sig.Append("|w").Append(path).Append(':').Append(mf.sharedMesh.GetInstanceID());
}
}
sig.Append('|').Append(cast.rule.poseSource).Append('|').Append(cast.rule.poses != null ? string.Join(",", cast.rule.poses) : "");
cast.sig = sig.ToString();
}
static void PreparePoses(Cast cast, WLGhostStrikeJson c)
{
float t0 = Time.realtimeSinceStartup;
var r = cast.rule;
bool clips = r.poseSource == "clips" && r.poses != null && r.poses.Length > 0 && cast.shooter is PCActor;
if (clips && cast.sig == s_poseSig && s_poses.Count > 0) return; // 보관한 자세 그대로
ClearPoses();
if (clips && BakeClips(cast)) s_poseSig = cast.sig;
else
{
ClearPoses(); // 꼭두각시 도중 실패면 일부 구운 자세를 버리고 live 1 개로
var p = BakePose(cast.shooter.transform, cast.smrs, cast.weapons); // 원본 숨김 중 정지 자세(렌더러가 꺼져 있어도 BakeMesh 됨 · 편집 모드 실측)
if (p != null) { s_poses.Add(p); LiveBaked++; }
}
LastBakeMs = (Time.realtimeSinceStartup - t0) * 1000f;
}
/// <summary>숨은 꼭두각시(PC 복제)에 poses 를 걸어 굽는다 — 러너 Update 에서만(DestroyImmediate). 스크립트 · 충돌체가 남으면 켜지 않고 버린다.</summary>
static bool BakeClips(Cast cast)
{
var pc = cast.shooter;
GameObject holder = null;
try
{
holder = new GameObject("WL_GhostPuppet"); holder.hideFlags = HideFlags.HideInHierarchy; holder.SetActive(false);
var go = Object.Instantiate(pc.gameObject, holder.transform); // 비활성 부모 아래 = Awake · OnEnable 안 돈다
string err = null;
for (int pass = 0; pass < 3; pass++)
{
var mbs = go.GetComponentsInChildren<MonoBehaviour>(true);
for (int i = mbs.Length - 1; i >= 0; i--) { if (mbs[i] == null) continue; try { Object.DestroyImmediate(mbs[i]); } catch (System.Exception ex) { err = ex.Message; } }
}
Strip<UnityEngine.AI.NavMeshAgent>(go, ref err);
Strip<Joint>(go, ref err); // 강체에 붙은 관절이 남으면 강체를 못 뗀다
Strip<Rigidbody>(go, ref err);
Strip<Collider>(go, ref err);
Strip<AudioSource>(go, ref err);
Strip<AudioListener>(go, ref err); // 켜는 순간 두 번째 리스너 경고 방지
int leftMb = go.GetComponentsInChildren<MonoBehaviour>(true).Length, leftCol = go.GetComponentsInChildren<Collider>(true).Length;
if (leftMb > 0 || leftCol > 0) { PuppetFailed++; LastLog = "꼭두각시 실패 — 남은 스크립트 " + leftMb + " · 충돌체 " + leftCol + (err != null ? " · " + err : ""); return false; }
var an = go.GetComponent<Animator>();
if (an == null || an.runtimeAnimatorController == null) { PuppetFailed++; LastLog = "꼭두각시 애니메이터 없음"; return false; }
an.fireEvents = false; an.applyRootMotion = false; an.cullingMode = AnimatorCullingMode.AlwaysAnimate; an.speed = 1f;
var gt = go.transform;
gt.localPosition = Vector3.zero; gt.localRotation = Quaternion.identity; gt.localScale = pc.transform.lossyScale;
var smrs = new List<SkinnedMeshRenderer>(); var weapons = new List<MeshFilter>();
foreach (var p in cast.smrPaths) { var x = Find(gt, p); var s = x != null ? x.GetComponent<SkinnedMeshRenderer>() : null; if (s != null) smrs.Add(s); }
foreach (var p in cast.weaponPaths) { var x = Find(gt, p); var m = x != null ? x.GetComponent<MeshFilter>() : null; if (m != null) weapons.Add(m); }
if (smrs.Count == 0) { PuppetFailed++; LastLog = "꼭두각시 부품 없음"; return false; }
holder.SetActive(true); // 남은 스크립트 0 확인 뒤에만 켠다 · 이 함수 끝에서 끄고 지운다(그 사이 그려지지 않음)
an.Rebind(); an.Update(0f);
foreach (var spec in cast.rule.poses)
{
string state; float nt;
if (!ParsePose(spec, out state, out nt)) continue;
int h = Animator.StringToHash(state);
if (!an.HasState(0, h)) { LastLog = "상태 없음 " + state; continue; }
an.Play(h, 0, nt); an.Update(0f);
var pose = BakePose(gt, smrs, weapons);
if (pose != null) { s_poses.Add(pose); PosesBaked++; }
}
// #954 착지 자세 1 개(순환 목록 밖) — 떨어지는 동안의 반투명 캐릭터 · 착지 순간 튀어나오는 잔상
if (s_landPose != null && s_landPose.body != null) Object.Destroy(s_landPose.body);
s_landPose = null;
if (!string.IsNullOrEmpty(cast.rule.landPose))
{
string ls; float lnt;
if (ParsePose(cast.rule.landPose, out ls, out lnt) && an.HasState(0, Animator.StringToHash(ls))) { an.Play(Animator.StringToHash(ls), 0, lnt); an.Update(0f); s_landPose = BakePose(gt, smrs, weapons); }
else LastLog = "착지 자세 상태 없음 " + cast.rule.landPose;
}
PuppetBuilt++;
return s_poses.Count > 0;
}
catch (System.Exception ex) { Errors++; PuppetFailed++; LastLog = "꼭두각시 예외 — " + ex.Message; return false; }
finally { if (holder != null) { holder.SetActive(false); Object.Destroy(holder); } }
}
/// <summary>부품을 root 회전 기준 · 월드 단위로 합친 메시 1 개(+ 읽을 수 없는 무기 메시는 따로 그릴 목록).</summary>
static Pose BakePose(Transform root, List<SkinnedMeshRenderer> smrs, List<MeshFilter> weapons)
{
var rootPos = root.position; var inv = Quaternion.Inverse(root.rotation);
s_combine.Clear(); s_tmp.Clear();
var pose = new Pose();
int verts = 0;
for (int i = 0; i < smrs.Count; i++)
{
var r = smrs[i];
if (r == null || r.sharedMesh == null) continue;
var m = new Mesh(); r.BakeMesh(m); s_tmp.Add(m); // useScale false = 렌더러 위치 · 회전 기준 월드 크기
var t = r.transform; var mx = Matrix4x4.TRS(inv * (t.position - rootPos), inv * t.rotation, Vector3.one);
for (int s = 0; s < m.subMeshCount; s++) s_combine.Add(new CombineInstance { mesh = m, subMeshIndex = s, transform = mx });
verts += m.vertexCount;
}
for (int i = 0; i < weapons.Count; i++)
{
var mf = weapons[i];
if (mf == null || mf.sharedMesh == null) continue;
var t = mf.transform; var mesh = mf.sharedMesh;
var mx = Matrix4x4.TRS(inv * (t.position - rootPos), inv * t.rotation, t.lossyScale);
if (mesh.isReadable) { for (int s = 0; s < mesh.subMeshCount; s++) s_combine.Add(new CombineInstance { mesh = mesh, subMeshIndex = s, transform = mx }); verts += mesh.vertexCount; }
else { pose.extraMesh.Add(mesh); pose.extraM.Add(mx); } // 읽을 수 없는 메시(빌드에서 Read/Write 끔)는 합치지 않고 따로 그린다
}
if (s_combine.Count > 0)
{
var body = new Mesh { name = "__WLGhostStrikePose" };
if (verts > 65000) body.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
body.CombineMeshes(s_combine.ToArray(), true, true);
body.UploadMeshData(true); // GPU 로 올리고 CPU 사본은 버린다(그리기만 한다)
pose.body = body;
}
for (int i = 0; i < s_tmp.Count; i++) Object.Destroy(s_tmp[i]);
s_tmp.Clear(); s_combine.Clear();
return pose.body != null || pose.extraMesh.Count > 0 ? pose : null;
}
static void ClearPoses()
{
for (int i = 0; i < s_poses.Count; i++) if (s_poses[i].body != null) Object.Destroy(s_poses[i].body);
s_poses.Clear(); s_poseSig = "";
}
static void Strip<T>(GameObject go, ref string err) where T : Component
{
foreach (var x in go.GetComponentsInChildren<T>(true)) { try { Object.DestroyImmediate(x); } catch (System.Exception ex) { err = ex.Message; } }
}
static bool ParsePose(string spec, out string state, out float nt)
{
state = ""; nt = 0f;
if (string.IsNullOrEmpty(spec)) return false;
int at = spec.LastIndexOf('@');
state = (at >= 0 ? spec.Substring(0, at) : spec).Trim();
if (at >= 0 && !float.TryParse(spec.Substring(at + 1).Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out nt)) return false;
return state.Length > 0;
}
static string RelPath(Transform root, Transform t)
{
if (t == root) return "";
var names = new List<string>();
for (var x = t; x != root; x = x.parent) { if (x == null) return null; names.Add(x.name); }
names.Reverse();
return string.Join("/", names.ToArray());
}
static Transform Find(Transform root, string path) { return string.IsNullOrEmpty(path) ? root : root.Find(path); }
// ───────────────────────────────────────── 고스트 · 재질
static Pose s_landPose; // #954 착지 자세(규칙 landPose) — 반투명 캐릭터 · 튀어나오는 잔상용(순환 목록 밖)
static bool Spawn(WLGhostStrikeJson c, Cast cast, Vector3 pos, Vector3 face, Pose poseOverride = null, float[] color = null, float hold = -1f, float fade = -1f)
{
if ((s_poses.Count == 0 && poseOverride == null) || !EnsureMaterial(c)) return false;
var g = Rent(c);
if (g == null) { SkippedCap++; return false; }
var pose = poseOverride ?? s_poses[(cast.poseCursor++ & 0x7fffffff) % s_poses.Count];
g.color = color; g.hold = hold; g.fade = fade;
g.pre = s_matsPre != null;
g.bodyF.sharedMesh = pose.body;
g.bodyR.sharedMaterials = g.pre ? s_matsPre : s_matsOne;
while (g.exF.Count < pose.extraMesh.Count) { MeshFilter f; MeshRenderer mr; Part(g.root.transform, out f, out mr); g.exF.Add(f); g.exR.Add(mr); g.exSingle.Add(true); }
for (int i = 0; i < g.exF.Count; i++)
{
bool on = i < pose.extraMesh.Count;
g.exF[i].gameObject.SetActive(on);
if (!on) continue;
var mesh = pose.extraMesh[i]; var m = pose.extraM[i];
g.exF[i].sharedMesh = mesh;
g.exSingle[i] = mesh.subMeshCount <= 1;
if (g.exSingle[i]) g.exR[i].sharedMaterials = g.pre ? s_matsPre : s_matsOne; // 부분 메시 1 개 = 남는 재질이 같은 메시를 한 번 더 그린다(앞 패스 → 색)
else { var mats = new Material[mesh.subMeshCount]; for (int k = 0; k < mats.Length; k++) mats[k] = s_mat; g.exR[i].sharedMaterials = mats; }
var tr = g.exF[i].transform; tr.localPosition = m.GetColumn(3); tr.localRotation = m.rotation; tr.localScale = m.lossyScale;
}
g.root.transform.SetPositionAndRotation(pos, Quaternion.LookRotation(face, Vector3.up));
g.t0 = Time.time; g.busy = true;
SetAlpha(g, 1f);
g.root.SetActive(true);
return true;
}
static Ghost Rent(WLGhostStrikeJson c)
{
for (int i = s_pool.Count - 1; i >= 0; i--) if (s_pool[i].root == null) s_pool.RemoveAt(i); // 씬 · Play 경계에서 파괴된 것
for (int i = 0; i < s_pool.Count; i++) if (!s_pool[i].busy) return s_pool[i];
if (s_pool.Count >= Mathf.Max(1, c.maxAlive)) return null;
var g = new Ghost();
g.root = new GameObject("__WLGhostStrike" + s_pool.Count);
Object.DontDestroyOnLoad(g.root);
g.root.SetActive(false);
Part(g.root.transform, out g.bodyF, out g.bodyR);
s_pool.Add(g);
return g;
}
static void Part(Transform parent, out MeshFilter f, out MeshRenderer r)
{
var go = new GameObject("part");
go.transform.SetParent(parent, false);
f = go.AddComponent<MeshFilter>();
r = go.AddComponent<MeshRenderer>();
r.sharedMaterials = s_matsPre ?? s_matsOne ?? new[] { s_mat };
r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; r.receiveShadows = false;
r.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; r.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off;
}
static void SetAlpha(Ghost g, float k)
{
var a = g.color != null && g.color.Length >= 4 ? g.color : Cfg.color; // #954 잔상별 색 덮기
var col = a != null && a.Length >= 4 ? new Color(a[0], a[1], a[2], a[3]) : Color.black;
col.a *= Mathf.Clamp01(k);
g.mpb.Clear();
for (int i = 0; i < s_colorIds.Count; i++) g.mpb.SetColor(s_colorIds[i], col);
bool pre = s_matsPre != null && col.a >= Cfg.prepassMinAlpha; // 거의 투명해지면 앞 패스를 끈다(뒤 이펙트를 가리지 않게)
if (pre != g.pre)
{
g.pre = pre;
var arr = pre ? s_matsPre : s_matsOne;
g.bodyR.sharedMaterials = arr;
for (int i = 0; i < g.exR.Count; i++) if (g.exSingle[i] && g.exR[i].gameObject.activeSelf) g.exR[i].sharedMaterials = arr;
}
g.bodyR.SetPropertyBlock(g.mpb);
for (int i = 0; i < g.exR.Count; i++) if (g.exR[i].gameObject.activeSelf) g.exR[i].SetPropertyBlock(g.mpb);
}
/// <summary>런타임 재질 1 장(에셋 0) — URP Unlit 반투명 배관 값(DashAfterImage · WLPartSkillClone 과 같은 방식).</summary>
static bool EnsureMaterial(WLGhostStrikeJson c)
{
if (s_mat != null) return true;
if (s_matTried) return false;
s_matTried = true;
Shader sh = null;
if (c.shaderNames != null) foreach (var n in c.shaderNames) { if (string.IsNullOrEmpty(n)) continue; sh = Shader.Find(n); if (sh != null) break; }
if (sh == null) { LastLog = "셰이더 없음"; return false; }
bool pre = c.prepass != 0;
var m = NewMat(sh, "__WLGhostStrikeMat", UnityEngine.Rendering.BlendMode.SrcAlpha, UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha,
!pre && c.zWrite != 0, c.renderQueue > 0 ? c.renderQueue : (int)UnityEngine.Rendering.RenderQueue.Transparent);
s_colorIds.Clear();
if (c.colorProperties != null) foreach (var p in c.colorProperties) if (!string.IsNullOrEmpty(p) && m.HasProperty(p)) s_colorIds.Add(Shader.PropertyToID(p));
s_mat = m;
s_matsOne = new[] { m };
if (pre)
{
// 깊이만 쓰는 앞 패스 = 색 그대로(Blend Zero One) · ZWrite On · 색 패스보다 앞 큐 → 색 패스(ZTest LEqual)는 가장 앞 면만 한 번 칠한다
s_depthMat = NewMat(sh, "__WLGhostStrikeDepth", UnityEngine.Rendering.BlendMode.Zero, UnityEngine.Rendering.BlendMode.One, true, c.prepassQueue);
s_matsPre = new[] { s_depthMat, m };
}
for (int i = 0; i < s_pool.Count; i++) if (s_pool[i].bodyR != null) s_pool[i].bodyR.sharedMaterials = s_matsPre ?? s_matsOne;
return true;
}
static Material NewMat(Shader sh, string name, UnityEngine.Rendering.BlendMode src, UnityEngine.Rendering.BlendMode dst, bool zWrite, int queue)
{
var m = new Material(sh) { name = name };
if (m.HasProperty("_Surface")) m.SetFloat("_Surface", 1f);
if (m.HasProperty("_Blend")) m.SetFloat("_Blend", 0f);
if (m.HasProperty("_SrcBlend")) m.SetFloat("_SrcBlend", (float)src);
if (m.HasProperty("_DstBlend")) m.SetFloat("_DstBlend", (float)dst);
if (m.HasProperty("_SrcBlendAlpha")) m.SetFloat("_SrcBlendAlpha", (float)(src == UnityEngine.Rendering.BlendMode.Zero ? UnityEngine.Rendering.BlendMode.Zero : UnityEngine.Rendering.BlendMode.One));
if (m.HasProperty("_DstBlendAlpha")) m.SetFloat("_DstBlendAlpha", (float)dst);
if (m.HasProperty("_ZWrite")) m.SetFloat("_ZWrite", zWrite ? 1f : 0f);
if (m.HasProperty("_AlphaClip")) m.SetFloat("_AlphaClip", 0f);
m.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
m.DisableKeyword("_ALPHATEST_ON");
m.SetOverrideTag("RenderType", "Transparent");
m.renderQueue = queue;
return m;
}
}
/// <summary>#943-⑦ 고스트 러너(숨김 GameObject 1 개) — 예약 · 요청 생성 · 페이드 · 꼭두각시 굽기는 여기(Update)에서만.</summary>
public sealed class WLGhostStrikeRunner : MonoBehaviour { void Update() { WLGhostStrike.Tick(); } void LateUpdate() { WLGhostStrike.LateTick(); } }
}