Project_WL/Assets/WL/Combat/WLCombatMotionSettings.cs

301 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

// ─────────────────────────────────────────────────────────────────────────────
// 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];
[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;
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;
[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;
}
[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; } }
// ── 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;
[Header("■ 검증 (에디터 전용)")]
[Tooltip("켜면 전진 시작·종료·이동량을 콘솔에 남긴다")]
public bool verboseLog = false;
}
}