Project_WL/Assets/WL/Combat/DashDriver.cs

366 lines
20 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>
/// 중간 거리에서 대쉬 클립을 재생하며 타겟 쪽으로 직진하고, 도착하면 즉시 공격한다.
/// 필요할 때 런타임으로 자동 부착되므로 프리팹을 미리 손댈 필요가 없다.
/// </summary>
[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 로 읽는다 · 게임 로직은 쓰지 않는다) ────────────
/// <summary>대쉬를 시작한 누적 횟수.</summary>
public static int DashCount;
/// <summary>마지막 대쉬 시작 시각(Time.time).</summary>
public static float LastBeginTime;
/// <summary>마지막 대쉬 종료 시각(Time.time).</summary>
public static float LastEndTime;
/// <summary>마지막 대쉬의 시작 거리(m).</summary>
public static float LastStartDistance;
/// <summary>마지막 대쉬의 실제 이동 거리(m · NavMesh 투영 후).</summary>
public static float LastMovedDistance;
/// <summary>마지막 대쉬가 끝난 뒤 공격으로 이어졌는가.</summary>
public static bool LastEndedInAttack;
/// <summary>마지막 대쉬 요약(검증용 문자열).</summary>
public static string LastInfo = "(none)";
/// <summary>대쉬 중인가.</summary>
public bool IsDashing { get { return _active; } }
/// <summary>이 액터가 지금 대쉬 중인가. MyActor.Update 가 접근 로직을 건너뛸지 묻는다.</summary>
public static bool IsDashingNow(Component actor)
{
if (actor == null) return false;
var d = actor.GetComponent<DashDriver>();
return d != null && d._active;
}
/// <summary>
/// 대쉬를 시작할 수 있으면 시작하고 true 를 돌려준다.
/// false 면 호출부(MyActor)는 기존 달리기 추적을 그대로 한다(C8 롤백 경로).
/// </summary>
/// <param name="distance">타겟까지의 현재 수평 거리(m)</param>
/// <param name="reach">지금 자리에서 때릴 수 있는 거리(m). 도착 판정의 하한으로 쓴다.</param>
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;
// ── PD #816x 최종 게이트 — 사거리 안에 적이 있으면 대쉬 0, 그 적을 제자리에서 친다.
// 대쉬의 유일한 진입점이 여기(MyActor.cs:242)라 어떤 경로로 와도 반드시 통과한다.
if (cfg.dashBlockIfEnemyInReach && cfg.dashBlockFinalGate
&& BlockByEnemyInReach(pc, target, reach, cfg)) return true; // true = 호출부가 Play_Run 을 부르지 않는다
// 거리 창 밖이면 기존 동작. (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<DashDriver>();
if (driver == null) driver = pc.gameObject.AddComponent<DashDriver>();
if (driver._active) return true; // 이미 대쉬 중 = 접근 로직 건너뛰기
if (Time.time < driver._readyTime) return false; // 쿨다운
return driver.BeginInternal(pc, agent, cfg, target, distance, reach);
}
// ─────────────────────────────────────────────────────────────────────────
// PD #816x — 사거리 안 적 우선 · 대쉬 억제
//
// PD 원문: "플레이어의 공격 사정거리에 적이 있을 경우에는 대시(몬스터에게 접근)하지 않고
// 현재 위치에서 공격 사거리에 닿는 적을 공격하도록 해줘."
//
// 왜 여기인가 — 기존 #790 차단은 MyActor(원본) 안에서 `Find_Target_Facing(RetargetSearchRange, true)`
// 로만 돌았고, 그 '사거리'는 `Get_AttackReach`(정지거리 + 상대 반경 보정 + 크기 보정 + 여유)라
// PD 가 화면에서 보는 **사거리 원**(WeaponTrailDriver.ResolveAttackReach = 기본 정지거리 + AttackRange_Up + 여유)
// 과 같은 값이 아니다. 두 해석이 갈리면 "원 안에 있는데 대쉬" 가 그대로 난다.
// 그래서 대쉬의 유일한 진입점에서 **두 해석의 큰 쪽 + 여유**로 다시 한 번 본다(값은 전부 SO).
//
// 🔴 원본 0줄 — 타겟 교체는 공개 API `Actor.Set_Target(Actor)` · 공격은 `PCActor.WL_PlayAttackNow()`.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>게이트가 대쉬를 막은 누적 횟수 · 그중 타겟을 바꾼 횟수(검증용 읽기 전용).</summary>
public static int BlockedByReachCount, BlockedRetargetCount;
/// <summary>마지막 차단 요약(검증용 문자열).</summary>
public static string LastBlockInfo = "(none)";
/// <summary>게이트가 쓰는 사거리(m) — 전투 reach 와 화면 사거리 원 중 큰 쪽 + 여유.</summary>
public static float GateReach(float reach, WLCombatMotionSettings cfg)
{
float r = reach;
if (cfg.dashBlockUseRingReach)
{
float ring = WeaponTrailDriver.LastAttackReach; // 화면에 그려지는 그 값(816p 사거리 원과 동일 출처)
if (ring > r) r = ring;
}
return r + Mathf.Max(cfg.dashBlockReachBonus, 0f);
}
/// <summary>사거리 안에 적이 있으면 그 적을 제자리에서 치고 true(= 대쉬 안 함).</summary>
private static bool BlockByEnemyInReach(PCActor pc, Actor target, float reach, WLCombatMotionSettings cfg)
{
if (ActorInfo.Ins == null) return false;
float gate = GateReach(reach, cfg);
if (gate <= 0.01f) return false;
Vector3 my = pc.Get_position();
var actors = ActorInfo.Ins.Get_EnemyActors(pc.IsEnemy(), my, gate);
Actor best = null;
float bestDist = float.MaxValue;
for (int i = 0; i < actors.Count; i++)
{
var a = actors[i];
if (a == null || a.IsDead()) continue;
Vector3 d = a.Get_position() - my; d.y = 0f;
float dist = d.magnitude;
if (dist > gate) continue; // Get_EnemyActors 는 수직 성분도 보므로 수평으로 다시 자른다
if (dist < bestDist) { bestDist = dist; best = a; }
}
ActorInfo.Ins.Release_Actors(actors);
if (best == null) return false;
BlockedByReachCount++;
bool retargeted = false;
if (cfg.dashBlockRetarget && best != target)
{
if (pc.IsMainPC())
{
if (target != null) target.Set_Outline(false);
best.Set_Outline(true);
}
pc.Set_Target(best); // 공개 API — 원본 수정 0줄
BlockedRetargetCount++;
retargeted = true;
}
if (cfg.dashBlockLookAtTarget)
{
Vector3 dir = best.Get_position() - my; dir.y = 0f;
if (dir.sqrMagnitude > 0.0001f) pc.transform.rotation = Quaternion.LookRotation(dir);
}
pc.WL_PlayAttackNow(); // 제자리 공격 (호출부는 CurAnim < Attack 구간이라 콤보를 끊지 않는다)
LastBlockInfo = string.Format("gate={0:F2}m d={1:F2}m target={2} retarget={3}",
gate, bestDist, best.name, retargeted);
#if UNITY_EDITOR
if (cfg.verboseLog) Debug.Log("[WL #816x] 대쉬 억제 " + LastBlockInfo, pc);
#endif
return true;
}
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;
}
/// <summary>대쉬를 끝낸다. attack = true 면 그 자리에서 즉시 공격을 건다.</summary>
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
}
/// <summary>대쉬를 즉시 접는다(피격·사망·정지·조이스틱).</summary>
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();
}
}
/// <summary>도착 판정 거리. 0 이면 공격 전진(AttackStep)의 정지 거리를 그대로 쓴다.</summary>
private static float StopDistance(WLCombatMotionSettings cfg)
{
return cfg.dashStopDistance > 0f ? cfg.dashStopDistance : cfg.attackStepStopDistance;
}
private void OnDisable() { Cancel(); }
// ── 유틸 ────────────────────────────────────────────────────────────────
/// <summary>컨트롤러 0번 레이어에 그 이름의 상태가 있는가(런타임 판정 = Animator.HasState).</summary>
private static bool HasState(Animator anim, string stateName)
{
return anim.HasState(0, Animator.StringToHash(stateName));
}
/// <summary>
/// 클립 길이(초). 런타임 컨트롤러에서는 상태→클립 매핑을 직접 못 읽으므로
/// animationClips 에서 이름으로 찾는다. 못 찾으면 기본값을 쓴다.
/// </summary>
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;
}
}
}