Project_WL/Assets/WL/Combat/DashDriver.cs

372 lines
21 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.

// ─────────────────────────────────────────────────────────────────────────────
// 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)라 어떤 경로로 와도 반드시 통과한다.
// `WL_CanDash()` 를 함께 보는 이유 = 조이스틱 조작·CC·정지·사망 중에는 기존 동작을 그대로 둔다
// (플레이어가 직접 움직이는데 제자리 공격을 강제하면 조작을 뺏는다).
if (cfg.dashBlockIfEnemyInReach && cfg.dashBlockFinalGate && pc.WL_CanDash()
&& 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, PCActor pc = null)
{
float r = reach;
if (cfg.dashBlockUseRingReach)
{
float ring = WeaponTrailDriver.LastAttackReach; // 화면에 그려지는 그 값(816p 사거리 원과 동일 출처)
if (ring > r) r = ring;
}
float bonus = Mathf.Max(cfg.dashBlockReachBonus, 0f);
var o = WLCombatMotionSettings.FindClassStop(pc); // #900 해머 = 여유 0 (원 밖 적은 다가가서 친다)
if (o != null && o.gateBonus >= 0f) bonus = o.gateBonus;
return r + bonus;
}
/// <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, pc);
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;
// #900 PD 「사정 거리 밖에서 스킬이 시전 가능한 상태일 경우(쿨타임 충족 시) 대쉬 후 바로 방패치기」 — 표 dash_cast 칸 스킬이 준비돼 있으면 그 스킬, 아니면 일반 공격
if (attack && _pc != null && !WL.Combat.Weapon.WLWeaponSkillSlots.TryDashCast(_pc)) _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)의 정지 거리를 그대로 쓴다. #893 approachReachRatio > 0 이면 공격 가능 거리 × 비율.</summary>
private float StopDistance(WLCombatMotionSettings cfg)
{
return WLCombatMotionSettings.ApproachStop(_pc, 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;
}
}
}