2026-10-18 18:48:42 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// 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 예약도 이 안에서만
2026-10-20 10:01:04 +00:00
// #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 = 클립 감지만)
2026-10-21 14:13:56 +00:00
public int invisibleMatchFx ; // #965 1 = 원본 숨김 시간(Set_Invisible)을 이 시전 이펙트가 끝나는 시각(켜진 렌더러 입자계의 시작 지연 + 마지막 분출 + 수명 최댓값)으로
public float invisibleExtra ; // #965 위 시각에 더할 초(0 = 이펙트 끝과 동시에 등장)
2026-10-20 10:01:04 +00:00
public string airClip = "" ; // 원본 스킬의 공중 클립 이름 조각(예 Skill8_Air) — 이 클립이 보였다가 다른 클립으로 바뀌는 순간 = 공격 끝 → 착지 시작
public int landGhosts = 2 ; // 떨어지는 동안 잔상 수
public string dustPrefab = "" ; public float dustScale = 1f ;
2026-10-20 14:21:09 +00:00
public int keepCamera = 0 ; // #953 1 = 원본 Skill_DarkAmbush 의 탑뷰 전환(RealCamera.Set_TopView)을 건너뛴다 — 카메라 각도 유지
2026-10-21 04:56:16 +00:00
// #959 PD 「각성 시전 후 공중에서 떨어지지 말고 옆에서 튀어나와 정지하는 것처럼」 — entrySide ≠ 0: 다시 보이는 순간 최종 자리에서 PC 오른쪽(+ · 왼쪽 − )으로 entrySide m 떨어진 곳에 워프한 뒤
// landFallSeconds 동안 감속하며 최종 자리로 미끄러져 들어온다(그동안 landPose 반투명 캐릭터 · landHeight 0 이면 수직 이동 없음) · jumpSide: 시전 순간 잔상이 옆으로도 빠진다(m · 같은 부호 = 같은 쪽)
public float entrySide = 0f , jumpSide = 0f ;
2026-10-20 14:21:09 +00:00
public float fallForward = 0f ; // #953 낙하하는 동안 앞으로 가는 거리(m · 대각선으로 미끄러지며 낙하) · 착지 뒤 미끄러짐(slideDistance)은 별도
2026-10-20 16:54:40 +00:00
// #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 ; // 착지 순간 착지 자세로 터뜨리는 잔상 수(잔상 색 · 기본 유지·소멸)
2026-10-21 05:29:56 +00:00
// #960 PD 「각성 마지막 랜딩 때 그림자는 옆에서 등장하는데 캐릭터는 공중에서 낙하 → 캐릭터를 양손 베기 모션 상태로 그림자처럼 옆에서 등장하며 랜딩」 —
// landStateStart = 착지 상태를 이 정규화 시각부터(양손 베기 클립의 베는 구간이 옆 진입 동안 보이게) · landNoEvents 1 = 착지 동안 애니메이션 이벤트 끔(4타 클립의 순간이동 · 카메라 고정 · 타격이 착지에 끼지 않게) ·
// entryGhostInterval > 0 = 옆에서 들어오는 동안(실제 모델이 보일 때) 이 간격으로 지금 자세 잔상(landGhostColor · landGhostHold · landGhostFade) = 그림자처럼 지나온 길
public float landStateStart = 0f , entryGhostInterval = 0f ;
2026-10-21 07:56:27 +00:00
public float popGhostStep = 0.18f ; // #961 끝 잔상 간격(m · 캐릭터 뒤로 이어짐)
public string popGhostDir = "back" ; // #961 끝 잔상 방향 — back = 캐릭터 등 쪽(정면 반대 · 이전 = 정면) · entry = 옆 진입으로 들어온 길 쪽
// #961 PD 「비수 각성 시전 중 피격 대상이 된 몬스터 주위에 기존 비수 주력 이펙트(붉은 빛의 회전하는 검기들)도 함께 재생」 —
// hitFxPrefab = 이 스킬에 맞은 적마다 몸 중심에 띄우는 이펙트(Resources 경로) · hitFxScale 배율 · hitFxYOffset 높이(m) ·
// hitFxInterval = 같은 적에게 다시 띄우는 간격(초 · 맞는 동안 이어 보이게) · hitFxLife = 띄운 뒤 지우는 초 · hitFxFollow 1 = 밀려나는 적을 따라감(위치만)
public string hitFxPrefab = "" ;
public float hitFxScale = 0.5f , hitFxYOffset = 0f , hitFxInterval = 0.45f , hitFxLife = 1.2f ;
public int hitFxFollow = 1 ;
2026-10-21 05:29:56 +00:00
public int landNoEvents = 1 ;
2026-10-18 18:48:42 +00:00
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 = "" ;
2026-10-20 10:01:04 +00:00
// #951 도약 잔상 · 착지
2026-10-20 16:54:40 +00:00
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 ;
2026-10-21 04:56:16 +00:00
public Vector3 landPos , landFwd , landRight ; public float entryMoved ; public Transform model ; public UnityEngine . AI . NavMeshAgent agent ; public object stopWas ; // #959 landRight · entryMoved = 옆에서 들어오기
2026-10-21 05:29:56 +00:00
public bool eventsWas = true , eventsOff ; public float nextEntryGhost ; // #960 착지 동안 애니메이션 이벤트 끔 · 옆 진입 잔상 다음 시각
2026-10-18 18:48:42 +00:00
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 ;
2026-10-20 16:54:40 +00:00
public float [ ] color ; public float hold = - 1f , fade = - 1f ; // #954 잔상별 색·수명 덮기(착지 중 반투명 캐릭터)
2026-10-18 18:48:42 +00:00
}
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 ;
2026-10-21 07:56:27 +00:00
static readonly List < HitReq > s_hitQ = new List < HitReq > ( 16 ) , s_fxQ = new List < HitReq > ( 16 ) ; // #961 s_fxQ = 피격 대상 이펙트 요청
static readonly Dictionary < Actor , float > s_bearing = new Dictionary < Actor , float > ( ) , s_lastHitAt = new Dictionary < Actor , float > ( ) , s_lastFxAt = new Dictionary < Actor , float > ( ) ;
public static int HitFxSpawned ; public static string HitFxLog = "" ;
2026-10-18 18:48:42 +00:00
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 ( )
{
2026-10-21 07:56:27 +00:00
s_cfg = null ; s_cast = null ; s_hitQ . Clear ( ) ; s_bearing . Clear ( ) ; s_lastHitAt . Clear ( ) ; s_fxCache . Clear ( ) ; s_fxQ . Clear ( ) ; s_lastFxAt . Clear ( ) ; HitFxSpawned = 0 ; HitFxLog = "" ;
2026-10-18 18:48:42 +00:00
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 ;
2026-10-20 10:01:04 +00:00
Casts = FxSpawned = HitSpawned = SkippedCap = PuppetBuilt = PuppetFailed = PosesBaked = LiveBaked = Errors = 0 ; LastBakeMs = 0f ; LastLog = "" ; JumpSpawned = Landings = 0 ; LandLog = "" ;
2026-10-18 18:48:42 +00:00
CombatEvents . HitConfirmed . Remove ( OnHit ) ;
if ( Cfg . enabled = = 0 ) return ;
2026-10-21 07:56:27 +00:00
foreach ( var r in s_rules . Values ) if ( r . anchor ! = "fx" | | ! string . IsNullOrEmpty ( r . hitFxPrefab ) ) { CombatEvents . HitConfirmed . Add ( OnHit ) ; break ; } // #961 피격 대상 이펙트도 타격 확정을 본다
2026-10-18 18:48:42 +00:00
}
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>
2026-10-20 14:21:09 +00:00
/// <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 ; }
}
2026-10-21 14:13:56 +00:00
public static string InvisibleLog = "" ;
/// <summary>#965 PD 「비수 각성 스킬 끝나고 캐릭터 등장까지 딜레이가 너무 길어 — 이펙트 종료와 동시에 등장」 — 원본 Skill_DarkAmbush.Co_Update 의 Set_Invisible(1.5 s) 인자 훅(1줄):
/// 규칙 invisibleMatchFx 면 이 시전 인스턴스의 켜진 렌더러 입자계(반복 · 렌더러 꺼진 보조 입자계 제외)마다 (시작 지연 + 마지막 분출 + 수명 최댓값)의 최댓값 = 마지막 폭발이 사라지는 시각 + invisibleExtra.
/// 다시 보이는 순간 = 착지 시작(LateTick 「보이는 순간」)이라 등장도 그 시각.</summary>
public static float InvisibleSeconds ( ProjectileBase skill , float orig )
{
try
{
var c = Cfg ; if ( skill = = null | | c . enabled = = 0 ) return orig ;
WLGhostStrikeRule r ; if ( ! s_rules . TryGetValue ( skill . GetType ( ) . Name , out r ) | | r = = null | | r . invisibleMatchFx = = 0 ) return orig ;
float v = 0f ;
foreach ( var ps in skill . GetComponentsInChildren < ParticleSystem > ( true ) )
{
var rd = ps . GetComponent < ParticleSystemRenderer > ( ) ; if ( rd = = null | | ! rd . enabled ) continue ;
var m = ps . main ; if ( m . loop ) continue ;
float delay = m . startDelay . mode = = ParticleSystemCurveMode . Constant ? m . startDelay . constant : m . startDelay . constantMax ;
float life = m . startLifetime . mode = = ParticleSystemCurveMode . Constant ? m . startLifetime . constant : ( m . startLifetime . mode = = ParticleSystemCurveMode . TwoConstants ? m . startLifetime . constantMax : m . startLifetime . curveMultiplier ) ;
var em = ps . emission ; float last = - 1f ;
for ( int i = 0 ; i < em . burstCount ; i + + ) { var b = em . GetBurst ( i ) ; if ( b . cycleCount < = 0 ) continue ; last = Mathf . Max ( last , b . time + ( b . cycleCount - 1 ) * b . repeatInterval ) ; }
if ( em . rateOverTime . constantMax > 0f ) last = Mathf . Max ( last , m . duration ) ;
if ( last < 0f ) continue ;
v = Mathf . Max ( v , delay + last + life ) ;
}
if ( v < 0.2f ) { InvisibleLog = "이펙트 시간 못 셈 → 원본 " + orig ; return orig ; }
float sec = Mathf . Max ( 0.2f , v + r . invisibleExtra ) ;
InvisibleLog = "숨김 " + orig . ToString ( "F2" ) + " → " + sec . ToString ( "F2" ) + " s(이펙트 끝 " + v . ToString ( "F2" ) + ")" ; return sec ;
}
catch ( System . Exception ) { return orig ; }
}
2026-10-18 18:48:42 +00:00
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 ;
2026-10-20 10:01:04 +00:00
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 } ;
2026-10-18 18:48:42 +00:00
if ( r . anchor ! = "hit" ) cast . fx = FxPoints ( skill , r ) ;
Capture ( cast , c ) ;
s_cast = cast ;
2026-10-21 07:56:27 +00:00
s_hitQ . Clear ( ) ; s_bearing . Clear ( ) ; s_lastHitAt . Clear ( ) ; s_fxQ . Clear ( ) ; s_lastFxAt . Clear ( ) ;
2026-10-18 18:48:42 +00:00
Casts + + ;
2026-10-21 04:56:16 +00:00
EnsureRunner ( ) ;
2026-10-18 18:48:42 +00:00
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 ; }
}
2026-10-21 04:56:16 +00:00
/// <summary>풀 정리(Tick · 유지·소멸 뒤 반환)를 돌리는 러너 — #959 실측: 각성(OnSet) 전에 4타 순간이동 잔상·난사 잔상만 쓰면 러너가 없어 잔상 6개가 영영 busy(원장 47 「첫 6개 뒤 0개」의 원인) → 잔상을 띄우는 모든 입구에서 보장.</summary>
static void EnsureRunner ( )
{
if ( s_runner ! = null ) return ;
var go = new GameObject ( "[WL943] GhostStrike" ) ; go . hideFlags = HideFlags . HideInHierarchy ; Object . DontDestroyOnLoad ( go ) ;
s_runner = go . AddComponent < WLGhostStrikeRunner > ( ) ;
}
2026-10-18 18:48:42 +00:00
/// <summary>타격 확정(물리 콜백 안) — 이번 시전 스킬이 시전자로 맞힌 것만 요청으로 쌓는다(생성은 러너 Update).</summary>
static void OnHit ( in HitConfirmedEvent e )
{
try
{
var cast = s_cast ;
2026-10-21 07:56:27 +00:00
if ( cast = = null | | DSUtil . CheckNull ( e . victim ) | | ! ReferenceEquals ( e . attacker , cast . shooter ) ) return ;
bool ghost = cast . rule . anchor ! = "fx" , fx = ! string . IsNullOrEmpty ( cast . rule . hitFxPrefab ) ; // #961 피격 대상 이펙트
if ( ! ghost & & ! fx ) return ;
2026-10-18 18:48:42 +00:00
var pd = e . projectile ;
var sk = pd ! = null ? pd . m_SkillListTableData : null ;
if ( sk = = null | | sk . e_Skill . ToString ( ) ! = cast . rule . skill ) return ;
2026-10-21 07:56:27 +00:00
if ( ghost & & s_hitQ . Count < Mathf . Max ( 1 , Cfg . maxAlive ) * 4 ) s_hitQ . Add ( new HitReq { victim = e . victim , time = Time . time } ) ;
if ( fx & & s_fxQ . Count < 32 ) s_fxQ . Add ( new HitReq { victim = e . victim , time = Time . time } ) ;
2026-10-18 18:48:42 +00:00
}
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 ) ; }
2026-10-20 10:01:04 +00:00
// #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 ( ) ;
2026-10-21 04:56:16 +00:00
var rt = Vector3 . Cross ( Vector3 . up , fw ) ; // #959 PC 오른쪽
if ( Spawn ( c , cast , sp + Vector3 . up * ( rl . jumpHeight * k ) + fw * ( rl . jumpForward * k ) + rt * ( rl . jumpSide * k ) , fw ) ) JumpSpawned + + ;
2026-10-20 10:01:04 +00:00
cast . jumpDone + + ; cast . jumpNext = now + rl . jumpSeconds / rl . jumpGhosts ;
}
2026-10-20 12:36:12 +00:00
// #951 ② 착지 연출 — 시작 판정은 LateTick(#952-② 다시 보이는 그 프레임에 즉시 · 서 있는 자세가 한 프레임도 안 보이게) · 진행은 여기
2026-10-20 10:01:04 +00:00
if ( cast . landing ) UpdateLanding ( c , cast , now ) ;
2026-10-18 18:48:42 +00:00
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 ] ) ;
2026-10-21 07:56:27 +00:00
for ( int i = 0 ; i < s_fxQ . Count ; i + + ) HitFxSpawn ( cast , s_fxQ [ i ] ) ; // #961
2026-10-20 10:01:04 +00:00
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 ;
2026-10-18 18:48:42 +00:00
}
2026-10-21 07:56:27 +00:00
s_hitQ . Clear ( ) ; s_fxQ . Clear ( ) ;
2026-10-18 18:48:42 +00:00
for ( int i = 0 ; i < s_pool . Count ; i + + )
{
var g = s_pool [ i ] ;
if ( ! g . busy | | g . root = = null ) continue ;
2026-10-20 16:54:40 +00:00
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 ) ) ;
2026-10-18 18:48:42 +00:00
}
}
catch ( System . Exception ex ) { Errors + + ; LastLog = "틱 실패 — " + ex . Message ; }
}
2026-10-20 10:01:04 +00:00
// ───────────────────────────────────────── #951 도약 잔상 · 착지 연출
2026-10-20 16:54:40 +00:00
public static int JumpSpawned , Landings , LandGhostFrames ; public static string LandLog = "" ;
2026-10-21 01:28:52 +00:00
/// <summary>#955 비수 4타 순간이동 잔상 — 지금 자세(살아 있는 메시)를 한 번 굽고 from→to 사이에 n 개(잔상 색 · 기본 유지·소멸).</summary>
public static int BlinkGhosts ( Actor pc , Vector3 from , Vector3 to , int n )
{
try
{
var c = Cfg ; if ( c . enabled = = 0 | | pc = = null | | n < = 0 ) return 0 ;
2026-10-21 04:56:16 +00:00
EnsureRunner ( ) ; // #959 풀 정리 보장(원장 47)
2026-10-21 01:28:52 +00:00
var cast = new Cast { shooter = pc , rule = new WLGhostStrikeRule ( ) , t0 = Time . time , groundY = pc . Get_position ( ) . y , poseCursor = 0 } ;
Capture ( cast , c ) ;
var pose = BakePose ( pc . transform , cast . smrs , cast . weapons ) ; if ( pose = = null ) return 0 ;
var face = to - from ; face . y = 0f ; if ( face . sqrMagnitude < 1e-4f ) face = pc . transform . forward ;
int made = 0 ;
for ( int i = 0 ; i < n ; i + + ) if ( Spawn ( c , cast , Vector3 . Lerp ( from , to , ( i + 0.5f ) / n ) , face , pose ) ) made + + ;
if ( pose . body ! = null ) Object . Destroy ( pose . body , c . hold + c . fade + 0.5f ) ;
foreach ( var m in pose . extraMesh ) if ( m ! = null ) Object . Destroy ( m , c . hold + c . fade + 0.5f ) ;
BlinkGhostCount + = made ; return made ;
}
catch ( System . Exception ex ) { Errors + + ; LastLog = "잔상 실패 — " + ex . Message ; return 0 ; }
}
public static int BlinkGhostCount ;
2026-10-21 04:56:16 +00:00
/// <summary>#959 지금 자세 잔상 1개(살아 있는 메시를 굽는다) — 비수 주력 난사 중 「빠르게 공격하는」 잔상 · 색(RGBA · null = 기본) · 유지 · 소멸(− 1 = 기본).</summary>
public static int LiveGhosts ;
public static bool LiveGhost ( Actor pc , float [ ] color , float hold , float fade )
{
try
{
var c = Cfg ; if ( c . enabled = = 0 | | pc = = null ) return false ;
EnsureRunner ( ) ; // #959 풀 정리 보장
var cast = new Cast { shooter = pc , rule = new WLGhostStrikeRule ( ) , t0 = Time . time , groundY = pc . Get_position ( ) . y } ;
Capture ( cast , c ) ;
var pose = BakePose ( pc . transform , cast . smrs , cast . weapons ) ; if ( pose = = null ) return false ;
var face = pc . transform . forward ; face . y = 0f ; if ( face . sqrMagnitude < 1e-4f ) face = Vector3 . forward ;
bool ok = Spawn ( c , cast , pc . Get_position ( ) , face , pose , color , hold , fade ) ;
float life = ( hold > = 0f ? hold : c . hold ) + ( fade > = 0f ? fade : c . fade ) + 0.5f ;
if ( pose . body ! = null ) Object . Destroy ( pose . body , life ) ;
foreach ( var m in pose . extraMesh ) if ( m ! = null ) Object . Destroy ( m , life ) ;
if ( ok ) LiveGhosts + + ;
return ok ;
}
catch ( System . Exception ex ) { Errors + + ; LastLog = "잔상 실패 — " + ex . Message ; return false ; }
}
2026-10-20 16:54:40 +00:00
/// <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 ) ; }
2026-10-20 10:01:04 +00:00
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 : "" ;
}
2026-10-20 12:36:12 +00:00
/// <summary>러너 LateUpdate — 애니메이터가 모델 위치를 쓴 뒤에 낙하 높이를 덮는다(Update 에서 쓰면 되돌아간다 · 실측).
/// #952-② PD 「이펙트가 끝나는 시점에 잠시 캐릭터가 서 있는 상태가 보였다가 착지 → 끝나는 시점에 즉시 착지」 — 착지 시작 판정도 여기서: 원본 Co_Invisible 이 모델을 다시 켜는 순간(코루틴 = Update 뒤 · LateUpdate 앞)을
/// 같은 프레임에 잡아 착지 클립·높이를 적용하고 그 한 프레임은 모델을 숨긴다(이미 그려질 자세 = 서 있는 원본 자세) → 다음 프레임부터 공중 착지 자세. 공중 클립 끝·시각 조건은 예비.</summary>
2026-10-20 10:01:04 +00:00
internal static void LateTick ( )
{
2026-10-20 12:36:12 +00:00
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 ? " · 공중 클립 끝" : " · 시각" ;
}
}
2026-10-20 16:54:40 +00:00
if ( cast . landing & & cast . hideUntilFrame > 0 & & Time . frameCount > = cast . hideUntilFrame ) { cast . hideUntilFrame = 0 ; if ( ! cast . hideDuringFall ) SetVisible ( cast , true ) ; } // #954 landHideModel 이면 착지 순간까지 숨긴 채
2026-10-20 12:36:12 +00:00
if ( ! cast . landing | | cast . model = = null | | float . IsNaN ( cast . modelY ) ) return ;
2026-10-20 10:01:04 +00:00
var lp = cast . model . localPosition ; cast . model . localPosition = new Vector3 ( lp . x , cast . modelY , lp . z ) ;
}
2026-10-21 05:29:56 +00:00
public static int EntryGhosts ; // #960 옆 진입 잔상 수(검증용)
/// <summary>#960 착지 동안 끈 애니메이션 이벤트를 되돌린다(한 번만).</summary>
static void RestoreEvents ( Cast cast )
{
if ( ! cast . eventsOff ) return ; cast . eventsOff = false ;
try { if ( ! DSUtil . CheckNull ( cast . shooter ) & & cast . shooter . m_animation ! = null ) cast . shooter . m_animation . fireEvents = cast . eventsWas ; } catch ( System . Exception ) { }
}
2026-10-20 12:36:12 +00:00
/// <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 ) ; }
2026-10-21 05:29:56 +00:00
SetVisible ( cast , true ) ; SetStop ( a , cast . stopWas ) ; RestoreEvents ( cast ) ; // #960
2026-10-20 12:36:12 +00:00
}
2026-10-20 16:54:40 +00:00
cast . landing = false ; cast . landed = true ; cast . modelY = float . NaN ; cast . hideUntilFrame = 0 ; cast . hideDuringFall = false ; s_cast = null ; LandLog + = " · 취소(직업 전환)" ;
2026-10-20 12:36:12 +00:00
}
2026-10-20 10:01:04 +00:00
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 ;
2026-10-20 16:54:40 +00:00
cast . landing = true ; cast . landT0 = now ; cast . landGhostsDone = 0 ; cast . dustDone = false ; cast . hideDuringFall = r . landHideModel ! = 0 & & s_landPose ! = null ; // #954 착지 자세를 구웠을 때만 실제 모델을 숨긴다
2026-10-20 10:01:04 +00:00
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 ( ) ; }
2026-10-21 04:56:16 +00:00
cast . landRight = Vector3 . Cross ( Vector3 . up , cast . landFwd ) . normalized ; cast . entryMoved = 0f ;
if ( r . entrySide ! = 0f ) // #959 옆에서 들어오기 — 최종 자리(landPos)에서 옆으로 entrySide 만큼 떨어진 곳으로 워프 → UpdateLanding 이 감속하며 되돌아온다
{
var from = cast . landPos - cast . landRight * r . entrySide ;
try { if ( pc ! = null ) pc . Set_Warp ( from ) ; else cast . shooter . transform . position = from ; } catch ( System . Exception ) { cast . shooter . transform . position = from ; }
}
2026-10-21 05:29:56 +00:00
try
{
var an = cast . shooter . m_animation ;
if ( an ! = null )
{
if ( r . landNoEvents ! = 0 ) { cast . eventsWas = an . fireEvents ; an . fireEvents = false ; cast . eventsOff = true ; } // #960 양손 베기(4타) 클립의 이벤트(순간이동 · 카메라 · 타격)가 착지에 끼지 않게
an . speed = 1f ; an . CrossFade ( r . landState , 0f , 0 , Mathf . Clamp01 ( r . landStateStart ) ) ;
}
}
catch ( System . Exception ex ) { LandLog = "착지 클립 실패 — " + ex . Message ; } // #952-② 섞임 0 = 서 있는 자세에서 번지지 않게
cast . nextEntryGhost = now ; // #960 옆 진입 잔상 첫 장 = 바로
2026-10-20 10:01:04 +00:00
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 + + ; }
2026-10-20 16:54:40 +00:00
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 떨어지는 동안 = 반투명 캐릭터(매 프레임)
2026-10-20 14:21:09 +00:00
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 ;
}
2026-10-21 04:56:16 +00:00
if ( r . entrySide ! = 0f ) // #959 옆에서 튀어나오기 — 속도 2D/T → 0 감속(총 이동 = |entrySide|) · 방향 = 워프한 쪽의 반대
{
float total = Mathf . Abs ( r . entrySide ) , kk = Mathf . Clamp01 ( t / fall ) ;
float step = Mathf . Min ( total * ( 2f / fall ) * ( 1f - kk ) * Time . deltaTime , Mathf . Max ( 0f , total - cast . entryMoved ) ) ;
var dir = cast . landRight * Mathf . Sign ( r . entrySide ) ; cast . entryMoved + = step ;
if ( cast . agent ! = null & & cast . agent . enabled & & cast . agent . isOnNavMesh ) cast . agent . Move ( dir * step ) ; else cast . shooter . transform . position + = dir * step ;
2026-10-21 05:29:56 +00:00
if ( r . entryGhostInterval > 0f & & ! cast . hideDuringFall & & now > = cast . nextEntryGhost ) // #960 실제 캐릭터가 들어오는 길에 그림자 잔상
{
cast . nextEntryGhost = now + r . entryGhostInterval ;
if ( LiveGhost ( cast . shooter , r . landGhostColor , r . landGhostHold , r . landGhostFade ) ) EntryGhosts + + ;
}
2026-10-21 04:56:16 +00:00
}
2026-10-20 10:01:04 +00:00
return ;
}
cast . modelY = cast . modelBaseY ;
if ( ! cast . dustDone )
{
cast . dustDone = true ;
2026-10-21 04:56:16 +00:00
if ( r . entrySide ! = 0f ) // #959 들어오기 끝 = 최종 자리에 정확히
{
var d = cast . landPos - cast . shooter . Get_position ( ) ; d . y = 0f ;
if ( cast . agent ! = null & & cast . agent . enabled & & cast . agent . isOnNavMesh ) cast . agent . Move ( d ) ; else cast . shooter . transform . position + = d ;
LandLog + = " · 옆 진입 " + cast . entryMoved . ToString ( "F2" ) + " m" ;
}
2026-10-20 16:54:40 +00:00
if ( cast . hideDuringFall ) { cast . hideDuringFall = false ; SetVisible ( cast , true ) ; } // #954 착지 순간 실제 모델이 튀어나온다
2026-10-21 07:56:27 +00:00
if ( r . popGhosts > 0 & & s_landPose ! = null )
{
// #961 PD 「각성 뒤 그림자가 캐릭터 앞으로 나온다 → 캐릭터 뒤로 이어지게」 — 이전 = 정면(landFwd) ±18° 로 0.12 m 씩 앞에 놓였다 · 이제 popGhostDir(기본 back = 캐릭터 등 쪽 · entry = 옆 진입으로 들어온 길 쪽)으로 popGhostStep 씩
Vector3 back = r . popGhostDir = = "entry" & & r . entrySide ! = 0f ? - cast . landRight * Mathf . Sign ( r . entrySide ) : - cast . landFwd ; // Play 실측: 들어온 길 쪽은 PC 방향에 따라 카메라 쪽(캐릭터를 덮음)이 되어 기본 = 등 쪽
for ( int i = 0 ; i < r . popGhosts ; i + + ) { var face = Quaternion . Euler ( 0f , ( i - ( r . popGhosts - 1 ) * 0.5f ) * 8f , 0f ) * cast . landFwd ; if ( Spawn ( c , cast , cast . shooter . Get_position ( ) + back * r . popGhostStep * ( i + 1 ) , face , s_landPose ) ) JumpSpawned + + ; }
}
2026-10-20 10:01:04 +00:00
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 )
{
2026-10-20 12:36:12 +00:00
// #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 ;
2026-10-20 10:01:04 +00:00
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 ;
2026-10-21 05:29:56 +00:00
RestoreEvents ( cast ) ; // #960
2026-10-20 10:01:04 +00:00
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" ;
}
}
2026-10-18 18:48:42 +00:00
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 + + ;
}
2026-10-21 07:56:27 +00:00
/// <summary>#961 이 스킬에 맞은 적의 몸 중심에 hitFxPrefab(원본 주력 붉은 회전 검기 사본)을 띄운다 — 같은 적은 hitFxInterval 마다 · 월드 축(원본 Projectile_StopAndColliderControl 과 같은 방향) · hitFxLife 뒤 파괴.</summary>
static void HitFxSpawn ( Cast cast , HitReq q )
{
var r = cast . rule ; var v = q . victim ;
if ( DSUtil . CheckNull ( v ) | | v . IsDead ( ) ) return ;
float last ;
if ( s_lastFxAt . TryGetValue ( v , out last ) & & q . time - last < r . hitFxInterval ) return ;
var p = Resources . Load < GameObject > ( r . hitFxPrefab ) ;
if ( p = = null ) { HitFxLog = "이펙트 없음 " + r . hitFxPrefab ; return ; }
s_lastFxAt [ v ] = q . time ;
var offset = v . Get_Center_position ( ) - v . Get_position ( ) + Vector3 . up * r . hitFxYOffset ;
var go = Object . Instantiate ( p , v . Get_position ( ) + offset , Quaternion . identity ) ;
go . transform . localScale = p . transform . localScale * Mathf . Max ( 0.01f , r . hitFxScale ) ;
foreach ( var ps in go . GetComponentsInChildren < ParticleSystem > ( true ) ) { var m = ps . main ; m . scalingMode = ParticleSystemScalingMode . Hierarchy ; }
var root = go . GetComponent < ParticleSystem > ( ) ; if ( root ! = null ) root . Play ( true ) ;
if ( r . hitFxFollow ! = 0 ) { var f = go . AddComponent < WLGhostHitFxFollow > ( ) ; f . target = v . transform ; f . offset = offset ; } // 적 자식으로 두지 않는다(적 렌더러 일괄 처리 · 풀 반환에 휩쓸리지 않게)
Object . Destroy ( go , Mathf . Max ( 0.3f , r . hitFxLife ) ) ;
HitFxSpawned + + ;
HitFxLog = "피격 이펙트 " + HitFxSpawned + " · " + v . name + " @" + go . transform . position . ToString ( "F2" ) + " × " + r . hitFxScale . ToString ( "F2" ) + " · 시전 뒤 " + ( q . time - cast . t0 ) . ToString ( "F2" ) + " s" ;
}
2026-10-18 18:48:42 +00:00
// ───────────────────────────────────────── 부품 · 자세
/// <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 + + ; }
}
2026-10-20 16:54:40 +00:00
// #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 ;
}
2026-10-18 18:48:42 +00:00
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 ) ; }
// ───────────────────────────────────────── 고스트 · 재질
2026-10-20 16:54:40 +00:00
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 )
2026-10-18 18:48:42 +00:00
{
2026-10-20 16:54:40 +00:00
if ( ( s_poses . Count = = 0 & & poseOverride = = null ) | | ! EnsureMaterial ( c ) ) return false ;
2026-10-18 18:48:42 +00:00
var g = Rent ( c ) ;
if ( g = = null ) { SkippedCap + + ; return false ; }
2026-10-20 16:54:40 +00:00
var pose = poseOverride ? ? s_poses [ ( cast . poseCursor + + & 0x7fffffff ) % s_poses . Count ] ;
g . color = color ; g . hold = hold ; g . fade = fade ;
2026-10-18 18:48:42 +00:00
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 )
{
2026-10-20 16:54:40 +00:00
var a = g . color ! = null & & g . color . Length > = 4 ? g . color : Cfg . color ; // #954 잔상별 색 덮기
2026-10-18 18:48:42 +00:00
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>
2026-10-20 10:01:04 +00:00
public sealed class WLGhostStrikeRunner : MonoBehaviour { void Update ( ) { WLGhostStrike . Tick ( ) ; } void LateUpdate ( ) { WLGhostStrike . LateTick ( ) ; } }
2026-10-21 07:56:27 +00:00
/// <summary>#961 피격 대상 이펙트가 밀려나는 적을 위치만 따라간다(적이 꺼지면 그 자리에 남음).</summary>
public sealed class WLGhostHitFxFollow : MonoBehaviour
{
public Transform target ; public Vector3 offset ;
void LateUpdate ( ) { if ( target ! = null & & target . gameObject . activeInHierarchy ) transform . position = target . position + offset ; }
}
2026-10-18 18:48:42 +00:00
}