2026-09-06 23:24:25 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// WLCombatMotionSettings.cs — 전투 모션 이동(공격 전진) 설정 단일 출처(SOT)
/ /
// PD 지시 #762 (2026-09-06):
// "캐릭터가 공격할 때 캐릭터의 위치는 그대로인데 모션이 너무 커서 부자연스러워.
// 캐릭터 공격 모션을 고려해 이동 거리를 자연스럽게 수정해줘."
/ /
// ── 왜 위치가 그대로인가 (실측 2026-09-06 · AgentScripts/WL762_RootCurve.cs) ──
// 공격 클립에는 루트 전진 커브가 **실제로 들어 있다**:
// Sword&Shield_Attack1 RootT.z 최대 +0.781 m (t=0.80s)
// Sword&Shield_Attack2 RootT.z 최대 +1.727 m (t=0.90s)
// Sword&Shield_Attack3 RootT.z 최대 +1.619 m (t=0.34s)
// 셋 다 loopBlendPositionXZ(Bake Into Pose) = false 라 루트모션으로 추출돼 있는데,
// PC 프리팹의 Animator 는 applyRootMotion = 0 이라 그 이동이 전부 버려진다.
// 그래서 모션만 크게 앞으로 나가고 실제 위치는 제자리 — PD 가 지적한 그 증상이다.
/ /
// ── 왜 루트모션을 켜지 않는가 ────────────────────────────────────────────────
// applyRootMotion 을 켜면 이동·회전이 전부 애니메이션에 끌려간다. 힘민지 이동은
// NavMeshAgent + 조이스틱(Run_byJoystick)이 쥐고 있어 서로 싸우게 되고,
// 이동량도 1.7 m 라 근접 사거리(≈1.3 m)를 넘겨 대상을 통과한다.
// → 클립의 `Move(float)` 애니메이션 이벤트(원래 있던 훅)로 **거리만** 받아
// NavMeshAgent.Move 로 지형을 따라 밀어 준다. 지형 관통·NavMesh 이탈이 없다.
/ /
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것
// (Actor/PCActor 가 Assembly-CSharp 에 있다).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic ;
using UnityEngine ;
namespace WL.Combat
{
/// <summary>공격 중 액터 이동 방식 (PD #789).</summary>
public enum AttackMoveMode { FixedStep = 0 , ClipRootMotion = 1 , FrameTable = 2 }
/// <summary>공격 클립의 "프레임 → 전진(cm)" 표 (PD #791 · 발 접지 실측). frames[i] 프레임(30fps · 1부터)에 cm[i] 만큼 전진.</summary>
[System.Serializable]
public sealed class AttackFrameTable
{
[Tooltip("클립 이름에 이 문자열이 포함되면 적용(예: Attack1_S). 클래스별 서브클립(Attack1_S_10501)도 잡힌다")]
public string clipContains = "Attack1_S" ;
[Tooltip("전진을 넣을 프레임 번호(30fps · 1부터)")]
public int [ ] frames = new int [ 0 ] ;
[Tooltip("각 프레임의 전진량(cm) — frames 와 같은 길이")]
public float [ ] cm = new float [ 0 ] ;
2026-10-18 15:09:05 +00:00
[Tooltip("#840 true = 적 앞 멈춤 거리(attackStepStopDistance)를 무시하고 적을 통과하며 전진(비수 4타 돌진베기)")]
public bool passThrough ;
[Tooltip("#899 0 보다 크면 이 표의 적 앞 멈춤 거리(m) — 공통 멈춤 거리(ApproachStop = 공격 가능 거리 × 비율) 대신 쓴다(방패 치기 앞 한 걸음처럼 붙어야 하는 동작). 0 = 공통")]
public float stopDistance ;
[Tooltip("#899 1 이상이면 이 프레임(30fps · 1부터)까지 애니메이션을 speedScale 배로 재생(방패 치기 전 숙이기). 0 = 끔")]
public int speedUntilFrame ;
[Tooltip("#899 speedUntilFrame 까지의 재생 배속(1 = 그대로)")]
public float speedScale = 1f ;
2026-09-06 23:24:25 +00:00
public float ValueAt ( int frame )
{
if ( frames = = null | | cm = = null ) return 0f ;
for ( int i = 0 ; i < frames . Length & & i < cm . Length ; i + + ) if ( frames [ i ] = = frame ) return cm [ i ] ;
return 0f ;
}
public float Total ( ) { float t = 0f ; if ( cm ! = null ) foreach ( var v in cm ) t + = v ; return t ; }
}
/// <summary>
/// 공격 모션 전진 이동의 단일 출처(SOT). 코드 상수 금지(C45) — 모든 값을 여기서 읽는다.
/// 에셋 경로: <c>Assets/WL/Settings/Resources/WL/WLCombatMotionSettings.asset</c>
/// </summary>
[CreateAssetMenu(fileName = "WLCombatMotionSettings", menuName = "WL/Combat Motion Settings")]
public sealed class WLCombatMotionSettings : ScriptableObject
{
public const string ResourcesPath = "WL/WLCombatMotionSettings" ;
private static WLCombatMotionSettings s_cached ;
private static bool s_lookupDone ;
/// <summary>
/// 설정 에셋(1회 로드 후 캐시). 없으면 null 을 돌려주고 호출부는 조용히
/// 기존(제자리) 동작으로 돌아간다 — 에셋을 지우면 즉시 원복된다(C8 롤백 경로).
/// </summary>
public static WLCombatMotionSettings Instance
{
get
{
if ( s_lookupDone ) return s_cached ;
s_lookupDone = true ;
s_cached = Resources . Load < WLCombatMotionSettings > ( ResourcesPath ) ;
return s_cached ;
}
}
/// <summary>캐시를 비운다(에디터에서 에셋을 새로 만들었을 때 · 테스트용).</summary>
public static void ClearCache ( ) { s_cached = null ; s_lookupDone = false ; }
[Header("■ 마스터 스위치")]
[ Tooltip ( "끄면 Move 애니메이션 이벤트를 무시하고 지금처럼 제자리에서 공격한다. " +
"라이브 롤백 스위치 — 코드 되돌리기 없이 원복할 수 있다(C8)" ) ]
public bool enableAttackStep = true ;
[Header("■ 전진 이동")]
[ Tooltip ( "Move 이벤트 시점부터 이 시간(초) 동안 나눠서 전진한다. " +
"클립 실측상 루트 전진은 Attack1 0.10~0.20s · Attack2 0.10~0.20s · Attack3 0.11~0.23s 구간에 " +
"대부분 몰려 있다(WL762_RootCurve). 그 구간과 비슷한 0.25 를 기본으로 둔다" ) ]
[Range(0.05f, 1f)] public float attackStepSeconds = 0.25f ;
[ Tooltip ( "Move 이벤트의 float 파라미터(m)에 곱하는 배수. 전체 전진량을 한 값으로 조절한다. " +
"1 = 클립에 적힌 거리 그대로" ) ]
[Range(0f, 3f)] public float attackStepScale = 1f ;
[ Tooltip ( "한 번의 공격으로 전진할 수 있는 상한(m). 데이터가 잘못 들어와도 " +
"캐릭터가 날아가지 않게 막는 안전판" ) ]
public float attackStepMaxDistance = 2f ;
[ Tooltip ( "켜면 앞이 빠르고 뒤가 느린 감속(ease-out) 곡선으로 나눠 이동한다. " +
"끄면 등속. 실측 루트 커브가 초반에 몰려 있어 기본은 켬" ) ]
public bool attackStepEaseOut = true ;
[ Tooltip ( "전진 중 이 거리(m) 안에 대상이 들어오면 더 밀지 않는다. 0 이면 검사하지 않는다. " +
"대상을 통과해 지나가는 것을 막는다" ) ]
public float attackStepStopDistance = 0.9f ;
// ── PD 지시 #787 (2026-09-06) 대쉬 후 공격 ───────────────────────────────
// "적과 거리가 일정거리 떨어진 경우 대쉬 동작이 발생된 후 공격하도록"
// 달려가는 추적(NavMeshAgent 경로 이동)과 즉시 공격 사이의 **중간 거리 창**에서만
// Stander@Chase_Start 클립을 재생하며 타겟 방향으로 직진해 붙고 곧바로 때린다.
// 이동은 AttackStepDriver 와 같은 이유로 NavMeshAgent.Move 다(지형 관통·NavMesh 이탈 없음).
[Header("■ #787 대쉬 후 공격")]
[Tooltip("끄면 대쉬를 쓰지 않고 지금처럼 달려가서 공격한다. 라이브 롤백 스위치(C8)")]
public bool dashEnabled = true ;
[Tooltip("이 거리(m) 미만이면 대쉬하지 않고 바로 공격한다(이미 붙어 있다)")]
public float dashMinDistance = 1.6f ;
[ Tooltip ( "이 거리(m)를 넘으면 대쉬하지 않고 기존 달리기로 추적한다. " +
"즉 대쉬는 [dashMinDistance, dashMaxDistance] 창 안에서만 일어난다" ) ]
public float dashMaxDistance = 6.0f ;
[ Tooltip ( "대쉬 중 대상까지 이 거리(m) 안에 들어오면 즉시 멈추고 공격한다. " +
"0 이면 attackStepStopDistance 를 그대로 쓴다" ) ]
public float dashStopDistance = 0.9f ;
2026-10-18 15:09:05 +00:00
[ Tooltip ( "#893 PD 「소드 공격 전 너무 적에게 근접 → 공격 가능한 범위의 절반 정도까지만 대쉬」 — 0 보다 크면 대쉬 도착 거리와 공격 전진 정지 거리를\n" +
"흰색 원 반지름(공격 가능 거리 · WLRangeRing.AttackReach) × 이 값으로 쓴다(무기마다 다름). 0 = 위 고정값(dashStopDistance · attackStepStopDistance)." ) ]
public float approachReachRatio = 0f ;
[System.Serializable]
public sealed class ClassApproachStop
{
[Tooltip("직업 ID(ClassConfig n_ClassID)")] public int [ ] classIds = new int [ 0 ] ;
[Tooltip("이 직업의 대쉬 도착·공격 전진 정지 거리(m)")] public float stopDistance ;
[Tooltip("켜면 위 거리에 대상의 몸 반지름(NavMeshAgent 반경 × 크기)을 더한다 = 대상 몸이 그 거리에 닿는 자리에서 멈춤")] public bool addTargetRadius ;
[Tooltip("0 이상이면 이 직업은 「사거리 안 적 = 제자리 공격」 게이트 여유(dashBlockReachBonus) 대신 이 값(m)을 쓴다. − 1 = 공통 값")] public float gateBonus = - 1f ;
[ Tooltip ( "#908 켜면(addTargetRadius 일 때) 대상 몸 반지름을 길찾기 반경 대신 충돌 상자(가로 · 깊이 중 큰 쪽의 절반 × 크기)로 잰다 — " +
"길찾기 반경은 몹끼리 덜 밀리게 작게 깎여 있어(실측 0.15 m) 몸이 큰 몹(RPG Monster BUNDLE)에 너무 붙었다. 해머 = 「망치 닿는 거리 + 몸 반지름」에서 멈춤" ) ]
public bool bodyFromCollider ;
}
/// <summary>#908 대상 몸 반지름(m) — 충돌 상자(가로 · 깊이 중 큰 쪽의 절반) · 캡슐/구 반경 × 크기 · 없으면 길찾기 반경.</summary>
public static float BodyRadius ( Actor a )
{
if ( a = = null ) return 0f ;
var s = a . transform . lossyScale ; float sxz = Mathf . Max ( Mathf . Abs ( s . x ) , Mathf . Abs ( s . z ) ) ;
var col = a . GetComponent < Collider > ( ) ;
var box = col as BoxCollider ;
if ( box ! = null ) return Mathf . Max ( Mathf . Abs ( box . size . x * s . x ) , Mathf . Abs ( box . size . z * s . z ) ) * 0.5f ;
var cap = col as CapsuleCollider ;
if ( cap ! = null ) return cap . radius * sxz ;
var sph = col as SphereCollider ;
if ( sph ! = null ) return sph . radius * sxz ;
var ag = a . Get_Agent ( ) ;
return ag ! = null ? ag . radius * sxz : 0f ;
}
/// <summary>#900 — 그 PC 직업의 정지 거리 설정(없으면 null).</summary>
public static ClassApproachStop FindClassStop ( PCActor pc )
{
var i = Instance ;
if ( i = = null | | pc = = null | | i . approachStopByClass = = null ) return null ;
int cls = pc . Get_ID ( ) ;
foreach ( var o in i . approachStopByClass )
if ( o ! = null & & o . classIds ! = null & & System . Array . IndexOf ( o . classIds , cls ) > = 0 ) return o ;
return null ;
}
[ Tooltip ( "#900 PD 「해머 내려치기(1타) 시 너무 근접 — 적이 해머 내려치는 위치에 닿는 정도까지만 이동한 상태에서 공격」 — 여기 있는 직업은\n" +
"위 비율 대신 이 거리(m)에서 멈춘다. 값 = 내려치기 때 무기 끝이 땅에 닿는 자리까지의 거리(Play 실측)." ) ]
public ClassApproachStop [ ] approachStopByClass = new ClassApproachStop [ 0 ] ;
/// <summary>#893 — 대쉬 도착·공격 전진 정지 거리. #900 직업별 값이 있으면 그 값 · approachReachRatio > 0 이면 공격 가능 거리 × 비율 · 아니면 고정값.</summary>
public static float ApproachStop ( PCActor pc , float fixedStop )
{
var i = Instance ;
var o = FindClassStop ( pc ) ;
if ( o ! = null & & o . stopDistance > 0f )
{
float s = o . stopDistance ;
var tg = o . addTargetRadius ? pc . Get_Target ( ) : null ;
if ( tg ! = null & & o . bodyFromCollider ) return s + BodyRadius ( tg ) ; // #908 몸 크기(충돌 상자) 기준
var ag = tg ! = null ? tg . Get_Agent ( ) : null ;
if ( ag ! = null ) s + = ag . radius * Mathf . Max ( tg . transform . lossyScale . x , tg . transform . lossyScale . z ) ;
return s ;
}
if ( i = = null | | i . approachReachRatio < = 0f | | pc = = null ) return fixedStop ;
float r = WL . Combat . RangeRing . WLRangeRing . AttackReach ( pc ) * i . approachReachRatio ;
return r > 0.05f ? r : fixedStop ;
}
2026-09-06 23:24:25 +00:00
[Tooltip("대쉬 이동 속도 상한(m/s). 남은 거리를 클립 길이로 나눈 속도가 이 값을 넘으면 여기서 잘린다")]
public float dashMaxSpeed = 12f ;
[Tooltip("대쉬가 이 시간(초)을 넘기면 강제로 끝낸다. 사거리 안이면 공격, 아니면 기존 달리기로 돌아간다")]
public float dashMaxSeconds = 0.6f ;
[Tooltip("켜면 대쉬 내내 대상을 바라보도록 수평 회전한다")]
public bool dashLookAtTarget = true ;
[ Tooltip ( "한 번 대쉬한 뒤 다시 대쉬할 수 있을 때까지의 최소 간격(초). " +
"사거리 밖에서 대쉬가 매 프레임 다시 걸리는 것을 막는다" ) ]
public float dashCooldownSeconds = 0.35f ;
[ Tooltip ( "대쉬가 계획 거리의 절반도 못 가고 끝났을 때(NavMesh 경계·장애물·도망가는 대상) " +
"적용하는 긴 쿨다운(초). 붙지도 못하면서 대쉬↔달리기를 반복하는 깜빡임을 막는다. " +
"실측 2026-09-06: 물 건너 못 가는 몹을 상대로 대쉬가 3회 연속 실패하며 제자리걸음했다" ) ]
public float dashFailCooldownSeconds = 2.0f ;
[Tooltip("컨트롤러에서 재생할 대쉬 상태 이름. 4개 근접 컨트롤러에 같은 이름으로 추가한다(C45 — 코드에 문자열을 넣지 않는다)")]
public string dashStateName = "dash" ;
[ Tooltip ( "대쉬 상태에 물린 클립 이름. 런타임에는 상태→클립 매핑을 직접 읽을 수 없어 " +
"이 이름으로 길이(초)를 찾는다. 못 찾으면 dashMaxSeconds 의 절반을 쓴다" ) ]
public string dashClipName = "Chase_Start" ;
[Tooltip("대쉬 상태로 넘어갈 때의 크로스페이드 시간(초). 0 이면 즉시 전환")]
public float dashCrossFadeSeconds = 0.06f ;
// ── PD #789 (2026-09-07) 공격 클립 루트모션 재현 ─────────────────────────
[Header("■ 공격 이동 방식 (PD #789)")]
[Tooltip("ClipRootMotion = 공격 클립의 루트 이동(animator.deltaPosition)을 그대로 액터 이동으로 재현(AttackRootMotion) · FixedStep = #762 고정 전진(AttackStepDriver). 체크 하나로 즉시 원복(C8)")]
public AttackMoveMode attackMoveMode = AttackMoveMode . FrameTable ;
[Tooltip("루트 이동 배율. 1 = 클립 그대로")]
[Range(0f, 3f)] public float rootMotionScale = 1f ;
[Tooltip("루트 이동 속도 상한(m/s) — 프레임 스파이크 방지")]
public float rootMotionMaxSpeed = 6f ;
[Tooltip("클립이 뒤로 물러나는 구간(Attack1 은 0.30 m 나갔다 돌아온다)도 그대로 적용할지. 끄면 전진 성분만")]
public bool rootMotionAllowBackward = true ;
[Tooltip("FrameTable 모드의 전체 배율(1 = 실측 표 그대로)")]
[Range(0f, 3f)] public float frameTableScale = 1f ;
[Tooltip("클립별 프레임 표. 기본값 = Ai01 모델에 Knight 클립을 30fps 로 샘플해 접지 발 슬라이딩이 0 이 되는 전진량(PM 실측 2026-09-07)")]
public List < AttackFrameTable > attackFrameTables = DefaultFrameTables ( ) ;
public static List < AttackFrameTable > DefaultFrameTables ( )
{
return new List < AttackFrameTable >
{
// Attack1: 앞발(왼발) 접지 유지 · 뒷발이 앞으로 갔다 되돌아오는 제자리 런지 → 상체가 나가는 6~7 프레임과 14~17 프레임에 소량(총 15 cm)
new AttackFrameTable { clipContains = "Attack1_S" , frames = new [ ] { 6 , 7 , 14 , 15 , 16 , 17 } , cm = new [ ] { 4f , 1f , 1f , 3f , 3f , 3f } } ,
// Attack2: 2~8 프레임 도약(양발 공중) → 9~11 착지(넓은 보폭) → 전진은 공중 구간(2~8f)에만 배분(총 30 cm · 착지 프레임 0 = 슬라이딩 방지 · 2026-09-07 실측 후 조정)
new AttackFrameTable { clipContains = "Attack2_S" , frames = new [ ] { 2 , 3 , 4 , 5 , 6 , 7 , 8 } , cm = new [ ] { 3f , 4f , 5f , 5f , 5f , 4f , 4f } } ,
// Attack3: 9~11 프레임 왼발 내딛기(43 cm 스윙) → 12 착지 · 이후 양발 고정 → 내딛는 구간에만 전진(총 28 cm)
new AttackFrameTable { clipContains = "Attack3_S" , frames = new [ ] { 9 , 10 , 11 , 12 } , cm = new [ ] { 6f , 8f , 8f , 6f } } ,
} ;
}
public AttackFrameTable FindFrameTable ( string clipName )
{
if ( string . IsNullOrEmpty ( clipName ) | | attackFrameTables = = null ) return null ;
for ( int i = 0 ; i < attackFrameTables . Count ; i + + )
{
var t = attackFrameTables [ i ] ;
if ( t ! = null & & ! string . IsNullOrEmpty ( t . clipContains ) & & clipName . IndexOf ( t . clipContains , System . StringComparison . OrdinalIgnoreCase ) > = 0 ) return t ;
}
return null ;
}
[Header("■ 대쉬 조건 보강 (PD #790)")]
[Tooltip("대쉬 최소 거리 = 공격 사정거리(reach) + 이 값. 사정거리 바로 밖에서 짧은 대쉬가 반복되는 것을 막는다")]
public float dashMinBeyondReach = 0.8f ;
[Tooltip("사정거리 안에 다른 적이 하나라도 있으면 대쉬하지 않고 그 적을 즉시 공격한다")]
public bool dashBlockIfEnemyInReach = true ;
public static bool DashBlockIfEnemyInReach { get { var i = Instance ; return i ! = null & & i . dashBlockIfEnemyInReach ; } }
2026-09-15 05:59:32 +00:00
// ── PD #816x (2026-09-15) "사정거리에 적이 있으면 대쉬하지 말고 제자리에서 그 적을 공격" ──
// 기존 #790 차단은 MyActor 안(원본)에서만 돌아 놓치는 경우가 있었다(락 타겟·사거리 해석 차이).
// 아래 값들은 **대쉬의 유일한 진입점인 DashDriver.TryBegin** 에서 마지막으로 한 번 더 본다.
[Tooltip("대쉬 직전 최종 게이트를 켠다. 사거리 안에 적이 하나라도 있으면 대쉬를 시작하지 않는다")]
public bool dashBlockFinalGate = true ;
[ Tooltip ( "게이트가 보는 사거리에 화면 사거리 원(WeaponTrailDriver.LastAttackReach)을 함께 쓴다. " +
"PD 화면에서 원 안에 보이는 적이 게이트에도 '안'으로 잡히게 하는 스위치" ) ]
public bool dashBlockUseRingReach = true ;
[Tooltip("게이트 사거리에 더하는 여유(m). 원 테두리에 걸친 적까지 '안'으로 본다")]
public float dashBlockReachBonus = 0.35f ;
[Tooltip("게이트가 사거리 안 적을 찾으면 타겟을 그 적(가장 가까운)으로 바꾼다. 끄면 기존 타겟을 그대로 두고 대쉬만 막는다")]
public bool dashBlockRetarget = true ;
[Tooltip("제자리 공격 직전에 그 적 쪽으로 몸을 돌린다(수평 회전만)")]
public bool dashBlockLookAtTarget = true ;
2026-09-06 23:24:25 +00:00
[Header("■ 검증 (에디터 전용)")]
[Tooltip("켜면 전진 시작·종료·이동량을 콘솔에 남긴다")]
public bool verboseLog = false ;
}
}