2026-10-18 15:09:05 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// WLImpactFx.cs — 타격 임팩트 교체·보강 (#843 · 2026-10-12 · PD 「검 피격 = 파란 별 베기 · 해머 궤적 = 주황 회전 · 해머 피격 = 주황 타격 ·
// 해머 3타·대검 4타 지면 충격파 · 지팡이 마법 투사체는 몸 중심까지 날아가 피격 이펙트와 함께 소멸」)
// #844 (2026-10-12 · PD) ① 해머 궤적은 휘두르는 방향에 맞게 — 궤적 베이크(SlashTrailSettings.swingCalibrations: 평면·시작 방향·이등분·반경·sweep)에
// 회전 이펙트의 실측 프로필(파티클 방출 궤도의 축·시작·반경)을 정합 ② 충격파는 무기 끝이 지면에 닿는 순간·그 자리(WeaponTrailDriver 칼끝 추적)
// ③ 피격 이펙트는 몬스터 몸체 기준(발~정수리 중간 · 이펙트 실측 치수를 몸 높이에 맞춤).
/ /
// ■ 연결 지점(원본 훅 2줄 · 나머지 WL)
// · ProjectileBase.Show_HitEffect — isHitEffect && !TryHitEffect(...) : 직업별 피격 이펙트 교체(몸 중심 = 발~정수리 중간 · 몸 높이 비례).
// · ProjectileBase.ProcessCollider — Off_byHit && !DeferOff(...) : 지팡이 투사체는 맞은 뒤 끄지 않고 몸 중심까지 조준 비행 → 도달 시 피격 이펙트 + 소멸.
// · WeaponTrailDriver.TryHandleShowEffect — TrySwingOverride : 해머 궤적을 회전형 이펙트로(검기 호 대신) · 궤적 베이크 프레임에 정합.
// · CombatEvents.ComboStage 구독 — 해머 3타·대검 4타 단계 진입 시 칼끝 높이 추적 → 지면 접점에 충격파. HitboxSpawned 는 추적 불가 시 폴백.
// ■ 값·프리팹 이름 = Resources/WL/WLImpactFx.json (C45). 프리팹 = Resources/WL/Impact/(Master Stylized FX 사본 · WL842_StatusPrefabs.BuildImpact).
// ■ 프리팹 프로필(WLFxProfile) = 첫 사용 시 보이지 않는 곳에서 Simulate 로 실측(치수·중심 오프셋·방출 궤도 축) — 손으로 적은 상수 없음.
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic ;
using UnityEngine ;
using WL.Combat.Core ;
namespace WL.Combat.Impact
{
/// <summary>피격 교체. fit > 0 이면 이펙트 최대 치수 = fit × 몸 높이(프로필 실측 치수로 환산) · 0 이면 scale × 키 비례(구 방식).</summary>
public sealed class WLHitFxRule { public int [ ] classIds ; public string prefab ; public float scale = 1f ; public float fit = 1.3f ; public bool offsetCompensate = true ; }
/// <summary>#936 스킬 투사체(원본 str_HitEffect 가 빈 것 — 강력한 일격 · 돌진 베기 · 방패 치기 · 대지 소환 · 대지 줄기 · 화살 비 …)의 무기별 스킬 피격 이펙트. skills = 투사체 이름 앞부분 목록(비우면 그 직업의 모든 스킬 투사체).</summary>
public sealed class WLSkillHitFxRule { public int [ ] classIds ; public string [ ] skills ; public string prefab ; public float scale = 1f ; public float fit = 1.6f ; public bool offsetCompensate = true ; }
/// <summary>궤적 교체. calibrated = 궤적 베이크 프레임에 정합(emitter = 프로필 기준 방출기 이름 · 비우면 자동) · radiusFit = 칼끝 반경 대비 이펙트 반경.</summary>
public sealed class WLSwingFxRule { public int [ ] classIds ; public int [ ] stages ; public string prefab ; public float scale = 1f ; public float [ ] euler ; public float yOffset ; public bool calibrated = true ; public string emitter = "" ; public float radiusFit = 1f ; public bool sweepFlip ; public float fallbackScale = 0.55f ;
public float minFacing ;
// #932 PD 「대검 1타는 모션을 내려치기로 바꾸지 말고 대각선 베기하면서 이펙트만 좀 더 잘보이게」 — 1 = 기울임 축을 호의 현(시작 → 끝 선 · 면 법선 × 이등분) 으로: 베는 방향(대각선)은 그대로 두고 초승달 가운데만 카메라 쪽으로 펼친다 · maxTilt = 최대 각(도)
2026-10-18 17:27:34 +00:00
public int facingAxis ; public float maxTilt = 40f ; // #928 0 보다 크면 초승달 면 법선과 시선의 |cos| 이 이 값보다 작을 때(카메라에서 면이 얇게 보일 때) 면을 카메라 쪽으로 그 값까지만 기울인다(피벗 고정)
// #940 PD 「창 2타 연타 때 무기 부분을 FX_Machine_Gun 으로 가려」 — calibrated 0 전용: follow = PC 에 붙여 따라감 · forward = PC 정면 거리(m) · life = 켜 둘 초(0 = 프리팹 길이) · minInterval = 같은 PC·단계의 ShowEffect 가 이 초 안에 또 오면 새로 띄우지 않고 궤적만 막는다(6연타 이벤트 → 이펙트 1개)
public bool follow ; public float forward ; public float side ; public float life ; public float minInterval ; // side = PC 오른쪽 거리(m)
// #941 PD 「창 공격 시 이펙트와 캐릭터 방향이 일치하게」 — alignWeapon = 매 프레임 무기 소켓(손잡이 → 무기 끝) 방향에 맞춰 돌리고 손잡이에서 alignFraction 만큼 간 자리에 둔다(forward/side 는 안 씀) · massLocal = 프리팹 로컬 단위의 보이는 묶음 중심(원점 보정 · Play 실측)
2026-10-18 18:48:42 +00:00
public bool alignWeapon ; public float alignFraction = 0.5f ; public float [ ] massLocal ;
2026-10-21 01:28:52 +00:00
// #955 PD 비수 4타·3타 부가 연출 — blinkDistance: 띄우는 순간 PC 를 정면으로 순간이동(m · 잔상 blinkGhosts 개를 길에) · camHoldSeconds: 그동안 카메라 회전·추적 고정 + camZoomOut(m) 만큼 줌아웃했다 복귀 · spinDegrees/spinSeconds: PC 를 코드로 회전(클립에 회전이 없을 때 · 0 = 안 함)
public float blinkDistance ; public int blinkGhosts = 4 ; public float camHoldSeconds ; public float camZoomOut ; public float spinDegrees ; public float spinSeconds = 0.6f ;
2026-10-21 01:54:38 +00:00
// #956 PD 「3타 때 몸을 회전 1회하며 공격 · 이펙트도 회전 모션에 맞게」 — spinFx: PC 에 붙은(follow) 이펙트를 몸과 같은 각으로 돌린다(기본 켬)
public bool spinFx = true ;
2026-10-21 04:05:18 +00:00
// #958 회전은 클립 yaw 를 덮어쓴다(클립 자체 회전과 상쇄 방지) · spinBlendOut = 끝난 뒤 애니메이터 자세로 되돌아가는 시간(초)
public float spinBlendOut = 0.12f ;
2026-10-18 18:48:42 +00:00
// #943-⑤ PD 「타격하려는 방향을 고정한 채 이펙트가 재생되는 동안 타격 판정 … 이펙트가 캐릭터 방향에 따라 마구 바뀌면 안 됨」 — fixedDir = 띄우는 순간 PC 정면(LookAtTarget 뒤 = 표적 방향)을 잡아 두고 life 동안 이펙트 방향 고정 + PC 회전도 그 방향으로 잠금(공격 상태인 동안) · 자리 = PC 중심 + 정면 alignForward + 오른쪽 alignSide + 위 yOffset − R × massLocal × 배율(PC 위치는 따라감)
public bool fixedDir ; public float alignForward = 0.5f ; public float alignSide ; public bool lockFacing = true ; }
2026-10-18 15:09:05 +00:00
/// <summary>지면 충격파. tipContact = 칼끝이 지면(contactHeight 이내)에 닿는 프레임·자리에서 재생(단계 안에서 minDrop 이상 올라갔다 내려올 때) · 못 잡으면 timeout 뒤 최저점(fallbackHeight 이내)에.</summary>
/// <summary>#900 frontOnly = PC 정면 쪽 접점만 인정(해머 1타 내려치기 — 뒤로 휘두를 때 등 뒤 지면 접점을 건너뛴다 · 2026-10-17 실측 − 0.8 m).</summary>
public sealed class WLGroundFxRule { public int [ ] classIds ; public int stage ; public string prefab ; public float scale = 1f ; public float forward ; public bool tipContact = true ; public float contactHeight = 0.15f ; public float minDrop = 0.35f ; public float timeout = 1.5f ; public float fallbackHeight = 0.5f ; public bool frontOnly ; }
public sealed class WLDeferRule
{
public int [ ] classIds ; public float arriveDistance = 0.12f ; public float maxSeconds = 0.35f ; public float minSpeed = 8f ;
// #921 PD 「활 기본 공격 시 휘어지지 않고 직선으로만 날아가도록」 — 이 이름의 투사체는 쏠 때 몸 중심을 겨누고(원본은 발을 겨눠 아래로 날았다 · 실측 진행 높이 − 0.23)
// 맞은 뒤에도 방향을 틀지 않고 곧게 몸 중심 깊이까지 날아간다(조준 비행으로 꺾이던 것)
public string [ ] straightProjectiles ;
}
public sealed class WLImpactSettings
{
public bool enabled = true ;
public float referenceHeight = 1f , sizeFactorMin = 0.5f , sizeFactorMax = 3f ;
public float bodyCenterFraction = 0.5f ; // 몸 중심 높이 = 발 + 키 × 이 값
2026-10-18 17:27:34 +00:00
// #941 PD 「피격 이펙트는 몬스터보다 상단에 보여야 해(몬스터에게 가려지기 때문)」 — 피격 이펙트를 몸 중심에서 카메라 쪽으로 (몸 너비 × hitTowardWidthMul · hitTowardMin~Max m) × hitTowardCamera 만큼 당겨 몸 앞에 그린다(화면 위치는 그대로) · 0 = 끔
public float hitTowardCamera = 1f , hitTowardWidthMul = 0.6f , hitTowardMin = 0.5f , hitTowardMax = 1.5f ;
2026-10-18 15:09:05 +00:00
public float flyingGap = 0.1f ; // #921 몸 그림 바닥이 발보다 이만큼(m) 넘게 높으면 떠 있는 몹 — 몸 중심 = 지금 그림(bounds) 가운데(오르내려도 정중앙) · 0 = 끔
public bool profileWarmup = true ; // 프리팹 프로필 실측(끄면 구 방식 배치)
public float profileSeconds = 2.5f ; // 프로필 실측 길이
public List < WLHitFxRule > hitFx = new List < WLHitFxRule > ( ) ;
public List < WLSkillHitFxRule > skillHitFx = new List < WLSkillHitFxRule > ( ) ; // #936
public float skillHitMinInterval = 0.12f ; // #936 같은 대상에 스킬 피격 이펙트를 다시 띄우는 최소 간격(초 · 화살 비 15회 다단 히트)
public List < WLSwingFxRule > swingFx = new List < WLSwingFxRule > ( ) ;
public List < WLGroundFxRule > groundFx = new List < WLGroundFxRule > ( ) ;
public WLDeferRule deferToCenter ;
}
/// <summary>몸 상자 — 발·정수리(원본 HUD_Top)로 높이, 렌더러 bounds 로 가로.</summary>
public struct WLBodyBox { public Vector3 center ; public float height , width ; public bool valid ; }
/// <summary>프리팹 1개의 실측 프로필(스케일 1 기준) — 치수·중심 오프셋 + 방출 궤도(축·시작·중심·반경).</summary>
public sealed class WLFxProfile
{
public string prefab ;
public Bounds bounds ; public Vector3 centerOffset ; public float extent ;
public bool sweepValid ; public string sweepEmitter = "" ; public Vector3 sweepNormal , sweepStart , sweepCenter ; public float sweepRadius , sweepConsistency ; public int sweepSamples ;
public string log = "" ;
}
public static class WLImpactFx
{
public const string JsonPath = "WL/WLImpactFx" ;
public const string ResourcesDir = "WL/Impact/" ;
static WLImpactSettings s_settings ; static bool s_loaded ;
public static WLImpactSettings S { get { if ( ! s_loaded ) Load ( ) ; return s_settings ; } }
public static bool Enabled { get { var s = S ; return s ! = null & & s . enabled ; } }
public static void Reload ( ) { s_loaded = false ; ClearPool ( ) ; }
2026-10-20 16:54:40 +00:00
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetForPlay ( ) { Reload ( ) ; } // #954 도메인 리로드 끔 대비 — Play 마다 WLImpactFx.json 을 다시 읽는다(실측: swingFx 배율 변경이 다음 Play 에 안 먹었다)
2026-10-18 15:09:05 +00:00
static void Load ( )
{
s_loaded = true ;
var ta = Resources . Load < TextAsset > ( JsonPath ) ;
try { s_settings = ta ! = null ? Newtonsoft . Json . JsonConvert . DeserializeObject < WLImpactSettings > ( ta . text ) : null ; }
catch ( System . Exception ex ) { Debug . LogException ( ex ) ; s_settings = null ; }
}
// ── 진단(프로브가 읽는다)
public static int HitFxShown , SwingShown , SwingCalibrated , GroundShown , GroundContacts , GroundFallbacks , Defers , Arrivals , Misses , SkillHitFxShown , SkillHitThrottled ;
public static float LastArriveDist , LastArriveSeconds ;
public static string LastLog = "" , LastSwing = "" , LastGround = "" , LastHit = "" ;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register ( ) { CombatEvents . HitboxSpawned . Add ( OnHitboxSpawned ) ; CombatEvents . ComboStage . Add ( OnComboStage ) ; }
static bool Contains ( int [ ] ids , int id ) { if ( ids = = null ) return false ; for ( int i = 0 ; i < ids . Length ; i + + ) if ( ids [ i ] = = id ) return true ; return false ; }
public static int ShooterClass ( ProjectileBase pb )
{
if ( pb = = null ) return 0 ;
var pd = pb . Get_ProjectTileData ( ) ;
return pd ! = null & & pd . shooter ! = null ? pd . shooter . Get_ID ( ) : 0 ;
}
// ── 몸 상자 ──────────────────────────────────────────────────────────
static readonly Dictionary < Actor , Renderer > s_body = new Dictionary < Actor , Renderer > ( 32 ) ;
static Renderer BodyRenderer ( Actor a )
{
Renderer r ;
if ( ! s_body . TryGetValue ( a , out r ) | | r = = null )
{
r = a . GetComponentInChildren < SkinnedMeshRenderer > ( ) ;
if ( r = = null ) r = a . GetComponentInChildren < MeshRenderer > ( ) ;
s_body [ a ] = r ;
}
return r ;
}
/// <summary>몸 상자 — 높이 = 원본 HUD_Top(정수리) − 발 · 중심 = 발 + 높이 × bodyCenterFraction · 가로 = 렌더러 bounds(발에서 키 이내일 때만 xz 채택).</summary>
public static WLBodyBox GetBodyBox ( Actor a )
{
var bb = new WLBodyBox ( ) ;
if ( a = = null ) return bb ;
Vector3 feet = a . Get_position ( ) ;
float top ;
try { top = a . Get_Top_postion ( ) . y ; } catch ( System . Exception ) { top = feet . y ; }
float h = top - feet . y ;
var r = BodyRenderer ( a ) ;
Bounds b = r ! = null ? r . bounds : new Bounds ( feet , Vector3 . zero ) ;
if ( h < 0.05f ) { if ( r ! = null & & b . size . y > 0.05f ) { h = b . size . y ; feet . y = b . min . y ; } else h = 1f ; }
float frac = S ! = null ? S . bodyCenterFraction : 0.5f ;
bb . height = h ;
bb . center = new Vector3 ( feet . x , feet . y + h * frac , feet . z ) ;
// #921 PD 「공중 몬스터라도 항상 몸의 정중앙에」 — 떠 있는 몹(그림 바닥이 발보다 flyingGap 넘게 위)은 지금 그림 bounds 의 세로 가운데(박쥐 실측: 발 기준식 0.40 · 그림 가운데 0.54)
if ( r ! = null & & S ! = null & & S . flyingGap > 0f & & b . size . y > 0.05f & & b . min . y - feet . y > S . flyingGap ) bb . center . y = b . center . y ;
if ( r ! = null ) bb . width = Mathf . Max ( b . size . x , b . size . z ) ; // xz 는 발(transform) — 박쥐는 스킨 bounds 중심이 몸에서 0.47 m 벗어나 있었다(2026-10-12 실측)
if ( bb . width < 0.05f ) bb . width = h * 0.6f ;
bb . valid = true ;
return bb ;
}
public static Vector3 BodyCenter ( Actor a ) { return a = = null ? Vector3 . zero : GetBodyBox ( a ) . center ; }
2026-10-18 17:27:34 +00:00
/// <summary>#941 피격 이펙트를 몸 앞(카메라 쪽)에 두는 이동량 — 카메라가 없으면 0. 다른 피격 경로(방패 치기 · 대검 · 투사체별 명중)도 같은 식을 쓴다.</summary>
public static Vector3 TowardCamera ( Vector3 at , float bodyWidth )
{
var s = S ; if ( s = = null | | s . hitTowardCamera < = 0f ) return Vector3 . zero ;
var cam = Camera . main ; if ( cam = = null ) return Vector3 . zero ;
var d = cam . transform . position - at ; if ( d . sqrMagnitude < 1e-4f ) return Vector3 . zero ;
float m = Mathf . Clamp ( bodyWidth * s . hitTowardWidthMul , s . hitTowardMin , s . hitTowardMax ) * s . hitTowardCamera ;
return d . normalized * m ;
}
public static Vector3 TowardCamera ( Actor a ) { if ( a = = null ) return Vector3 . zero ; var bb = GetBodyBox ( a ) ; return TowardCamera ( bb . center , bb . width ) ; }
2026-10-18 15:09:05 +00:00
public static float SizeFactor ( Actor actor )
{
var s = S ; if ( actor = = null | | s = = null | | s . referenceHeight < = 0f ) return 1f ;
float h = GetBodyBox ( actor ) . height ;
if ( h < 0.05f ) return 1f ;
return Mathf . Clamp ( h / s . referenceHeight , s . sizeFactorMin , s . sizeFactorMax ) ;
}
static WLHitFxRule FindHit ( int cls ) { var s = S ; if ( s = = null | | s . hitFx = = null ) return null ; for ( int i = 0 ; i < s . hitFx . Count ; i + + ) if ( s . hitFx [ i ] ! = null & & Contains ( s . hitFx [ i ] . classIds , cls ) ) return s . hitFx [ i ] ; return null ; }
/// <summary>원본 훅 — ProjectileBase.Show_HitEffect. true 면 원본 이펙트를 띄우지 않는다.</summary>
public static bool TryHitEffect ( ProjectileBase pb , bool byHit , Actor target )
{
if ( ! Enabled | | pb = = null ) return false ;
var d = pb . GetComponent < WLDeferredOff > ( ) ;
if ( d ! = null & & d . enabled ) return true ; // 도달 시점에 보여 준다
if ( ! byHit | | target = = null ) return false ;
var hf = pb . GetComponent < WL . Combat . Weapon . WLProjectileHitFx > ( ) ; // #917 투사체별 명중 이펙트(활 화살 · 속사 화살)가 직업 규칙보다 먼저
if ( hf ! = null & & hf . Play ( target ) ) { HitFxShown + + ; return true ; }
return PlayHitOverride ( ShooterClass ( pb ) , target ) ;
}
/// <summary>직업별 피격 이펙트 교체 — 규칙이 있으면 몸 중심에 몸 높이 비례로 띄우고 true.</summary>
public static bool PlayHitOverride ( int shooterClass , Actor target )
{
var rule = FindHit ( shooterClass ) ; if ( rule = = null | | target = = null ) return false ;
if ( ! PlayFit ( rule . prefab , rule . scale , rule . fit , rule . offsetCompensate , target , "hit" , "cls " + shooterClass ) ) return false ;
HitFxShown + + ;
return true ;
}
/// <summary>프리팹을 대상 몸 중심에 띄운다 — fit > 0 이면 이펙트 p75 지름 = fit × 몸 높이(프로필 실측) · 0 이면 scale × 키 비례(구 방식).</summary>
static bool PlayFit ( string prefab , float scale0 , float fit , bool offsetCompensate , Actor target , string kind , string why )
{
var bb = GetBodyBox ( target ) ;
var prof = Profile ( prefab , null ) ;
float scale ; string how ;
if ( fit > 0f & & prof ! = null & & prof . extent > 1e-3f ) { scale = scale0 * fit * bb . height / prof . extent ; how = "fit" ; }
else { scale = scale0 * SizeFactor ( target ) ; how = "legacy" ; }
2026-10-18 17:27:34 +00:00
var toCam = TowardCamera ( bb . center , bb . width ) ; // #941 몸 앞(카메라 쪽)
Vector3 pos = bb . center - ( prof ! = null & & offsetCompensate ? prof . centerOffset * scale : Vector3 . zero ) + toCam ;
2026-10-18 15:09:05 +00:00
if ( Play ( prefab , pos , Quaternion . identity , scale ) = = null ) return false ;
2026-10-18 17:27:34 +00:00
LastHit = kind + " " + prefab + " → " + target . name + " (" + why + ") " + how + " scale=" + scale . ToString ( "F3" ) + " body h=" + bb . height . ToString ( "F2" ) + " w=" + bb . width . ToString ( "F2" ) + " center=" + bb . center . ToString ( "F2" ) + " toCam=" + toCam . magnitude . ToString ( "F2" ) + " feet=" + target . Get_position ( ) . ToString ( "F2" ) ;
2026-10-18 15:09:05 +00:00
LastLog = LastHit ;
return true ;
}
// ── #936 스킬 투사체 피격 이펙트 ──────────────────────────────────────
static readonly Dictionary < Actor , float > s_skillHitAt = new Dictionary < Actor , float > ( 32 ) ;
/// <summary>원본 훅 — ProjectileBase.Show_HitEffect(원본 피격 이펙트가 비어 있는 투사체가 맞은 대상). 투사체별 이펙트(WLProjectileHitFx) → 직업·스킬 규칙(skillHitFx) 순으로 몸 중심에 띄우고 true. 같은 대상에는 skillHitMinInterval 간격.</summary>
public static bool TrySkillHitEffect ( ProjectileBase pb , Actor target )
{
var s = S ; if ( ! Enabled | | s = = null | | pb = = null | | target = = null ) return false ;
var hf = pb . GetComponent < WL . Combat . Weapon . WLProjectileHitFx > ( ) ;
if ( hf ! = null & & hf . Play ( target ) ) { HitFxShown + + ; return true ; }
return PlaySkillHit ( ShooterClass ( pb ) , pb . name , target ) ;
}
/// <summary>#936 주인공(MyValue.MyPC) 직업 규칙으로 스킬 피격 이펙트 — 투사체가 없는 WL 판정(파츠 스킬 대지 분쇄 · 스킬 콜라이더 화살 비)에서 부른다.</summary>
public static bool PlaySkillHitByPC ( Actor target , string name )
{
var pc = MyValue . MyPC as Actor ; if ( pc = = null ) return false ;
return PlaySkillHit ( pc . Get_ID ( ) , name ? ? "" , target ) ;
}
/// <summary>직업 cls · 투사체(스킬) 이름으로 skillHitFx 규칙을 찾아 몸 중심에 띄운다(같은 대상 skillHitMinInterval 간격).</summary>
public static bool PlaySkillHit ( int cls , string name , Actor target )
{
var s = S ; if ( ! Enabled | | s = = null | | s . skillHitFx = = null | | target = = null ) return false ;
for ( int i = 0 ; i < s . skillHitFx . Count ; i + + )
{
var r = s . skillHitFx [ i ] ; if ( r = = null | | ! Contains ( r . classIds , cls ) ) continue ;
if ( r . skills ! = null & & r . skills . Length > 0 )
{
bool ok = false ;
for ( int k = 0 ; k < r . skills . Length ; k + + ) if ( ! string . IsNullOrEmpty ( r . skills [ k ] ) & & name . StartsWith ( r . skills [ k ] ) ) { ok = true ; break ; }
if ( ! ok ) continue ;
}
float last ;
if ( s . skillHitMinInterval > 0f & & s_skillHitAt . TryGetValue ( target , out last ) & & Time . time - last < s . skillHitMinInterval ) { SkillHitThrottled + + ; return true ; }
if ( ! PlayFit ( r . prefab , r . scale , r . fit , r . offsetCompensate , target , "skill-hit" , "cls " + cls + " " + name ) ) return false ;
s_skillHitAt [ target ] = Time . time ; SkillHitFxShown + + ;
return true ;
}
return false ;
}
/// <summary>원본 훅 — ProjectileBase.ProcessCollider. true 면 즉시 Off 하지 않는다(몸 중심 도달 뒤 WLDeferredOff 가 끈다).</summary>
public static bool DeferOff ( ProjectileBase pb , Actor hitter )
{
var s = S ; if ( ! Enabled | | s = = null | | s . deferToCenter = = null | | pb = = null | | hitter = = null ) return false ;
if ( pb . m_Pierce ) return false ; // #918 관통 투사체(활 각성 화살 비 · 속사 화살)는 원본대로 지나간다 — 끄면 다단 히트 · 관통이 첫 명중에서 끊겼다(실측)
if ( ! Contains ( s . deferToCenter . classIds , ShooterClass ( pb ) ) ) return false ;
var d = pb . GetComponent < WLDeferredOff > ( ) ; if ( d = = null ) d = pb . gameObject . AddComponent < WLDeferredOff > ( ) ;
d . Begin ( pb , hitter , s . deferToCenter , IsStraight ( pb ) ) ;
Defers + + ;
return true ;
}
/// <summary>#921 곧게 나는 투사체(deferToCenter.straightProjectiles 이름으로 시작)인가.</summary>
public static bool IsStraight ( ProjectileBase pb )
{
var s = S ; if ( ! Enabled | | s = = null | | s . deferToCenter = = null | | s . deferToCenter . straightProjectiles = = null | | pb = = null ) return false ;
foreach ( var n in s . deferToCenter . straightProjectiles ) if ( ! string . IsNullOrEmpty ( n ) & & pb . name . StartsWith ( n ) ) return true ;
return false ;
}
/// <summary>#921 원본 ProjectileBase.Set 끝 훅 1줄 — 곧게 나는 투사체는 쏘는 순간 대상 몸 중심을 겨눈다(원본 Shooter 시작 = 대상 발을 겨눔).</summary>
public static void AimLaunch ( ProjectileBase pb , ProjectileData data )
{
if ( data = = null | | DSUtil . CheckNull ( data . target ) | | ! IsStraight ( pb ) ) return ;
var c = BodyCenter ( data . target ) ;
if ( ( c - pb . transform . position ) . sqrMagnitude > 1e-4f ) { pb . transform . LookAt ( c ) ; Aims + + ; }
}
public static int Aims ;
// ── 궤적(회전형) 교체 — 베이크 프레임 정합 ──────────────────────────────
/// <summary>WeaponTrailDriver 분기 — ShowEffect 클립 이벤트(Effect_Slash_{cls}_{stage})를 회전형 이펙트로 바꾼다.
/// 궤적 베이크(SwingCalibration: 평면 법선·시작 방향·이등분·칼끝 반경·sweep)가 있으면 이펙트의 실측 방출 궤도(축·시작·반경)를 그 프레임에 맞춘다.</summary>
public static bool TrySwingOverride ( PCActor pc , AnimationEvent ev )
{
var s = S ; if ( ! Enabled | | s = = null | | s . swingFx = = null | | pc = = null | | ev = = null ) return false ;
int cls = pc . Get_ID ( ) ; int stage = ParseStage ( ev . stringParameter ) ;
for ( int i = 0 ; i < s . swingFx . Count ; i + + )
{
var r = s . swingFx [ i ] ;
if ( r = = null | | ! Contains ( r . classIds , cls ) | | ( r . stages ! = null & & r . stages . Length > 0 & & ! Contains ( r . stages , stage ) ) ) continue ;
var tf = pc . transform ;
2026-10-18 17:27:34 +00:00
Quaternion rot ; Vector3 pos ; float scale ; string how ; bool cal_used = false ;
2026-10-18 15:09:05 +00:00
SwingCalibration cal = null ;
var prof = r . calibrated ? Profile ( r . prefab , r . emitter ) : null ;
if ( r . calibrated & & SlashTrailSettings . Instance ! = null ) cal = SlashTrailSettings . Instance . FindCalibration ( cls , ev . stringParameter ) ;
if ( cal ! = null & & cal . kind ! = SwingKind . Stab & & cal . sweepDeg > 1f & & cal . tipRadius > 1e-3f & & prof ! = null & & prof . sweepValid )
{
// 목표 프레임(월드) — 회전 축은 「시작 → 이등분」 방향으로 도는 쪽(sweep > 180° 도 반각 비교로 판정) · 기준 방향 = 궤적 이등분(호의 가운데)
Vector3 nW = tf . rotation * cal . planeNormal , sW = tf . rotation * cal . startDir , bW = tf . rotation * cal . bisector ;
nW . Normalize ( ) ;
float a = Vector3 . SignedAngle ( sW , bW , nW ) , half = cal . sweepDeg * 0.5f ;
bool positive = Mathf . Abs ( Mathf . DeltaAngle ( a , half ) ) < = Mathf . Abs ( Mathf . DeltaAngle ( a , - half ) ) ;
Vector3 axis = ( positive ? nW : - nW ) * ( r . sweepFlip ? - 1f : 1f ) ;
bW - = axis * Vector3 . Dot ( bW , axis ) ; if ( bW . sqrMagnitude < 1e-6f ) bW = Vector3 . Cross ( axis , tf . forward ) ; bW . Normalize ( ) ;
// 이펙트 프레임(프리팹 로컬) — 프로필의 회전 축(같은 cross 정의) · 질량(머리) 방향 ↔ 궤적 이등분
Vector3 nE = prof . sweepNormal , sE = prof . sweepStart ; sE - = nE * Vector3 . Dot ( sE , nE ) ; sE . Normalize ( ) ;
rot = Quaternion . LookRotation ( axis , bW ) * Quaternion . Inverse ( Quaternion . LookRotation ( nE , sE ) ) ;
scale = r . scale * r . radiusFit * cal . tipRadius / Mathf . Max ( prof . sweepRadius , 1e-3f ) ;
float tilt = 0f ;
if ( r . minFacing > 0f )
{
var q = r . facingAxis = = 1 ? ChordTilt ( axis , Vector3 . Cross ( axis , bW ) , r . minFacing , r . maxTilt , out tilt ) : FaceTilt ( axis , r . minFacing , out tilt ) ;
if ( tilt ! = 0f ) { rot = q * rot ; SwingTilted + + ; }
}
Vector3 pivotW = tf . position + tf . rotation * cal . pivot ;
pos = pivotW - rot * ( prof . sweepCenter * scale ) + Vector3 . up * r . yOffset ;
2026-10-18 17:27:34 +00:00
SwingCalibrated + + ; cal_used = true ; how = "cal tilt=" + tilt . ToString ( "F0" ) + " axis=" + axis . ToString ( "F2" ) + " bis=" + bW . ToString ( "F2" ) + " sweep=" + cal . sweepDeg . ToString ( "F0" ) + ( positive ? "+" : "-" ) + " tipR=" + cal . tipRadius . ToString ( "F2" ) + " pivot=" + pivotW . ToString ( "F2" ) + " effR=" + prof . sweepRadius . ToString ( "F2" ) ;
2026-10-18 15:09:05 +00:00
}
else
{
2026-10-18 17:27:34 +00:00
if ( r . minInterval > 0f ) // #940 연타 이벤트 묶음 — 첫 이벤트만 띄우고 나머지는 궤적만 막는다
{
long key = ( ( long ) pc . GetInstanceID ( ) < < 4 ) | ( long ) ( stage & 15 ) ; float last ;
if ( s_swingAt . TryGetValue ( key , out last ) & & Time . time - last < r . minInterval ) { SwingThrottled + + ; LastSwing = "swing throttled stage " + stage ; return true ; }
s_swingAt [ key ] = Time . time ;
}
2026-10-18 15:09:05 +00:00
rot = tf . rotation * ( r . euler ! = null & & r . euler . Length > = 3 ? Quaternion . Euler ( r . euler [ 0 ] , r . euler [ 1 ] , r . euler [ 2 ] ) : Quaternion . identity ) ;
scale = r . scale * r . fallbackScale * SizeFactor ( pc ) ;
2026-10-18 17:27:34 +00:00
pos = pc . Get_Center_position ( ) + tf . forward * r . forward + tf . right * r . side + Vector3 . up * r . yOffset ;
how = "legacy(" + ( cal = = null ? "no-cal" : prof = = null ? "no-profile" : ! prof . sweepValid ? "no-sweep" : "cal-kind" ) + ")" + ( r . follow ? " follow" : "" ) ;
2026-10-18 15:09:05 +00:00
}
2026-10-18 17:27:34 +00:00
var inst = Play ( r . prefab , pos , rot , scale ) ; if ( inst = = null ) return false ;
if ( r . follow ) inst . transform . SetParent ( tf , true ) ; // #940 PC 를 따라간다(풀 반환 때 WLImpactOneShot 이 원래 부모로 되돌린다)
if ( r . life > 0f ) { var os = inst . GetComponent < WLImpactOneShot > ( ) ; if ( os ! = null ) os . OverrideLife ( r . life ) ; }
2026-10-18 18:48:42 +00:00
var fw = inst . GetComponent < WLWeaponFollowFx > ( ) ; // #941 무기 방향 추종 · #943-⑤ 고정 방향
if ( ( r . alignWeapon | | r . fixedDir ) & & ! cal_used ) { if ( fw = = null ) fw = inst . AddComponent < WLWeaponFollowFx > ( ) ; if ( fw . Arm ( pc , r , scale ) ) how + = r . fixedDir ? " fixedDir" : " alignWeapon" ; else how + = " alignWeapon(no-socket)" ; }
2026-10-18 17:27:34 +00:00
else if ( fw ! = null ) fw . Disarm ( ) ;
2026-10-18 15:09:05 +00:00
SwingShown + + ; LastSwing = "swing " + r . prefab + " stage " + stage + " " + how + " scale=" + scale . ToString ( "F3" ) ; LastLog = LastSwing ;
2026-10-21 01:28:52 +00:00
if ( r . blinkDistance > 0f | | r . camHoldSeconds > 0f | | r . spinDegrees ! = 0f ) WLSwingExtras . Begin ( pc , r , inst ) ; // #955
2026-10-18 15:09:05 +00:00
return true ;
}
return false ;
}
2026-10-18 17:27:34 +00:00
public static int SwingTilted , SwingThrottled ;
static readonly Dictionary < long , float > s_swingAt = new Dictionary < long , float > ( ) ; // #940 (PC, 단계) → 마지막으로 띄운 시각
2026-10-18 15:09:05 +00:00
/// <summary>#928 PD 「대검 1타 공격이 보는 방향에 따라서 너무 얇게 보여서 … 어느 방향에서 보더라도 검기처럼 보이게」 —
/// 초승달 면 법선 n 과 시선(Camera.main)의 |cos| 이 minFacing 보다 작으면 n 을 시선 쪽(가까운 쪽 부호)으로 그 값이 될 만큼만 돌리는 회전(축 = n × 시선 · 가장 작은 회전) · deg = 돌린 각.
/// 호 가운데 축으로만 돌리면 호 가운데 방향이 카메라를 향할 때(대검 1타를 동쪽으로 칠 때 |cos| 최대 0.26) 끝까지 얇았다(Play 캡처).</summary>
static Quaternion FaceTilt ( Vector3 n , float minFacing , out float deg )
{
deg = 0f ;
var cam = Camera . main ; if ( cam = = null ) return Quaternion . identity ;
var c = cam . transform . forward ; n . Normalize ( ) ;
float d = Vector3 . Dot ( n , c ) ;
if ( Mathf . Abs ( d ) > = minFacing ) return Quaternion . identity ;
var t = d > = 0f ? c : - c ; // 가까운 쪽으로
var ax = Vector3 . Cross ( n , t ) ; if ( ax . sqrMagnitude < 1e-8f ) return Quaternion . identity ; ax . Normalize ( ) ;
float now = Mathf . Acos ( Mathf . Clamp01 ( Mathf . Abs ( d ) ) ) * Mathf . Rad2Deg , want = Mathf . Acos ( Mathf . Clamp01 ( minFacing ) ) * Mathf . Rad2Deg ;
deg = now - want ;
var q = Quaternion . AngleAxis ( deg , ax ) ;
if ( Mathf . Abs ( Vector3 . Dot ( q * n , c ) ) + 1e-3f < minFacing ) { q = Quaternion . AngleAxis ( - deg , ax ) ; deg = - deg ; } // 회전 부호 확인
return q ;
}
/// <summary>#932 현(chord) 축 기울임 — 면 법선 n 을 현 방향 t(= n × 이등분) 둘레로 돌려 |n·시선| 이 minFacing 에 닿는 가장 작은 각(양쪽 부호 · 1° 단위 · 최대 maxDeg) ·
/// 현 방향은 그대로라 화면에서 베는 대각선은 유지되고 초승달 가운데(이등분 쪽)만 카메라 쪽으로 펼쳐진다 · 못 닿으면 maxDeg 안에서 가장 넓게 보이는 각.</summary>
static Quaternion ChordTilt ( Vector3 n , Vector3 t , float minFacing , float maxDeg , out float deg )
{
deg = 0f ;
var cam = Camera . main ; if ( cam = = null | | t . sqrMagnitude < 1e-8f ) return Quaternion . identity ;
var c = cam . transform . forward ; n . Normalize ( ) ; t . Normalize ( ) ;
float best = Mathf . Abs ( Vector3 . Dot ( n , c ) ) ; if ( best > = minFacing ) return Quaternion . identity ;
float bestDeg = 0f ;
for ( float a = 1f ; a < = maxDeg ; a + = 1f )
for ( int k = 0 ; k < 2 ; k + + )
{
float d = k = = 0 ? a : - a , f = Mathf . Abs ( Vector3 . Dot ( Quaternion . AngleAxis ( d , t ) * n , c ) ) ;
if ( f > = minFacing ) { deg = d ; return Quaternion . AngleAxis ( d , t ) ; }
if ( f > best + 1e-4f ) { best = f ; bestDeg = d ; }
}
deg = bestDeg ; return Quaternion . AngleAxis ( bestDeg , t ) ;
}
static int ParseStage ( string s )
{
if ( string . IsNullOrEmpty ( s ) ) return 0 ;
int i = s . LastIndexOf ( '_' ) ;
int v ; return i > = 0 & & int . TryParse ( s . Substring ( i + 1 ) , out v ) ? v : 0 ;
}
// ── 지면 충격파 — 콤보 단계 진입 → 칼끝 추적 → 접점 ─────────────────────
static WLGroundFxRule FindGround ( int cls , int stage ) { var s = S ; if ( s = = null | | s . groundFx = = null ) return null ; for ( int i = 0 ; i < s . groundFx . Count ; i + + ) { var r = s . groundFx [ i ] ; if ( r ! = null & & r . stage = = stage & & Contains ( r . classIds , cls ) ) return r ; } return null ; }
static void OnComboStage ( in ComboStageEvent e )
{
if ( ! Enabled | | e . actor = = null | | ! e . actor . IsMainPC ( ) ) return ;
if ( e . actor . Get_CurAnim ( ) = = eAnim . Skill ) return ; // #911 스킬이 1타 동작을 빌려 쓸 때(해머 각성 내려치기)는 기본 공격 지면 충격을 띄우지 않는다
var r = FindGround ( e . actor . Get_ID ( ) , e . stage ) ; if ( r = = null | | ! r . tipContact ) return ;
var runner = e . actor . GetComponent < WLImpactRunner > ( ) ; if ( runner = = null ) runner = e . actor . gameObject . AddComponent < WLImpactRunner > ( ) ;
runner . Watch ( e . actor , r , e . stage ) ;
}
static void OnHitboxSpawned ( in HitboxSpawnedEvent e )
{
var s = S ; if ( ! Enabled | | s = = null | | s . groundFx = = null | | e . shooter = = null | | ! e . shooter . IsMainPC ( ) ) return ;
if ( e . shooter . Get_CurAnim ( ) = = eAnim . Skill ) return ; // #929 스킬이 기본 공격 상태를 빌려 쓸 때(대검 주력 = 4타 동작) 그 스킬 투사체 판정에 기본 4타 지면 충격(노란 폭발)이 같이 뜨던 것
var ct = e . shooter . GetComponent < ComboTracker > ( ) ;
int stage = ct ! = null & & ct . LastStage > 0 ? ct . LastStage : ( int ) e . shooter . Get_CurAnim ( ) - ( int ) eAnim . Attack + 1 ;
int cls = e . shooter . Get_ID ( ) ;
var r = FindGround ( cls , stage ) ; if ( r = = null ) return ;
var runner = e . shooter . GetComponent < WLImpactRunner > ( ) ;
if ( runner ! = null ) { runner . NoteHitbox ( r , stage ) ; if ( runner . FireAtHitbox ( r , stage ) ) return ; } // 접점이 먼저면 이미 떴고, 아니면 타격 시점의 무기 끝 자리에
var fwd = e . shooter . transform . forward ; fwd . y = 0f ; fwd . Normalize ( ) ;
var pos = new Vector3 ( e . startPos . x , e . shooter . Get_position ( ) . y , e . startPos . z ) + fwd * r . forward ;
PlayGround ( r , e . shooter , pos , "hitbox" ) ;
}
internal static void PlayGround ( WLGroundFxRule r , Actor shooter , Vector3 pos , string how )
{
if ( Play ( r . prefab , pos , Quaternion . Euler ( 0f , shooter . transform . eulerAngles . y , 0f ) , r . scale * SizeFactor ( shooter ) ) = = null ) return ;
GroundShown + + ; if ( how . StartsWith ( "contact" ) ) GroundContacts + + ; else GroundFallbacks + + ;
LastGround = "ground " + r . prefab + " stage " + r . stage + " " + how + " @" + pos . ToString ( "F2" ) ; LastLog = LastGround ;
}
// ── 프리팹 프로필(스케일 1 실측) ───────────────────────────────────────
static readonly Dictionary < string , WLFxProfile > s_profiles = new Dictionary < string , WLFxProfile > ( ) ;
static readonly Dictionary < string , int > s_profileTries = new Dictionary < string , int > ( ) ;
static ParticleSystem . Particle [ ] s_buf ;
/// <summary>프리팹 프로필 — 첫 호출 때 보이지 않는 곳(y − 1000)에 세워 Simulate 로 잰다. emitter 가 있으면 그 방출기의 궤도를, 없으면 가장 뚜렷한 궤도를 쓴다.</summary>
public static WLFxProfile Profile ( string prefab , string emitter )
{
var s = S ; if ( s = = null | | ! s . profileWarmup | | string . IsNullOrEmpty ( prefab ) ) return null ;
string key = prefab + "|" + ( emitter ? ? "" ) ;
WLFxProfile prof ;
if ( s_profiles . TryGetValue ( key , out prof ) ) return prof ;
prof = Measure ( prefab , emitter , s . profileSeconds ) ;
// 궤적용(emitter 지정)인데 궤도를 못 잡았으면 3회까지 다음 호출에서 다시 잰다(2026-10-12 첫 측정 1회 실패 실측 · 원인 미확인)
int tries ; s_profileTries . TryGetValue ( key , out tries ) ; s_profileTries [ key ] = tries + 1 ;
if ( prof = = null | | prof . sweepValid | | string . IsNullOrEmpty ( emitter ) | | tries > = 2 ) s_profiles [ key ] = prof ;
return prof ;
}
struct Orbit { public ParticleSystem ps ; public List < Vector3 > pts ; public Vector3 normal , start , center ; public float radius , consistency ; public bool valid ; public bool byRotation ; public Vector3 rotAxis ; public float rotDeg ; public float sizeMed , distP90 ; public int count ; public string why ; }
/// <summary>프로필 실측 — 각 파티클 시스템을 Simulate 로 돌리며 (randomSeed 로 식별한) 새 파티클의 방출점·크기·거리를 모은다.
/// 치수 = 2 × (|위치| + 크기/2 의 75 백분위) · 중심 오프셋 = 크기 가중 위치 평균(첫 0.5 s) · 궤도 = 연속 방출점(없으면 메시 파티클의 회전 축).</summary>
static WLFxProfile Measure ( string prefab , string emitter , float seconds )
{
GameObject p ; if ( ! s_prefabs . TryGetValue ( prefab , out p ) | | p = = null ) { p = Resources . Load < GameObject > ( ResourcesDir + prefab ) ; s_prefabs [ prefab ] = p ; }
if ( p = = null ) return null ;
var prof = new WLFxProfile { prefab = prefab } ;
var origin = new Vector3 ( 0f , - 1000f , 0f ) ;
var inst = Object . Instantiate ( p , origin , Quaternion . identity ) ;
inst . name = prefab + "_profile" ;
try
{
inst . transform . localScale = Vector3 . one ;
inst . SetActive ( true ) ;
var systems = inst . GetComponentsInChildren < ParticleSystem > ( true ) ;
var orbits = new List < Orbit > ( systems . Length ) ;
const float dt = 1f / 60f ; int steps = Mathf . Clamp ( Mathf . CeilToInt ( seconds / dt ) , 10 , 400 ) ;
if ( s_buf = = null | | s_buf . Length < 4096 ) s_buf = new ParticleSystem . Particle [ 4096 ] ;
var seen = new HashSet < uint > ( ) ;
var reach = new List < float > ( 4096 ) ; // |위치| + 크기/2 (전 시스템 · 시간 균등)
Vector3 wsum = Vector3 . zero ; float wtot = 0f ; // 첫 0.5 s 크기 가중 위치 합
Bounds union = new Bounds ( origin , Vector3 . zero ) ; bool any = false ;
for ( int i = 0 ; i < systems . Length ; i + + )
{
var ps = systems [ i ] ; var psr = ps . GetComponent < ParticleSystemRenderer > ( ) ;
var main = ps . main ; bool worldSpace = main . simulationSpace = = ParticleSystemSimulationSpace . World ;
bool mesh = psr ! = null & & psr . renderMode = = ParticleSystemRenderMode . Mesh ;
var ob = new Orbit { ps = ps , pts = new List < Vector3 > ( steps ) } ;
var sizes = new List < float > ( 256 ) ; var dists = new List < float > ( 256 ) ;
// 메시 파티클 — 메시 에셋 정점(피벗 기준 평면·질량 방향·반경) + 파티클 회전값(시간 변화 부호)으로 축·머리·반경을 잰다.
// (BakeMesh 는 2026-10-12 OOM 크래시 — 쓰지 않는다. 회전 부호는 Transform 과 같은 좌표계로 가정 · JSON sweepFlip 으로 뒤집을 수 있다)
int sampleK = Mathf . RoundToInt ( 0.15f / dt ) ;
uint trackSeed = 0 ; bool tracking = false ; Vector3 prevRot = Vector3 . zero , rotSum = Vector3 . zero , rotAt = Vector3 . zero , posAt = Vector3 . zero ; int rotN = 0 ; float sizeAt = 1f ; bool sampled = false ;
seen . Clear ( ) ;
ps . Simulate ( 0f , false , true , false ) ;
for ( int k = 0 ; k < steps ; k + + )
{
ps . Simulate ( dt , false , false , false ) ;
int n = ps . GetParticles ( s_buf ) ;
Vector3 young = Vector3 . zero ; int yc = 0 ;
for ( int j = 0 ; j < n ; j + + )
{
var pt = s_buf [ j ] ;
Vector3 w = ( worldSpace ? pt . position : ps . transform . TransformPoint ( pt . position ) ) - origin ;
float sz = pt . GetCurrentSize ( ps ) ;
if ( seen . Add ( pt . randomSeed ) ) { young + = w ; yc + + ; }
if ( ( k & 3 ) = = 0 ) { sizes . Add ( sz ) ; float d = w . magnitude ; dists . Add ( d ) ; reach . Add ( d + sz * 0.5f ) ; }
if ( k * dt < = 0.5f ) { wsum + = w * sz ; wtot + = sz ; }
var bb = new Bounds ( w , new Vector3 ( sz , sz , sz ) ) ; if ( ! any ) { union = bb ; any = true ; } else union . Encapsulate ( bb ) ;
if ( mesh )
{
if ( ! tracking ) { tracking = true ; trackSeed = pt . randomSeed ; prevRot = pt . rotation3D ; }
else if ( pt . randomSeed = = trackSeed ) { var dr = pt . rotation3D - prevRot ; prevRot = pt . rotation3D ; if ( dr . sqrMagnitude < 90f * 90f ) { rotSum + = dr ; rotN + + ; } }
if ( ! sampled & & k > = sampleK & & pt . randomSeed = = trackSeed ) { sampled = true ; rotAt = pt . rotation3D ; posAt = w ; sizeAt = sz ; }
}
}
if ( yc > 0 ) ob . pts . Add ( young / yc ) ;
}
ob . count = seen . Count ;
if ( sizes . Count > 0 ) { sizes . Sort ( ) ; dists . Sort ( ) ; ob . sizeMed = sizes [ sizes . Count / 2 ] ; ob . distP90 = dists [ Mathf . Min ( dists . Count - 1 , ( int ) ( dists . Count * 0.9f ) ) ] ; }
AnalyzeOrbit ( ref ob ) ;
if ( mesh ) ob . why = "mesh=" + ( psr . mesh ! = null ? psr . mesh . name : "null" ) + " sampled=" + sampled + " rotN=" + rotN + " spin=" + rotSum . ToString ( "F0" ) ;
if ( ! ob . valid & & mesh & & sampled & & psr . mesh ! = null )
{
MeshShape ms = MeshShapeOf ( psr . mesh ) ;
ob . why + = " shape=" + ( ms . valid ? "ok v=" + ms . verts : "bad " + ms . err ) ;
if ( ms . valid )
{
// 파티클 프레임(프리팹 로컬) = 시스템 회전 × 파티클 회전(Euler · Transform 과 같은 부호 가정)
Quaternion R = ps . transform . rotation * Quaternion . Euler ( rotAt ) ;
Vector3 nM = R * ms . normal ; // 메시 평면 법선(부호 임의)
Vector3 head = R * ms . massDir ; // 질량 방향(초승달 이등분 · 머리 쪽)
// 회전 방향 → 축 부호: 시간 변화가 가장 큰 Euler 성분의 부호(Transform 규약: +θ about +axis = cross 정의의 +axis)
float sign = 0f ;
if ( rotN > = 3 & & rotSum . magnitude > 3f )
{
int ax = Mathf . Abs ( rotSum . x ) > = Mathf . Abs ( rotSum . y ) & & Mathf . Abs ( rotSum . x ) > = Mathf . Abs ( rotSum . z ) ? 0 : Mathf . Abs ( rotSum . y ) > = Mathf . Abs ( rotSum . z ) ? 1 : 2 ;
Vector3 localAx = ax = = 0 ? Vector3 . right : ax = = 1 ? Vector3 . up : Vector3 . forward ;
Vector3 spin = ( ps . transform . rotation * localAx ) * Mathf . Sign ( rotSum [ ax ] ) ;
sign = Vector3 . Dot ( spin , nM ) > = 0f ? 1f : - 1f ; // 메시 법선을 회전 축 쪽으로 맞춘다
ob . rotDeg = rotSum [ ax ] ;
}
if ( sign = = 0f ) sign = 1f ;
ob . byRotation = true ; ob . normal = ob . rotAxis = nM * sign ; ob . center = posAt ;
float lossy = ps . transform . lossyScale . x ;
ob . radius = ms . radius * sizeAt * ( lossy > 1e-3f ? lossy : 1f ) ;
head - = ob . normal * Vector3 . Dot ( head , ob . normal ) ;
if ( head . sqrMagnitude < 1e-6f ) { head = Vector3 . Cross ( ob . normal , Vector3 . up ) ; if ( head . sqrMagnitude < 1e-6f ) head = Vector3 . Cross ( ob . normal , Vector3 . right ) ; }
ob . start = head . normalized ; ob . consistency = rotN > = 3 ? 0.8f : 0.4f ; ob . valid = true ;
}
}
orbits . Add ( ob ) ;
}
if ( reach . Count > 0 ) { reach . Sort ( ) ; prof . extent = 2f * reach [ Mathf . Min ( reach . Count - 1 , ( int ) ( reach . Count * 0.75f ) ) ] ; }
if ( wtot > 1e-6f ) prof . centerOffset = wsum / wtot ;
if ( any ) { union . center - = origin ; prof . bounds = union ; }
// 궤도 선택 — 이름 지정 > 방출점 궤도(반경 × 표본 × 일관성) > 회전 축
int best = - 1 ; float bestScore = 0f ;
var sb = new System . Text . StringBuilder ( ) ;
sb . Append ( prefab ) . Append ( " extent(p75 지름)=" ) . Append ( prof . extent . ToString ( "F2" ) ) . Append ( " centerOffset=" ) . Append ( prof . centerOffset . ToString ( "F2" ) ) . Append ( " aabb=" ) . Append ( prof . bounds . size . ToString ( "F1" ) ) ;
for ( int i = 0 ; i < orbits . Count ; i + + )
{
var ob = orbits [ i ] ;
sb . Append ( "\n " ) . Append ( ob . ps . name ) . Append ( " n=" ) . Append ( ob . count ) . Append ( " sizeMed=" ) . Append ( ob . sizeMed . ToString ( "F2" ) ) . Append ( " distP90=" ) . Append ( ob . distP90 . ToString ( "F2" ) ) . Append ( " pts=" ) . Append ( ob . pts . Count ) . Append ( string . IsNullOrEmpty ( ob . why ) ? "" : " [" + ob . why + "]" )
. Append ( ob . valid ? ( ob . byRotation ? " MESH axis=" + ob . rotAxis . ToString ( "F2" ) + " spinDeg=" + ob . rotDeg . ToString ( "F0" ) + " r=" + ob . radius . ToString ( "F2" ) + " head=" + ob . start . ToString ( "F2" ) + " c=" + ob . center . ToString ( "F2" ) : " ORBIT r=" + ob . radius . ToString ( "F2" ) + " cons=" + ob . consistency . ToString ( "F2" ) + " n=" + ob . normal . ToString ( "F2" ) + " s=" + ob . start . ToString ( "F2" ) + " c=" + ob . center . ToString ( "F2" ) ) : "" ) ;
if ( ! ob . valid ) continue ;
bool named = ! string . IsNullOrEmpty ( emitter ) & & ob . ps . name = = emitter ;
float score = ( named ? 1000f : 1f ) * ( ob . byRotation ? 0.01f * Mathf . Abs ( ob . rotDeg ) * ob . radius * ob . consistency : ob . radius * ob . pts . Count * ob . consistency ) ;
if ( named ) score = Mathf . Max ( score , 1e6f ) ; // #925 이름 지정 방출기는 회전이 없어도(대검 잉크 베기 = 돌지 않는 초승달 메시 · UV 로 쓸고 지나감) 그대로 쓴다
if ( score > bestScore ) { bestScore = score ; best = i ; }
}
if ( best > = 0 )
{
var ob = orbits [ best ] ;
prof . sweepValid = true ; prof . sweepEmitter = ob . ps . name ; prof . sweepNormal = ob . normal ; prof . sweepStart = ob . start ; prof . sweepCenter = ob . center ; prof . sweepRadius = ob . radius ; prof . sweepConsistency = ob . consistency ; prof . sweepSamples = ob . pts . Count ;
sb . Append ( "\n → sweep " ) . Append ( ob . ps . name ) . Append ( ob . byRotation ? " (메시 형상+회전)" : " (방출 궤도)" ) ;
}
prof . log = sb . ToString ( ) ;
}
catch ( System . Exception ex ) { prof . log = "profile error " + ex . Message + "\n" + ex . StackTrace ; }
finally { Object . Destroy ( inst ) ; }
return prof ;
}
/// <summary>메시 에셋 형상(메시 로컬) — bounds 만 쓴다(정점은 비-읽기 메시가 한 번 렌더된 뒤 0개를 돌려줘 2026-10-12 첫 측정이 상황 따라 실패했다).
/// 평면 법선 = 가장 얇은 축 · 질량(머리) 방향 = bounds 중심의 평면 내 성분 · 반경 = 평면 내 반폭 최대 + 중심 치우침.</summary>
struct MeshShape { public bool valid ; public Vector3 normal , massDir ; public float radius ; public int verts ; public string err ; }
static readonly Dictionary < Mesh , MeshShape > s_meshShapes = new Dictionary < Mesh , MeshShape > ( ) ;
static MeshShape MeshShapeOf ( Mesh m )
{
MeshShape ms ;
if ( s_meshShapes . TryGetValue ( m , out ms ) ) return ms ;
ms = new MeshShape ( ) ;
try
{
var b = m . bounds ; var ext = b . size ;
int thin = ext . x < = ext . y & & ext . x < = ext . z ? 0 : ext . y < = ext . z ? 1 : 2 ;
ms . normal = thin = = 0 ? Vector3 . right : thin = = 1 ? Vector3 . up : Vector3 . forward ;
Vector3 c = b . center ; Vector3 cp = c - ms . normal * Vector3 . Dot ( c , ms . normal ) ;
float half = 0f ; for ( int i = 0 ; i < 3 ; i + + ) if ( i ! = thin ) half = Mathf . Max ( half , ext [ i ] * 0.5f ) ;
ms . radius = half + cp . magnitude ;
ms . massDir = cp ;
ms . verts = m . vertexCount ; ms . valid = ms . radius > 1e-4f ;
if ( ! ms . valid ) ms . err = "bounds r=0" ;
}
catch ( System . Exception ex ) { ms . valid = false ; ms . err = ex . Message ; }
s_meshShapes [ m ] = ms ;
return ms ;
}
/// <summary>연속 방출점의 궤도 — 중심 기준 반경 벡터의 연속 cross 합 = 회전 축(같은 정의를 목표 궤적에도 쓴다) · 일관성 = |합| / 절대값 합.</summary>
static void AnalyzeOrbit ( ref Orbit ob )
{
var pts = ob . pts ; int n = pts . Count ; ob . valid = false ;
if ( n < 6 ) return ;
Vector3 c = Vector3 . zero ; for ( int i = 0 ; i < n ; i + + ) c + = pts [ i ] ; c / = n ;
Vector3 sum = Vector3 . zero ; float abs = 0f ; float rad = 0f ; int cnt = 0 ;
for ( int i = 0 ; i + 1 < n ; i + + )
{
Vector3 a = pts [ i ] - c , b = pts [ i + 1 ] - c ;
if ( a . sqrMagnitude < 1e-6f | | b . sqrMagnitude < 1e-6f ) continue ;
var cr = Vector3 . Cross ( a , b ) ; sum + = cr ; abs + = cr . magnitude ; rad + = a . magnitude ; cnt + + ;
}
if ( cnt < 5 | | abs < 1e-6f ) return ;
float cons = sum . magnitude / abs ; rad / = cnt ;
if ( cons < 0.6f | | rad < 0.02f ) return ;
ob . center = c ; ob . normal = sum . normalized ; ob . radius = rad ; ob . consistency = cons ;
Vector3 s0 = pts [ 0 ] - c ; s0 - = ob . normal * Vector3 . Dot ( s0 , ob . normal ) ;
ob . start = s0 . sqrMagnitude > 1e-6f ? s0 . normalized : Vector3 . Cross ( ob . normal , Vector3 . up ) . normalized ;
ob . valid = true ;
}
// ── 위치 기반 1회 이펙트 풀 ──────────────────────────────────────────
static readonly Dictionary < string , GameObject > s_prefabs = new Dictionary < string , GameObject > ( ) ;
static readonly Dictionary < string , List < GameObject > > s_pool = new Dictionary < string , List < GameObject > > ( ) ;
static Transform s_root ;
public static GameObject Play ( string prefab , Vector3 pos , Quaternion rot , float scale )
{
if ( string . IsNullOrEmpty ( prefab ) ) return null ;
GameObject p ;
if ( ! s_prefabs . TryGetValue ( prefab , out p ) | | p = = null ) { p = Resources . Load < GameObject > ( ResourcesDir + prefab ) ; s_prefabs [ prefab ] = p ; }
if ( p = = null ) { Misses + + ; LastLog = "missing prefab " + ResourcesDir + prefab ; return null ; }
List < GameObject > list ; GameObject go = null ;
if ( s_pool . TryGetValue ( prefab , out list ) ) for ( int i = 0 ; i < list . Count ; i + + ) if ( list [ i ] ! = null & & ! list [ i ] . activeSelf ) { go = list [ i ] ; break ; }
if ( go = = null )
{
if ( s_root = = null ) { var r = new GameObject ( "WL_ImpactFx" ) ; Object . DontDestroyOnLoad ( r ) ; s_root = r . transform ; }
go = Object . Instantiate ( p , s_root ) ; go . name = prefab ; go . SetActive ( false ) ;
2026-10-18 17:27:34 +00:00
var one = go . GetComponent < WLImpactOneShot > ( ) ; if ( one = = null ) one = go . AddComponent < WLImpactOneShot > ( ) ; one . home = s_root ;
2026-10-18 15:09:05 +00:00
if ( list = = null ) { list = new List < GameObject > ( 4 ) ; s_pool [ prefab ] = list ; }
list . Add ( go ) ;
}
go . transform . SetPositionAndRotation ( pos , rot ) ;
go . transform . localScale = Vector3 . one * Mathf . Max ( 0.01f , scale ) ;
go . SetActive ( true ) ;
return go ;
}
public static void ClearPool ( )
{
foreach ( var kv in s_pool ) foreach ( var g in kv . Value ) if ( g ! = null ) Object . Destroy ( g ) ;
s_pool . Clear ( ) ; s_prefabs . Clear ( ) ; s_body . Clear ( ) ; s_profiles . Clear ( ) ; s_skillHitAt . Clear ( ) ;
2026-10-21 04:05:18 +00:00
s_swingAt . Clear ( ) ; // #958 도메인 리로드 꺼짐 — 지난 Play 의 시각(Time.time)이 남으면 새 Play(시각 0부터)의 같은 PC·단계 궤적 이펙트가 「throttled」로 막혀 비수 3타 이펙트·몸 회전이 안 나왔다(실측: 4타 콤보 6 s · swing +0 · spins +0)
2026-10-18 15:09:05 +00:00
}
}
2026-10-21 01:28:52 +00:00
/// <summary>#955 PD 비수 4타 「잔상을 남기며 0.5 m 쯤 순간이동하듯 나타나 베기 · 적을 뚫고 가도 카메라를 돌리지 말고 살짝 줌아웃했다 복귀」 · 3타 「한바퀴 회전」 —
/// 띄우는 순간: 정면 blinkDistance 로 워프(NavMesh 위) + 길에 잔상(WLGhostStrike 살아 있는 자세) · camHoldSeconds 동안 카메라 회전·PC 기준 오프셋 고정 + sin 곡선 줌아웃 · spinDegrees 를 spinSeconds 에 걸쳐 회전(이펙트는 PC 에 붙어 함께 돈다).</summary>
public sealed class WLSwingExtras : MonoBehaviour
{
2026-10-21 01:54:38 +00:00
PCActor _pc ; float _spinLeft , _spinRate , _spinAcc ; bool _spinning ; Transform _spinBone ; GameObject _fx ; Vector3 _fxPos ; Quaternion _fxRot ;
2026-10-21 04:05:18 +00:00
int _axis ; float _yaw0 , _blendDur , _blendT ; bool _blending ; // #958 yaw 덮어쓰기 — 시작 때 엉덩이 수평 축 · 그때 yaw · 끝난 뒤 애니메이터 자세로 섞는 시간
2026-10-21 01:54:38 +00:00
public static int Blinks , Spins , CamHolds ; public static string LastLog = "" , SpinLog = "" ;
2026-10-21 01:28:52 +00:00
public static void Begin ( PCActor pc , WLSwingFxRule r , GameObject fx )
{
if ( pc = = null ) return ;
var ex = pc . GetComponent < WLSwingExtras > ( ) ; if ( ex = = null ) ex = pc . gameObject . AddComponent < WLSwingExtras > ( ) ;
ex . _pc = pc ;
if ( r . blinkDistance > 0f )
{
var fwd = pc . transform . forward ; fwd . y = 0f ; if ( fwd . sqrMagnitude < 1e-4f ) fwd = Vector3 . forward ; fwd . Normalize ( ) ;
Vector3 from = pc . Get_position ( ) , to = from + fwd * r . blinkDistance ;
UnityEngine . AI . NavMeshHit hit ; if ( UnityEngine . AI . NavMesh . SamplePosition ( to , out hit , 1.0f , UnityEngine . AI . NavMesh . AllAreas ) ) to = hit . position ;
int ghosts = r . blinkGhosts > 0 ? WL . Combat . Weapon . WLGhostStrike . BlinkGhosts ( pc , from , to , r . blinkGhosts ) : 0 ;
try { pc . Set_Warp ( to ) ; } catch ( System . Exception ) { pc . transform . position = to ; }
Blinks + + ; LastLog = "blink " + Vector3 . Distance ( from , to ) . ToString ( "F2" ) + " m · 잔상 " + ghosts ;
}
if ( r . camHoldSeconds > 0f ) { WLCamHold . Begin ( pc , r . camHoldSeconds , r . camZoomOut ) ; CamHolds + + ; }
2026-10-21 01:54:38 +00:00
if ( r . spinDegrees ! = 0f )
{
// #956 PD 「3타 때 몸을 회전 1회하며 공격 · 이펙트도 회전 모션에 맞게」 — m_animation.transform 은 PC 루트 자체(Animator 가 루트 LH_M05 · Play 실측)라
// 「모델 자식 회전」(#955 2차)도 루트 회전이었고 원본 LookAtTarget 이 매 프레임 되돌려 클립당 61° 만 남았다 → 엉덩이 뼈(Humanoid Hips = Root_M)를 루트 축으로
// 누적 각만큼 LateUpdate(애니메이션 평가 뒤)에서 돌린다 — 애니메이터가 뼈를 매 프레임 다시 쓰므로 델타가 아니라 누적 각 · 루트·판정 방향·카메라는 그대로(보이는 몸만) · PC 에 붙은 이펙트는 같은 각으로
ex . _spinBone = SpinBone ( pc ) ; ex . _spinLeft = r . spinDegrees ; ex . _spinAcc = 0f ; ex . _spinRate = r . spinDegrees / Mathf . Max ( 0.05f , r . spinSeconds ) ; ex . _spinning = ex . _spinBone ! = null ;
2026-10-21 04:05:18 +00:00
// #958 프레임 실측(958_probe SpinTrace): 팔라딘 Around 클립 자체가 엉덩이를 시계 방향 약 300°/0.18 s 돌려 코드 회전(반시계)과 상쇄됐다 → 더하지 않고 「시작 yaw + 누적 각」으로 덮어쓴다(클립의 yaw 변화 제거)
ex . _blending = false ; ex . _blendDur = Mathf . Max ( 0f , r . spinBlendOut ) ;
if ( ex . _spinBone ! = null ) { ex . _axis = HorizAxis ( ex . _spinBone ) ; ex . _yaw0 = Yaw ( ex . _spinBone , pc . transform , ex . _axis ) ; }
2026-10-21 01:54:38 +00:00
ex . _fx = r . spinFx & & fx ! = null & & fx . transform . parent = = pc . transform ? fx : null ;
if ( ex . _fx ! = null ) { ex . _fxPos = fx . transform . localPosition ; ex . _fxRot = fx . transform . localRotation ; }
Spins + + ; SpinLog = ( ex . _spinning ? "spin " + r . spinDegrees + "°/" + r . spinSeconds + " s · 뼈 " + ex . _spinBone . name : "spin 뼈 없음" ) + ( ex . _fx ! = null ? " · fx 함께" : " · fx 없음" ) ;
}
}
/// <summary>#956 돌릴 뼈 — Humanoid Hips(이 PC 는 루트 직계 Root_M) · 없으면 루트 직계 중 이름이 Root 로 시작하는 것.</summary>
static Transform SpinBone ( PCActor pc )
{
var an = pc . m_animation ; Transform b = an ! = null & & an . isHuman ? an . GetBoneTransform ( HumanBodyBones . Hips ) : null ;
if ( b = = null ) foreach ( Transform c in pc . transform ) if ( c . name . StartsWith ( "Root" ) ) { b = c ; break ; }
return b ;
2026-10-21 01:28:52 +00:00
}
2026-10-21 04:05:18 +00:00
/// <summary>#958 엉덩이 뼈 축 중 가장 수평인 것(0 right · 1 up · 2 forward) — 이 PC 의 Root_M 은 축이 기울어 오일러 y 로는 위 축 회전을 못 잰다(실측).</summary>
static int HorizAxis ( Transform b ) { Vector3 [ ] ax = { b . right , b . up , b . forward } ; int best = 0 ; float bd = 2f ; for ( int i = 0 ; i < 3 ; i + + ) { float d = Mathf . Abs ( Vector3 . Dot ( ax [ i ] , Vector3 . up ) ) ; if ( d < bd ) { bd = d ; best = i ; } } return best ; }
/// <summary>#958 그 축의 수평 투영이 루트 정면에서 위 축 둘레로 몇 도(시계 +).</summary>
static float Yaw ( Transform b , Transform root , int axis )
{
Vector3 a = axis = = 0 ? b . right : axis = = 1 ? b . up : b . forward ; a = Vector3 . ProjectOnPlane ( a , root . up ) ; var f = Vector3 . ProjectOnPlane ( root . forward , root . up ) ;
return a . sqrMagnitude > 1e-6f & & f . sqrMagnitude > 1e-6f ? Vector3 . SignedAngle ( f , a , root . up ) : 0f ;
}
public static float LastSpinErr ; // 검증용 — 마지막 프레임의 (목표 yaw − 덮은 뒤 yaw)
2026-10-21 01:28:52 +00:00
void LateUpdate ( )
{
2026-10-21 04:05:18 +00:00
if ( ( ! _spinning & & ! _blending ) | | _pc = = null ) return ;
if ( _spinBone = = null ) { _spinning = _blending = false ; _fx = null ; return ; }
var root = _pc . transform ; float w = 1f ;
if ( _spinning )
{
float step = _spinRate * Time . deltaTime ; if ( Mathf . Abs ( step ) > Mathf . Abs ( _spinLeft ) ) step = _spinLeft ;
_spinLeft - = step ; _spinAcc + = step ;
if ( Mathf . Approximately ( _spinLeft , 0f ) ) { _spinning = false ; _blending = _blendDur > 0f ; _blendT = 0f ; if ( _fx ! = null ) { _fx . transform . localRotation = _fxRot ; _fx . transform . localPosition = _fxPos ; _fx = null ; } }
}
else { _blendT + = Time . deltaTime ; w = 1f - Mathf . Clamp01 ( _blendT / Mathf . Max ( 0.01f , _blendDur ) ) ; if ( w < = 0f ) { _blending = false ; return ; } }
// 목표 yaw = 시작 yaw + 누적 각(끝 = 한 바퀴 = 시작 yaw) · 애니메이터가 이번 프레임에 쓴 자세의 yaw 와의 차이만큼 루트 발 위치 · 위 축으로 몸 전체(뼈 아래 양손 무기 포함)를 돌린다
float cur = Yaw ( _spinBone , root , _axis ) , d = Mathf . DeltaAngle ( cur , _yaw0 + _spinAcc ) * w ;
if ( Mathf . Abs ( d ) > 0.01f ) _spinBone . RotateAround ( root . position , root . up , d ) ;
LastSpinErr = Mathf . DeltaAngle ( Yaw ( _spinBone , root , _axis ) , _yaw0 + _spinAcc ) ;
if ( _fx ! = null & & _spinning ) { if ( ! _fx . activeInHierarchy | | _fx . transform . parent ! = root ) _fx = null ; else { var q = Quaternion . Euler ( 0f , _spinAcc , 0f ) ; _fx . transform . localRotation = q * _fxRot ; _fx . transform . localPosition = q * _fxPos ; } }
2026-10-21 01:28:52 +00:00
}
}
/// <summary>#955 카메라 고정 + 줌아웃 펄스 — RealCamera 의 LateUpdate 뒤(실행 순서 1000)에 회전은 고정값 · 위치 = PC + 고정 오프셋 − 앞 × 줌아웃(sin 0→1→0). 끝나면 RealCamera 가 보간으로 복귀.</summary>
[DefaultExecutionOrder(1000)]
public sealed class WLCamHold : MonoBehaviour
{
PCActor _pc ; Quaternion _rot ; Vector3 _offset ; float _t0 , _seconds , _zoom ; bool _on ;
public static string LastLog = "" ;
public static void Begin ( PCActor pc , float seconds , float zoomOut )
{
var cam = Camera . main ; if ( cam = = null | | pc = = null ) return ;
var h = cam . GetComponent < WLCamHold > ( ) ; if ( h = = null ) h = cam . gameObject . AddComponent < WLCamHold > ( ) ;
h . _pc = pc ; h . _rot = cam . transform . rotation ; h . _offset = cam . transform . position - pc . Get_position ( ) ; h . _t0 = Time . time ; h . _seconds = seconds ; h . _zoom = zoomOut ; h . _on = true ;
LastLog = "hold " + seconds + " s · zoom " + zoomOut + " · euler " + h . _rot . eulerAngles . ToString ( "F0" ) ;
}
void LateUpdate ( )
{
if ( ! _on ) return ;
float t = Time . time - _t0 ; if ( t > = _seconds | | _pc = = null ) { _on = false ; return ; }
float k = Mathf . Sin ( Mathf . Clamp01 ( t / _seconds ) * Mathf . PI ) ;
transform . rotation = _rot ;
transform . position = _pc . Get_position ( ) + _offset - _rot * Vector3 . forward * ( _zoom * k ) ;
}
}
2026-10-18 17:27:34 +00:00
/// <summary>#941 궤적 교체 이펙트를 매 프레임 무기 소켓 방향(손잡이 → 무기 끝 · 수평)에 맞춘다 — 창 2타 기계총 가림처럼 PC 정면이 아니라 무기가 향한 쪽으로 쏘게. 자리 = 손잡이 + 방향 × 길이 × alignFraction + 위 yOffset − R × massLocal × 배율.</summary>
public sealed class WLWeaponFollowFx : MonoBehaviour
{
2026-10-18 18:48:42 +00:00
Transform _socket ; Vector3 _tipLocal , _mass , _dir ; Quaternion _base ; float _scale , _frac , _yOff , _fwd , _side , _until ; PCActor _pc ; bool _armed , _fixed , _lock ;
public static int Frames , Locked ; public static string LastLog = "" ;
2026-10-18 17:27:34 +00:00
public bool Arm ( PCActor pc , WLSwingFxRule r , float scale )
{
2026-10-18 18:48:42 +00:00
_pc = pc ; _scale = scale ; _frac = r . alignFraction ; _yOff = r . yOffset ; _fwd = r . alignForward ; _side = r . alignSide ; _fixed = r . fixedDir ; _lock = r . lockFacing ;
_until = Time . time + ( r . life > 0f ? r . life : 1f ) ;
2026-10-18 17:27:34 +00:00
_base = r . euler ! = null & & r . euler . Length > = 3 ? Quaternion . Euler ( r . euler [ 0 ] , r . euler [ 1 ] , r . euler [ 2 ] ) : Quaternion . identity ;
_mass = r . massLocal ! = null & & r . massLocal . Length > = 3 ? new Vector3 ( r . massLocal [ 0 ] , r . massLocal [ 1 ] , r . massLocal [ 2 ] ) : Vector3 . zero ;
2026-10-18 18:48:42 +00:00
if ( _fixed )
{
_dir = pc . transform . forward ; _dir . y = 0f ; if ( _dir . sqrMagnitude < 1e-4f ) _dir = Vector3 . forward ; _dir . Normalize ( ) ; // #943-⑤ 띄우는 순간의 표적 방향으로 고정
_armed = true ; ApplyFixed ( ) ; return true ;
}
2026-10-18 17:27:34 +00:00
_armed = WLImpactRunner . SocketTip ( pc , out _socket , out _tipLocal ) ;
if ( _armed ) Apply ( ) ;
return _armed ;
}
public void Disarm ( ) { _armed = false ; }
void OnDisable ( ) { _armed = false ; }
2026-10-18 18:48:42 +00:00
void LateUpdate ( ) { if ( ! _armed ) return ; if ( _fixed ) ApplyFixed ( ) ; else Apply ( ) ; }
/// <summary>#943-⑤ 고정 방향 — 이펙트는 PC 위치만 따라가고 방향은 띄운 순간 그대로 · 공격 상태인 동안 PC 회전도 그 방향으로 잠근다(원본 LookAtTarget · 애니메이션 회전이 바꿔 놓은 것을 LateUpdate 에서 되돌림).</summary>
void ApplyFixed ( )
{
if ( _pc = = null ) { _armed = false ; return ; }
var tf = _pc . transform ;
if ( _lock & & Time . time < _until & & _pc . Get_CurAnim ( ) = = eAnim . Attack ) { tf . rotation = Quaternion . LookRotation ( _dir , Vector3 . up ) ; Locked + + ; }
var rot = Quaternion . LookRotation ( _dir , Vector3 . up ) * _base ;
Vector3 right = Vector3 . Cross ( Vector3 . up , _dir ) ;
Vector3 pos = _pc . Get_Center_position ( ) + _dir * _fwd + right * _side + Vector3 . up * _yOff - rot * ( _mass * _scale ) ;
transform . SetPositionAndRotation ( pos , rot ) ; Frames + + ;
LastLog = "fixed dir=" + _dir . ToString ( "F2" ) + " pos=" + pos . ToString ( "F2" ) + " locked=" + Locked ;
}
2026-10-18 17:27:34 +00:00
void Apply ( )
{
if ( _socket = = null | | _pc = = null ) { _armed = false ; return ; }
Vector3 hilt = _socket . position , tip = _socket . TransformPoint ( _tipLocal ) ;
Vector3 d = tip - hilt ; float len = d . magnitude ; d . y = 0f ;
if ( d . sqrMagnitude < 1e-4f ) d = _pc . transform . forward ; d . Normalize ( ) ;
var rot = Quaternion . LookRotation ( d , Vector3 . up ) * _base ;
Vector3 pos = hilt + d * ( len * _frac ) + Vector3 . up * _yOff - rot * ( _mass * _scale ) ;
transform . SetPositionAndRotation ( pos , rot ) ; Frames + + ;
LastLog = "align hilt=" + hilt . ToString ( "F2" ) + " tip=" + tip . ToString ( "F2" ) + " dir=" + d . ToString ( "F2" ) + " pos=" + pos . ToString ( "F2" ) ;
}
}
2026-10-18 15:09:05 +00:00
/// <summary>PC 에 붙는 러너 — 콤보 단계 진입 뒤 칼끝(WeaponTrailDriver) 높이를 추적해 지면 접점에서 충격파를 띄운다.</summary>
public sealed class WLImpactRunner : MonoBehaviour
{
sealed class Entry { public WLGroundFxRule rule ; public int stage ; public PCActor pc ; public WeaponTrailDriver drv ; public int slot ; public Transform socket ; public Vector3 tipLocal ; public float startAt , prevY , maxY , minY = 999f , hitboxAt = - 1f ; public Vector3 minPos ; public bool done , wasDescending ; public string why = "" ; public bool HasTip { get { return slot > = 0 | | socket ! = null ; } }
public Vector3 Tip ( ) { if ( slot > = 0 & & drv ! = null ) { var t = drv . GetTipWorld ( slot ) ; if ( t ! = Vector3 . zero ) return t ; } return socket ! = null ? socket . TransformPoint ( tipLocal ) : Vector3 . zero ; } }
readonly List < Entry > _list = new List < Entry > ( 2 ) ;
/// <summary>무기 소켓(PCActor.tfs_weapon)의 메시 bounds 모서리 중 소켓 원점에서 가장 먼 점 = 무기 끝(해머 머리·칼끝). WeaponTrailDriver 슬롯이 없을 때(해머는 궤적을 가로채 슬롯이 안 생긴다) 쓴다.</summary>
internal static bool SocketTip ( PCActor pc , out Transform socket , out Vector3 tipLocal ) // #911 해머 각성 모으기 이펙트도 쓴다
{
socket = null ; tipLocal = Vector3 . zero ; float best = 0f ;
var sockets = pc ! = null ? pc . tfs_weapon : null ; if ( sockets = = null ) return false ;
for ( int i = 0 ; i < sockets . Length ; i + + )
{
2026-10-21 01:54:38 +00:00
Vector3 tl ; float d = SocketTipOf ( sockets [ i ] , out tl ) ;
if ( d > best ) { best = d ; socket = sockets [ i ] ; tipLocal = tl ; }
}
return socket ! = null & & best > 0.05f ;
}
/// <summary>#956 소켓 하나의 무기 끝(소켓 로컬) = 켜진 렌더러 bounds 모서리 중 소켓 원점에서 가장 먼 점 · 반환 = 그 거리(0 = 무기 없음). 비수처럼 양손 소켓을 따로 쓸 때(WLShadowSlash 난사 사본).</summary>
internal static float SocketTipOf ( Transform s , out Vector3 tipLocal )
{
tipLocal = Vector3 . zero ; float best = 0f ;
if ( s = = null | | ! s . gameObject . activeInHierarchy ) return 0f ;
foreach ( var r in s . GetComponentsInChildren < Renderer > ( false ) )
{
if ( r = = null | | ! r . enabled ) continue ;
Bounds lb ; Transform rt = r . transform ;
var mf = r . GetComponent < MeshFilter > ( ) ; var smr = r as SkinnedMeshRenderer ;
if ( mf ! = null & & mf . sharedMesh ! = null ) lb = mf . sharedMesh . bounds ; else if ( smr ! = null ) lb = smr . localBounds ; else continue ;
Vector3 far = Vector3 . zero ; float farD = - 1f ;
for ( int c = 0 ; c < 8 ; c + + )
2026-10-18 15:09:05 +00:00
{
2026-10-21 01:54:38 +00:00
var corner = lb . center + new Vector3 ( ( c & 1 ) = = 0 ? - lb . extents . x : lb . extents . x , ( c & 2 ) = = 0 ? - lb . extents . y : lb . extents . y , ( c & 4 ) = = 0 ? - lb . extents . z : lb . extents . z ) ;
var local = s . InverseTransformPoint ( rt . TransformPoint ( corner ) ) ; float d = local . magnitude ;
if ( d > farD ) { farD = d ; far = local ; }
2026-10-18 15:09:05 +00:00
}
2026-10-21 01:54:38 +00:00
if ( farD > best ) { best = farD ; tipLocal = far ; }
2026-10-18 15:09:05 +00:00
}
2026-10-21 01:54:38 +00:00
return best ;
2026-10-18 15:09:05 +00:00
}
public void Watch ( PCActor pc , WLGroundFxRule rule , int stage )
{
for ( int i = _list . Count - 1 ; i > = 0 ; i - - ) if ( _list [ i ] . rule = = rule ) _list . RemoveAt ( i ) ; // 같은 규칙 재진입 = 새로
var drv = pc . GetComponent < WeaponTrailDriver > ( ) ; int slot = - 1 ; float best = 0f ;
if ( drv ! = null ) for ( int i = 0 ; i < drv . ActiveSlotCount ; i + + ) { float bl = drv . GetBladeLength ( i ) ; if ( bl > best & & drv . GetTipWorld ( i ) ! = Vector3 . zero ) { best = bl ; slot = i ; } }
var e = new Entry { rule = rule , stage = stage , pc = pc , drv = drv , slot = slot , startAt = Time . time } ;
string mode = "slot " + slot ;
if ( slot < 0 ) { Transform sk ; Vector3 tl ; if ( SocketTip ( pc , out sk , out tl ) ) { e . socket = sk ; e . tipLocal = tl ; mode = "socket " + sk . name + " tipLocal=" + tl . ToString ( "F2" ) ; } }
if ( ! e . HasTip ) { e . done = true ; e . why = "no-tip" ; }
else { float gy = pc . Get_position ( ) . y ; var tip = e . Tip ( ) ; float y = tip . y - gy ; e . prevY = e . maxY = e . minY = y ; e . minPos = tip ; }
_list . Add ( e ) ;
WLImpactFx . LastGround = "watch stage " + stage + ( ! e . HasTip ? " no-tip(fallback hitbox)" : " " + mode + " tipY=" + e . prevY . ToString ( "F2" ) ) ;
}
/// <summary>HitboxSpawned 가 이 규칙·단계를 추적 중이면 true(폴백 차단). 추적 실패(no-tip)면 false 로 폴백 허용.</summary>
public bool Covers ( WLGroundFxRule rule , int stage )
{
for ( int i = 0 ; i < _list . Count ; i + + ) if ( _list [ i ] . rule = = rule & & _list [ i ] . stage = = stage ) return _list [ i ] . HasTip ;
return false ;
}
public void NoteHitbox ( WLGroundFxRule rule , int stage ) { for ( int i = 0 ; i < _list . Count ; i + + ) if ( _list [ i ] . rule = = rule & & _list [ i ] . stage = = stage & & _list [ i ] . hitboxAt < 0f ) _list [ i ] . hitboxAt = Time . time ; }
/// <summary>타격 상자가 생기는 프레임(= 클립의 타격 시점)에 아직 접점이 안 잡혔으면 그 순간의 무기 끝 자리(지면 투영)에 띄운다 — 지면까지 안 내려오는 모션(해머 머리 최저 0.24 m · 2026-10-12 실측)용.</summary>
public bool FireAtHitbox ( WLGroundFxRule rule , int stage )
{
for ( int i = 0 ; i < _list . Count ; i + + )
{
var e = _list [ i ] ; if ( e . rule ! = rule | | e . stage ! = stage ) continue ;
if ( e . done ) return true ; // 이미 접점에서 띄웠다
if ( ! e . HasTip ) return false ; // 무기 끝을 모른다 → 호출자가 타격 상자 자리로
var tip = e . Tip ( ) ; float gy = e . pc . Get_position ( ) . y ; if ( tip = = Vector3 . zero ) return false ;
e . done = true ; e . why = "hitbox-tip" ;
WLImpactFx . PlayGround ( rule , e . pc , new Vector3 ( tip . x , gy , tip . z ) , "contact-hitbox t=" + ( Time . time - e . startAt ) . ToString ( "F2" ) + " tipY=" + ( tip . y - gy ) . ToString ( "F2" ) + " min=" + e . minY . ToString ( "F2" ) + " max=" + e . maxY . ToString ( "F2" ) ) ;
return true ;
}
return false ;
}
void Update ( )
{
for ( int i = _list . Count - 1 ; i > = 0 ; i - - )
{
var e = _list [ i ] ;
if ( e . pc = = null | | ! e . HasTip ) { if ( Time . time - e . startAt > e . rule . timeout ) _list . RemoveAt ( i ) ; continue ; }
float elapsed = Time . time - e . startAt ;
if ( ! e . done )
{
Vector3 tip = e . Tip ( ) ; float gy = e . pc . Get_position ( ) . y ; float y = tip . y - gy ;
if ( tip ! = Vector3 . zero )
{
if ( y > e . maxY ) e . maxY = y ;
if ( y < e . minY ) { e . minY = y ; e . minPos = tip ; }
bool descending = y < e . prevY - 1e-4f , ascending = y > e . prevY + 1e-4f ;
bool behind = false ;
if ( e . rule . frontOnly ) { var f = e . pc . transform . forward ; f . y = 0f ; var v = tip - e . pc . Get_position ( ) ; v . y = 0f ; behind = Vector3 . Dot ( v , f ) < = 0f ; }
if ( ! behind & & y < = e . rule . contactHeight & & descending & & e . maxY > = e . rule . minDrop )
{ // 지면(접점 높이 이내)에 닿는 프레임
e . done = true ; e . why = "contact" ;
WLImpactFx . PlayGround ( e . rule , e . pc , new Vector3 ( tip . x , gy , tip . z ) , "contact t=" + elapsed . ToString ( "F2" ) + " tipY=" + y . ToString ( "F2" ) + " max=" + e . maxY . ToString ( "F2" ) + ( e . hitboxAt > = 0f ? " hitboxΔ=" + ( Time . time - e . hitboxAt ) . ToString ( "F2" ) : " hitbox:none" ) ) ;
}
e . wasDescending = descending | | ( e . wasDescending & & ! ascending ) ;
e . prevY = y ;
}
}
if ( elapsed > e . rule . timeout )
{
if ( ! e . done ) WLImpactFx . LastGround = "ground miss stage " + e . stage + " minY=" + e . minY . ToString ( "F2" ) + " max=" + e . maxY . ToString ( "F2" ) ; // 시간 초과 — 늦게 띄우느니 안 띄운다
_list . RemoveAt ( i ) ;
}
}
}
}
/// <summary>1회 이펙트 — 켜질 때 전부 재생, 파티클 최장 수명 뒤 자동 비활성(풀 복귀).</summary>
public sealed class WLImpactOneShot : MonoBehaviour
{
ParticleSystem [ ] _ps ; float _life = - 1f , _offAt ;
2026-10-18 17:27:34 +00:00
public Transform home ; // #940 풀 루트 — follow 로 PC 에 붙었던 것을 꺼질 때 되돌린다
public void OverrideLife ( float sec ) { if ( sec > 0f ) _offAt = Time . time + sec ; } // #940 규칙의 life(초)
2026-10-18 15:09:05 +00:00
void OnEnable ( )
{
if ( _ps = = null )
{
_ps = GetComponentsInChildren < ParticleSystem > ( true ) ;
float m = 0f ;
for ( int i = 0 ; i < _ps . Length ; i + + )
{
var main = _ps [ i ] . main ;
float d = main . startDelay . constantMax + ( main . loop ? 1.0f : main . duration ) + main . startLifetime . constantMax ;
if ( d > m ) m = d ;
}
_life = Mathf . Clamp ( m , 0.3f , 6f ) ;
}
for ( int i = 0 ; i < _ps . Length ; i + + ) { _ps [ i ] . Clear ( true ) ; _ps [ i ] . Play ( true ) ; }
_offAt = Time . time + _life ;
}
2026-10-18 17:27:34 +00:00
// #940 follow 로 PC 에 붙었던 것은 끄기 전에 풀 루트로 되돌린다(OnDisable 안 SetParent 는 「already being activated or deactivated」 오류 — Play 실측 7건)
void Update ( ) { if ( Time . time > = _offAt ) { if ( home ! = null & & transform . parent ! = home ) transform . SetParent ( home , false ) ; gameObject . SetActive ( false ) ; } }
2026-10-18 15:09:05 +00:00
}
/// <summary>맞은 투사체를 몸 중심까지 조준 비행시킨 뒤 피격 이펙트와 함께 끈다(지팡이 마법 투사체).</summary>
public sealed class WLDeferredOff : MonoBehaviour
{
ProjectileBase _pb ; Actor _target ; float _arrive , _until , _speed , _startAt ; Collider [ ] _cols ; bool _straight ;
public void Begin ( ProjectileBase pb , Actor target , WLDeferRule rule , bool straight = false )
{
_pb = pb ; _target = target ; _arrive = Mathf . Max ( 0.02f , rule . arriveDistance ) ; _until = Time . time + Mathf . Max ( 0.05f , rule . maxSeconds ) ; _startAt = Time . time ; _straight = straight ;
var pd = pb . Get_ProjectTileData ( ) ; _speed = Mathf . Max ( rule . minSpeed , pd ! = null ? pd . Speed : 0f ) ;
if ( _cols = = null ) _cols = GetComponentsInChildren < Collider > ( true ) ;
for ( int i = 0 ; i < _cols . Length ; i + + ) if ( _cols [ i ] ! = null ) _cols [ i ] . enabled = false ; // 더 이상 다른 몹에 맞지 않는다
pb . StopAllCoroutines ( ) ; // 원본 직진 이동·수명 코루틴을 끊고 이 쪽이 조준 비행
enabled = true ;
}
void Update ( )
{
if ( _target = = null | | ! _target . gameObject . activeInHierarchy ) { Finish ( false , 0f ) ; return ; }
var center = WLImpactFx . BodyCenter ( _target ) ;
if ( _straight )
{ // #921 방향을 틀지 않고 지금 방향 그대로 몸 중심 깊이(진행 방향으로 잰 거리)까지 — 화살이 꺾이지 않는다
var fwd = transform . forward ; float ahead = Vector3 . Dot ( center - transform . position , fwd ) ;
float step = Mathf . Min ( _speed * Time . deltaTime , Mathf . Max ( 0f , ahead ) ) ;
transform . position + = fwd * step ;
if ( ahead - step < = _arrive | | Time . time > = _until ) Finish ( true , Mathf . Max ( 0f , ahead - step ) ) ;
return ;
}
transform . position = Vector3 . MoveTowards ( transform . position , center , _speed * Time . deltaTime ) ;
var dir = center - transform . position ; if ( dir . sqrMagnitude > 1e-6f ) transform . rotation = Quaternion . LookRotation ( dir ) ;
float d = Vector3 . Distance ( transform . position , center ) ;
if ( d < = _arrive | | Time . time > = _until ) Finish ( true , d ) ;
}
void Finish ( bool fx , float dist )
{
enabled = false ;
if ( fx & & _pb ! = null & & _target ! = null )
{
var pos = WLImpactFx . BodyCenter ( _target ) ;
var hf = _pb . GetComponent < WL . Combat . Weapon . WLProjectileHitFx > ( ) ; // #918 화살별 명중 이펙트(노랑 · 주황)가 직업 규칙보다 먼저
if ( hf ! = null & & hf . Play ( _target ) ) WLImpactFx . HitFxShown + + ;
else if ( ! WLImpactFx . PlayHitOverride ( WLImpactFx . ShooterClass ( _pb ) , _target ) & & ! string . IsNullOrEmpty ( _pb . str_HitEffect ) & & InGameInfo . Ins ! = null )
InGameInfo . Ins . Show_Effect ( _pb . str_HitEffect , pos ) ;
WLImpactFx . Arrivals + + ; WLImpactFx . LastArriveDist = dist ; WLImpactFx . LastArriveSeconds = Time . time - _startAt ;
WLImpactFx . LastLog = "arrive d=" + dist . ToString ( "F2" ) + " t=" + ( Time . time - _startAt ) . ToString ( "F2" ) + " → " + _target . name ;
}
gameObject . SetActive ( false ) ; // 풀 복귀(원본 Off 와 같은 결과 · 코루틴은 이미 끊었다)
}
void OnDisable ( )
{
if ( _cols ! = null ) for ( int i = 0 ; i < _cols . Length ; i + + ) if ( _cols [ i ] ! = null ) _cols [ i ] . enabled = true ; // 다음 재사용을 위해 복구
enabled = false ;
}
}
}