// ───────────────────────────────────────────────────────────────────────────── // DashDriver.cs — 적과 중간 거리일 때 대쉬로 붙고 곧바로 공격 (PD 지시 #787 · 2026-09-06) // // PD 원문: "적과 거리가 일정거리 떨어진 경우 예시 gif 와 같이 대쉬 동작이 발생된 후 // 공격하도록 하고 싶어. (Platform_Animation 애니메이션의 00_Base 동작 중 // Stander@Chase_Start 를 쓰면 돼.)" // // ── 기존 흐름 어디에 끼는가 ───────────────────────────────────────────────── // MyActor.Update 의 자동 교전은 "사거리 밖 → Play_Run() 으로 달려감 → 사거리 안 → // Play_Attack(0)" 이다. 대쉬는 그 **달려감 구간의 앞쪽 일부**를 대체한다. // d < dashMinDistance → 기존: 즉시 공격 (건드리지 않는다) // dashMinDistance ≤ d ≤ dashMaxDistance → **대쉬 → 도착 즉시 공격** // d > dashMaxDistance → 기존: NavMesh 경로로 달려감 // // ── 왜 NavMeshAgent.Move 인가 (AttackStepDriver 와 같은 근거) ──────────────── // transform.position 직접 대입은 지형을 통과하고 에이전트 내부 위치와 어긋나 // 다음 이동 명령에서 순간이동한다. Warp 도 같은 이유로 쓰지 않는다(PD 지시 4). // NavMeshAgent.Move 는 이동을 NavMesh 표면에 투영하고 내부 상태도 함께 갱신한다. // // ── 어그로 없음 (PD 상시 규칙 c) ──────────────────────────────────────────── // 이 드라이버는 타겟을 **고르지 않는다**. MyActor 가 이미 정한 m_Target 으로만 // 달려간다. 대쉬 중 타겟이 죽거나 사라지면 즉시 끝내고 기존 재타겟 흐름(#770)에 맡긴다. // // ── 잔상·이펙트 없음 ──────────────────────────────────────────────────────── // PD 미지시 사항이라 대쉬 연출은 클립 재생 외에 아무것도 추가하지 않는다. // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using UnityEngine.AI; namespace WL.Combat { /// /// 중간 거리에서 대쉬 클립을 재생하며 타겟 쪽으로 직진하고, 도착하면 즉시 공격한다. /// 필요할 때 런타임으로 자동 부착되므로 프리팹을 미리 손댈 필요가 없다. /// [DisallowMultipleComponent] public sealed class DashDriver : MonoBehaviour { private PCActor _pc; private NavMeshAgent _agent; private Animator _animator; private WLCombatMotionSettings _cfg; private Actor _target; private bool _active; private float _elapsed; private float _clipLength = 0.3f; private float _speed; // 대쉬 시작 때 한 번 정한 등속(m/s) private float _plannedDuration; // 그 속도로 붙는 데 걸리는 계획 시간(초) private float _startDistance; private Vector3 _startPos; private float _readyTime; // 이 시각 전에는 다시 대쉬하지 않는다(쿨다운) // ── 검증용 (Play 중 CLI 로 읽는다 · 게임 로직은 쓰지 않는다) ──────────── /// 대쉬를 시작한 누적 횟수. public static int DashCount; /// 마지막 대쉬 시작 시각(Time.time). public static float LastBeginTime; /// 마지막 대쉬 종료 시각(Time.time). public static float LastEndTime; /// 마지막 대쉬의 시작 거리(m). public static float LastStartDistance; /// 마지막 대쉬의 실제 이동 거리(m · NavMesh 투영 후). public static float LastMovedDistance; /// 마지막 대쉬가 끝난 뒤 공격으로 이어졌는가. public static bool LastEndedInAttack; /// 마지막 대쉬 요약(검증용 문자열). public static string LastInfo = "(none)"; /// 대쉬 중인가. public bool IsDashing { get { return _active; } } /// 이 액터가 지금 대쉬 중인가. MyActor.Update 가 접근 로직을 건너뛸지 묻는다. public static bool IsDashingNow(Component actor) { if (actor == null) return false; var d = actor.GetComponent(); return d != null && d._active; } /// /// 대쉬를 시작할 수 있으면 시작하고 true 를 돌려준다. /// false 면 호출부(MyActor)는 기존 달리기 추적을 그대로 한다(C8 롤백 경로). /// /// 타겟까지의 현재 수평 거리(m) /// 지금 자리에서 때릴 수 있는 거리(m). 도착 판정의 하한으로 쓴다. public static bool TryBegin(PCActor pc, Actor target, float distance, float reach) { if (pc == null || target == null || target.IsDead()) return false; var cfg = WLCombatMotionSettings.Instance; if (cfg == null || !cfg.dashEnabled) return false; // 거리 창 밖이면 기존 동작. (min 미만은 애초에 여기까지 오지 않지만 방어적으로 본다) float minDist = Mathf.Max(cfg.dashMinDistance, reach + Mathf.Max(cfg.dashMinBeyondReach, 0f)); // PD #790: 사정거리 + 여유 밖에서만 대쉬 if (distance < minDist || distance > cfg.dashMaxDistance) return false; if (!pc.WL_CanDash()) return false; // 조이스틱 입력·CC·정지·사망 var agent = pc.Get_Agent(); if (agent == null || !agent.enabled || !agent.isOnNavMesh) return false; var driver = pc.GetComponent(); if (driver == null) driver = pc.gameObject.AddComponent(); if (driver._active) return true; // 이미 대쉬 중 = 접근 로직 건너뛰기 if (Time.time < driver._readyTime) return false; // 쿨다운 return driver.BeginInternal(pc, agent, cfg, target, distance, reach); } private bool BeginInternal(PCActor pc, NavMeshAgent agent, WLCombatMotionSettings cfg, Actor target, float distance, float reach) { _pc = pc; _agent = agent; _cfg = cfg; _target = target; _animator = pc.m_animation; if (_animator == null || _animator.runtimeAnimatorController == null) return false; string state = string.IsNullOrEmpty(cfg.dashStateName) ? "dash" : cfg.dashStateName; if (!HasState(_animator, state)) return false; // 상태가 없으면 기존 달리기로 string clipName = string.IsNullOrEmpty(cfg.dashClipName) ? state : cfg.dashClipName; _clipLength = FindStateClipLength(_animator, clipName, Mathf.Max(cfg.dashMaxSeconds, 0.1f) * 0.5f); // ── 속도는 **시작 때 한 번만** 정한다 ───────────────────────────────── // 남은 거리로 매 프레임 다시 계산하면 거리가 줄수록 속도도 줄어 대상 앞에서 // 한없이 느려진다(2026-09-06 실측: 1.90 m 대쉬가 0.60 s 를 다 쓰고 timeout). // 등속이면 계획 시간 안에 정확히 붙는다. float need = Mathf.Max(distance - StopDistance(cfg), 0.01f); _speed = Mathf.Min(need / Mathf.Max(_clipLength, 0.05f), Mathf.Max(cfg.dashMaxSpeed, 0.1f)); _plannedDuration = Mathf.Clamp(need / _speed, 0.05f, Mathf.Max(cfg.dashMaxSeconds, 0.05f)); // NavMesh 경로 이동을 끊고 직접 민다 — 경로가 남아 있으면 두 이동이 싸운다. pc.WL_StopNavMove(); float blend = Mathf.Max(cfg.dashCrossFadeSeconds, 0f); // 이동 시간이 클립보다 길어지면(속도 상한에 걸린 먼 거리) 클립을 그만큼 늦춰 재생한다. // 안 그러면 클립이 먼저 끝나 idle 로 넘어가고 캐릭터가 선 자세로 미끄러진다(실측). _animator.speed = Mathf.Clamp(_clipLength / _plannedDuration, 0.4f, 2.5f); if (blend > 0f) _animator.CrossFadeInFixedTime(state, blend, 0, 0f); else _animator.Play(state, 0, 0f); _elapsed = 0f; _startPos = transform.position; _startDistance = distance; _active = true; DashCount++; LastBeginTime = Time.time; LastStartDistance = distance; LastEndedInAttack = false; WL.Combat.Core.CombatEvents.RaiseDashBegan(_pc, distance); // WL #811b 코어 통지 #if UNITY_EDITOR if (cfg.verboseLog) Debug.Log(string.Format("[WL #787] 대쉬 시작 d={0:F2}m reach={1:F2}m clip={2:F2}s state={3}", distance, reach, _clipLength, state), this); #endif return true; } /// 대쉬를 끝낸다. attack = true 면 그 자리에서 즉시 공격을 건다. private void Finish(bool attack, string why) { if (!_active) return; _active = false; LastEndTime = Time.time; LastMovedDistance = Vector3.Distance(_startPos, transform.position); LastEndedInAttack = attack; WL.Combat.Core.CombatEvents.RaiseDashEnded(_pc, LastMovedDistance, attack); // WL #811b 코어 통지 // 계획 거리의 절반도 못 갔다 = NavMesh 경계·장애물·도망가는 대상이라 대쉬로는 못 붙는다. // 짧은 쿨다운이면 대쉬↔달리기를 반복하며 제자리걸음한다(실측) → 한동안 기존 추적에 맡긴다. float need = _cfg != null ? Mathf.Max(_startDistance - StopDistance(_cfg), 0.01f) : 1f; float progress = LastMovedDistance / need; float cool = _cfg != null ? Mathf.Max(_cfg.dashCooldownSeconds, 0f) : 0.35f; if (!attack && progress < 0.5f && _cfg != null) cool = Mathf.Max(cool, _cfg.dashFailCooldownSeconds); _readyTime = Time.time + cool; LastInfo = string.Format("{0} start={1:F2}m moved={2:F2}m({3:P0}) t={4:F3}s attack={5} cool={6:F2}s", why, _startDistance, LastMovedDistance, progress, _elapsed, attack, cool); // 클립 속도를 늦춰 놨을 수 있다. 공격으로 이어지면 Play_Attack 이 공격 속도로 덮어쓰고, // 아니면 여기서 1 로 되돌린다 — 안 그러면 idle/run 이 느리게 재생된다. if (_animator != null && !attack) _animator.speed = 1f; if (attack && _pc != null) _pc.WL_PlayAttackNow(); #if UNITY_EDITOR if (_cfg != null && _cfg.verboseLog) Debug.Log("[WL #787] 대쉬 종료 " + LastInfo, this); #endif } /// 대쉬를 즉시 접는다(피격·사망·정지·조이스틱). public void Cancel() { Finish(false, "cancel"); } private void Update() { if (!_active) return; if (_pc == null || _agent == null || !_agent.enabled || !_agent.isOnNavMesh || !_pc.WL_CanDash()) { Finish(false, "state"); return; } // 타겟 소실·사망 → 즉시 종료. 재타겟은 MyActor(#770)가 한다(어그로 개념 없음). if (_target == null || _target.IsDead()) { Finish(false, "target"); return; } Vector3 to = _target.Get_position() - transform.position; to.y = 0f; float dist = to.magnitude; if (dist > 0.0001f) to /= dist; float stopDist = Mathf.Max(StopDistance(_cfg), 0.05f); if (dist <= stopDist) { Finish(true, "arrive"); return; } if (_cfg.dashLookAtTarget && to.sqrMagnitude > 0.0001f) transform.rotation = Quaternion.LookRotation(to); // 등속 이동. 이번 프레임에 대상을 지나치지 않도록 남은 거리로 한 번 자른다. float step = Mathf.Min(_speed * Time.deltaTime, dist - stopDist); if (step > 0f) _agent.Move(to * step); _elapsed += Time.deltaTime; if (_elapsed >= Mathf.Max(_cfg.dashMaxSeconds, 0.05f)) { // 시간이 다 됐다. 사거리 안이면 때리고, 아니면 기존 달리기 추적으로 돌려준다. Vector3 d2 = _target.Get_position() - transform.position; d2.y = 0f; bool inReach = d2.magnitude <= stopDist + 0.35f; Finish(inReach, "timeout"); if (!inReach && _pc != null) _pc.WL_PlayRunNow(); } } /// 도착 판정 거리. 0 이면 공격 전진(AttackStep)의 정지 거리를 그대로 쓴다. private static float StopDistance(WLCombatMotionSettings cfg) { return cfg.dashStopDistance > 0f ? cfg.dashStopDistance : cfg.attackStepStopDistance; } private void OnDisable() { Cancel(); } // ── 유틸 ──────────────────────────────────────────────────────────────── /// 컨트롤러 0번 레이어에 그 이름의 상태가 있는가(런타임 판정 = Animator.HasState). private static bool HasState(Animator anim, string stateName) { return anim.HasState(0, Animator.StringToHash(stateName)); } /// /// 클립 길이(초). 런타임 컨트롤러에서는 상태→클립 매핑을 직접 못 읽으므로 /// animationClips 에서 이름으로 찾는다. 못 찾으면 기본값을 쓴다. /// private static float FindStateClipLength(Animator anim, string clipName, float fallback) { var ctrl = anim.runtimeAnimatorController; if (ctrl == null) return fallback; var clips = ctrl.animationClips; if (clips == null) return fallback; for (int i = 0; i < clips.Length; i++) if (clips[i] != null && string.Equals(clips[i].name, clipName, System.StringComparison.OrdinalIgnoreCase)) return clips[i].length > 0.01f ? clips[i].length : fallback; return fallback; } } }