227 lines
15 KiB
C#
227 lines
15 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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];
|
|
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("대쉬 이동 속도 상한(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;
|
|
}
|
|
}
|