Project_WL/Assets/WL/Scripts/Player/PlayerController.cs

1127 lines
57 KiB
C#
Raw Normal View History

using UnityEngine;
using UnityEngine.InputSystem;
using WL.Combat;
namespace WL.Player
{
/// <summary>
/// 플레이어 컨트롤러 (Suriyun Kazuko / CombatGirls RifleGirl 공용).
///
/// 1) 씬 시작 시 쓰러진 자세(Down · 98_Damage LyingFront) → 기상(GetUp · LyingFront_WakeUp 정방향) → 조작 개방.
/// (구 Cry 1회 단계는 PD #705 ② 로 제거 — Cry 상태·클립은 남아 있고 시퀀스 호출만 끊었다)
/// 2) 조작 = 모바일 터치 드래그가 기본. 화면 좌측 하단 1/4 = 가상 패드, 그 밖 = 터치 지점 방향 자동 이동.
/// (에디터에서는 마우스가 같은 동작. 키보드 WASD/게임패드는 에디터 테스트용으로 병행 유지)
/// 3) 입력이 없으면 자동 탐색: 시야 안 적 → 공격 범위까지 이동 후 공격 / 적 없으면 Idle.
///
/// 모든 수치는 ScriptableObject 또는 [SerializeField] 로 외부화한다(코드 상수 금지 · C45).
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class PlayerController : MonoBehaviour
{
public enum WakeState { Down, GetUp, Cry, Playable, Dead }
public enum ActState { Idle, Move, Chase, Attack, Swim }
[Header("수치 설정 (ScriptableObject)")]
[SerializeField] private PlayerMoveSettings settings;
[SerializeField] private PlayerTouchSettings touchSettings;
[Header("참조")]
[SerializeField] private Animator animator;
[SerializeField] private Transform cameraTransform;
[SerializeField] private InputActionAsset inputActions;
[SerializeField] private PlayerCombat combat;
[Tooltip("수영 판정기. 비어 있으면 같은 오브젝트에서 찾는다")]
[SerializeField] private WaterProbe waterProbe;
[Tooltip("검 궤적 슬래시 VFX 재생기 (PD #712 ②). 비어 있으면 같은 오브젝트에서 찾는다")]
[SerializeField] private SlashVfxPlayer slashVfx;
[Header("입력 액션 이름 (에디터 테스트용 키보드/게임패드)")]
[SerializeField] private string actionMapName = "Player";
[SerializeField] private string moveActionName = "Move";
[SerializeField] private string sprintActionName = "Sprint";
[SerializeField] private string jumpActionName = "Jump";
[Tooltip("끄면 터치/마우스 입력만 받는다")]
[SerializeField] private bool enableKeyboardGamepad = true;
[Header("애니메이터 상태 / 파라미터 이름 (Player_Base.controller 기준)")]
[Tooltip("공용 98_Damage · LyingFront (엎드려 쓰러진 정지 자세)")]
[SerializeField] private string downStateName = "Down";
[Tooltip("공용 98_Damage · LyingFront_WakeUp (일어나기 · 정방향 재생)")]
[SerializeField] private string getUpStateName = "GetUp";
[Tooltip("전투 세트 Idle/Walk/Run 블렌드 트리")]
[SerializeField] private string locomotionStateName = "Locomotion";
[Tooltip("캐릭터 고유 감정 모션. Kazuko = Cry_wing (Override 로 교체)")]
[SerializeField] private string cryStateName = "Cry";
[Tooltip("콤보 단계별 공격 상태 이름. 전투 세트에서 캐릭터(직업)마다 Override 로 교체된다")]
[SerializeField] private string[] attackStateNames = new string[] { "Attack1", "Attack2", "Attack3" };
[Tooltip("공용 98_Damage · Damage1 (피격)")]
[SerializeField] private string hitStateName = "Hit";
[Tooltip("공용 98_Damage · KnockDown_F_Light (사망)")]
[SerializeField] private string deathStateName = "Death";
[Tooltip("공용 50_Adventure · Swim 블렌드 트리")]
[SerializeField] private string swimStateName = "Swim";
[SerializeField] private string speedParamName = "Speed";
[SerializeField] private string groundedParamName = "Grounded";
[Header("조준/전투")]
[Tooltip("터치 지점 방향 계산에 쓰는 캐릭터 기준 높이(m)")]
[SerializeField] private float bodyScreenAnchorHeight = 1.0f;
[Tooltip("공격 중 적을 향해 도는 속도(도/초)")]
[SerializeField] private float aimTurnSpeed = 540f;
[Tooltip("공격 -> Locomotion 크로스페이드 시간(초)")]
[SerializeField] private float attackBlendTime = 0.08f;
[Header("시작 시퀀스 (PD #711 — 교체 투입 시 끈다)")]
[Tooltip("씬 시작 시 쓰러짐→기상 시퀀스를 재생한다. 캐릭터 교체로 투입될 때는 PlayerSwitcher 가 끈다")]
[SerializeField] private bool playIntroSequence = true;
[Header("디버그")]
[SerializeField] private bool logWakeSequence = true;
[Tooltip("체크 시 실제 입력 대신 아래 값을 이동 입력으로 사용한다(자동 검증용)")]
[SerializeField] private bool useDebugMoveInput = false;
[SerializeField] private Vector2 debugMoveInput = Vector2.zero;
private CharacterController _controller;
private InputActionMap _map;
private InputAction _moveAction, _sprintAction, _jumpAction;
private TouchInputProvider _touch;
private FollowCamera _followCam; // PD #713 ② — 입력 기준 요·요 추종 목표 보고 대상
private bool _followCamCached;
private float _inputLockTimer; // PD #711 — 캐릭터 교체 직후 입력 잠금 잔여 시간(초)
private float _idleTimer; // PD #715 ③ — 무입력·무전투 지속 시간(초)
private bool _idleVariationPlaying;
private float _idleVariationTimer;
private WakeState _wakeState = WakeState.Down;
private ActState _actState = ActState.Idle;
private float _stateTimer;
private float _getUpDuration;
private float _currentSpeed;
private float _speedDampVelocity;
private float _rotationDampVelocity;
private float _verticalVelocity;
private bool _suppressMoveRotation; // PD #733 — 공격 중 이동할 때는 이동 방향 회전을 끄고 대상 방향을 본다
private float _moveSpeedScale = 1f; // PD #734 — 공격 중 이동 속도 배율(설정 attackMoveSpeedRatio) · 평소 1
private bool _hurtLocked; // PD #735 — 피격 모션 중 이동 잠금 진행 여부(검증 로그용)
private Vector3 _hurtLockStartPos; // PD #735 — 잠금 시작 위치(피격 중 이동거리 실측용)
private float _attackTimer; // 현재 공격 클립 남은 시간
private float _attackClipLength; // 현재 공격 클립 전체 길이 (콤보 전이 시점 계산용)
private float _hitDelayTimer; // 피해 적용까지 남은 시간
private bool _hitPending;
private Enemy _target;
private int _comboStep; // 0 = 콤보 없음, 1~N = 현재 단계
private bool _comboAdvanceResolved; // 이번 단계의 전이 판정을 이미 했는가
private int _comboHitCount; // 이번 콤보에서 실제로 들어간 타격 수 (검증 로그용)
private bool _isSwimming;
private float _waterSurfaceY;
private float _deathTimer;
private float _hurtTimer; // 피격 모션 남은 시간
private float _hp = -1f; // 현재 체력 (첫 피격 때 최대치로 초기화)
private int _speedParamHash, _groundedParamHash;
private bool _hasSpeedParam, _hasGroundedParam;
private float _tDown = -1f, _tGetUp = -1f, _tCry = -1f, _tPlayable = -1f;
public bool IsPlayable { get { return _wakeState == WakeState.Playable; } }
public WakeState CurrentWakeState { get { return _wakeState; } }
public ActState CurrentActState { get { return _actState; } }
public float CurrentSpeed { get { return _currentSpeed; } }
public Enemy CurrentTarget { get { return _target; } }
public TouchInputProvider Touch { get { return _touch; } }
/// <summary>가상패드 UI 가 패드 영역·반경을 그릴 때 참조하는 설정 에셋.</summary>
public PlayerTouchSettings TouchSettings { get { return touchSettings; } }
/// <summary>현재 체력. 하한(기본 1) 아래로 내려가지 않는다 — 아직 사망은 구현하지 않는다(PD 지시 ⑥).</summary>
public float CurrentHp { get { return _hp < 0f ? MaxHp : _hp; } }
public float MaxHp { get { var s = CombatConfig; return s != null ? s.playerMaxHp : 100f; } }
/// <summary>공격 모션 재생 중인가. 피격 모션 생략 판정에 쓴다(PD 지시 ③).</summary>
public bool IsAttacking { get { return _attackTimer > 0f; } }
/// <summary>피격 모션 재생 중인가.</summary>
public bool IsHurt { get { return _hurtTimer > 0f; } }
/// <summary>수영 중인가 (PD #704 범위확정 1).</summary>
public bool IsSwimming { get { return _isSwimming; } }
/// <summary>사망 상태인가 (PD #704 범위확정 4).</summary>
public bool IsDead { get { return _wakeState == WakeState.Dead; } }
/// <summary>현재 콤보 단계. 0 = 진행 중 아님, 1~3 = Attack1~3.</summary>
public int CurrentComboStep { get { return _comboStep; } }
/// <summary>이번(또는 마지막) 콤보에서 실제로 들어간 타격 수. 검증용.</summary>
public int LastComboHitCount { get { return _comboHitCount; } }
/// <summary>자동 검증용 — 실제 터치 대신 화면 기준 입력(x=우, y=상)을 주입한다. 끄면 실제 입력으로 복귀.</summary>
public void SetDebugMoveInput(bool enabled, Vector2 screenInput)
{
useDebugMoveInput = enabled;
debugMoveInput = enabled ? screenInput : Vector2.zero;
}
/// <summary>주입 입력이 켜져 있는가.</summary>
public bool DebugMoveInputEnabled { get { return useDebugMoveInput; } }
/// <summary>전투 수치 에셋. PlayerCombat 이 들고 있는 것을 그대로 쓴다.</summary>
private CombatSettings CombatConfig { get { return combat != null ? combat.Settings : null; } }
/// <summary>각 단계 진입 시각(초, 씬 로드 기준). 아직 진입 전이면 -1.</summary>
public float DownEnterTime { get { return _tDown; } }
public float GetUpEnterTime { get { return _tGetUp; } }
public float CryEnterTime { get { return _tCry; } }
public float PlayableEnterTime { get { return _tPlayable; } }
public float MeasuredGetUpDuration { get { return _getUpDuration; } }
// ───────────────────────────────── 초기화
private void Awake()
{
_controller = GetComponent<CharacterController>();
if (animator == null) animator = GetComponentInChildren<Animator>();
if (combat == null) combat = GetComponent<PlayerCombat>();
if (waterProbe == null) waterProbe = GetComponent<WaterProbe>();
if (slashVfx == null) slashVfx = GetComponent<SlashVfxPlayer>();
if (cameraTransform == null && Camera.main != null) cameraTransform = Camera.main.transform;
if (animator != null)
{
animator.applyRootMotion = false;
foreach (var p in animator.parameters)
{
if (p.name == speedParamName) { _hasSpeedParam = true; _speedParamHash = p.nameHash; }
else if (p.name == groundedParamName) { _hasGroundedParam = true; _groundedParamHash = p.nameHash; }
}
}
_touch = new TouchInputProvider(touchSettings);
ResolveInput();
}
private void ResolveInput()
{
if (inputActions == null) return;
_map = inputActions.FindActionMap(actionMapName, false);
if (_map == null)
{
Debug.LogWarning("[PlayerController] 액션맵을 찾지 못했습니다: " + actionMapName, this);
return;
}
_moveAction = _map.FindAction(moveActionName, false);
_sprintAction = _map.FindAction(sprintActionName, false);
_jumpAction = _map.FindAction(jumpActionName, false);
}
private void OnEnable() { if (_map != null && !_map.enabled) _map.Enable(); }
private void OnDisable() { if (_map != null && _map.enabled) _map.Disable(); }
private void Start()
{
// PD #711 — 캐릭터 교체로 나중에 켜진 캐릭터는 시작 시퀀스를 다시 재생하지 않는다(최초 1회만).
if (playIntroSequence) EnterDown();
else EnterPlayable();
}
/// <summary>시작 시퀀스(쓰러짐→기상) 재생 여부. 교체로 투입되는 캐릭터는 끄고 켠다(PD #711).</summary>
public void SetPlayIntroSequence(bool v) { playIntroSequence = v; }
/// <summary>체력을 그대로 옮긴다(캐릭터 교체 시 유지 · PD #711).</summary>
public void SetHp(float value) { _hp = Mathf.Clamp(value, 0f, MaxHp); }
/// <summary>교체 직후 잠깐 입력을 막는다(초). 버튼을 뗄 때의 잔여 터치로 이동이 시작되는 것을 막는다.</summary>
public void LockInput(float seconds) { _inputLockTimer = Mathf.Max(_inputLockTimer, seconds); }
// ───────────────────────────────── 기상 시퀀스
private void EnterDown()
{
_wakeState = WakeState.Down;
_stateTimer = 0f;
_tDown = Time.timeSinceLevelLoad;
if (animator != null && animator.runtimeAnimatorController != null)
{
// 98_Damage · LyingFront = 전 구간이 엎드려 쓰러진 자세라 어느 시점에서 멈춰도 같다.
// Down 상태의 speed 는 0 이므로 아래 정규화 시각에서 정지한 채 유지된다.
animator.Play(downStateName, 0, settings != null ? settings.downFreezeNormalizedTime : 0f);
animator.Update(0f);
}
if (logWakeSequence)
Debug.Log("[PlayerController] WAKE t=" + Time.timeSinceLevelLoad.ToString("F2") + "s STATE=Down (쓰러진 자세 유지) pos=" + transform.position.ToString("F3"));
}
private void EnterGetUp()
{
_wakeState = WakeState.GetUp;
_stateTimer = 0f;
_tGetUp = Time.timeSinceLevelLoad;
_getUpDuration = settings != null ? settings.getUpFallbackDuration : 1.7f;
if (animator != null && animator.runtimeAnimatorController != null)
{
// 98_Damage · LyingFront_WakeUp = 엎드린 자세에서 일어나는 전용 클립이므로 정방향(0 -> 1) 재생한다.
// (구 Kazuko DieA_wing 역재생 방식은 PD #704 추가지시 3 으로 폐기)
animator.Play(getUpStateName, 0, 0f);
animator.Update(0f);
var info = animator.GetCurrentAnimatorStateInfo(0);
if (info.length > 0f && !float.IsInfinity(info.length)) _getUpDuration = info.length;
}
if (logWakeSequence)
Debug.Log("[PlayerController] WAKE t=" + Time.timeSinceLevelLoad.ToString("F2") + "s STATE=GetUp 재생 시작 (길이 " + _getUpDuration.ToString("F2") + "s)");
}
/// <summary>
/// 기상 직후 Cry 모션을 설정된 시간만큼 재생한다. crySeconds 0 이면 건너뛴다.
/// **PD #705 ② 로 현재 시퀀스에서는 호출하지 않는다** (미사용 · 복구용으로 보존).
/// </summary>
private void EnterCry()
{
float cry = settings != null ? settings.crySeconds : 0f;
if (cry <= 0f || string.IsNullOrEmpty(cryStateName)) { EnterPlayable(); return; }
_wakeState = WakeState.Cry;
_stateTimer = 0f;
_tCry = Time.timeSinceLevelLoad;
if (animator != null && animator.runtimeAnimatorController != null)
animator.CrossFade(cryStateName, settings != null ? settings.getUpToCryBlend : 0.12f, 0, 0f);
if (logWakeSequence)
Debug.Log("[PlayerController] WAKE t=" + Time.timeSinceLevelLoad.ToString("F2") + "s STATE=Cry 재생 (" + cry.ToString("F2") + "s)");
}
private void EnterPlayable()
{
_wakeState = WakeState.Playable;
_actState = ActState.Idle;
_stateTimer = 0f;
_tPlayable = Time.timeSinceLevelLoad;
CrossFadeLocomotion();
if (logWakeSequence)
Debug.Log("[PlayerController] WAKE t=" + Time.timeSinceLevelLoad.ToString("F2") + "s STATE=Playable — 조작 활성화 pos=" + transform.position.ToString("F3"));
}
private void CrossFadeLocomotion()
{
if (animator == null || animator.runtimeAnimatorController == null) return;
animator.CrossFade(locomotionStateName, settings != null ? settings.getUpToLocomotionBlend : 0.15f, 0, 0f);
}
// ───────────────────────────────── 메인 루프
private void Update()
{
float dt = Time.deltaTime;
_stateTimer += dt;
if (_inputLockTimer > 0f) _inputLockTimer -= dt;
switch (_wakeState)
{
case WakeState.Down:
ApplyGravityOnly(dt);
if (settings == null || _stateTimer >= settings.downHoldSeconds) EnterGetUp();
break;
case WakeState.GetUp:
ApplyGravityOnly(dt);
// PD #705 ② — 기상 직후 Cry 를 거치지 않고 바로 조작을 연다.
if (IsGetUpFinished()) EnterPlayable();
break;
case WakeState.Cry:
// 현재 시퀀스는 이 단계로 들어오지 않는다(PD #705 ②).
// 되살릴 때는 위 GetUp 분기를 EnterCry() 로 되돌리기만 하면 된다.
ApplyGravityOnly(dt);
if (settings == null || _stateTimer >= settings.crySeconds) EnterPlayable();
break;
case WakeState.Playable:
PlayableUpdate(dt);
break;
case WakeState.Dead:
// 사망 후에는 조작을 받지 않는다. 클립이 끝나면 그대로 쓰러진 자세로 남는다.
if (_deathTimer > 0f) _deathTimer -= dt;
ApplyGravityOnly(dt);
break;
}
if (animator != null && _hasGroundedParam) animator.SetBool(_groundedParamHash, _controller.isGrounded);
}
private bool IsGetUpFinished()
{
float timeout = settings != null ? settings.getUpTimeoutSeconds : 6f;
if (_stateTimer >= timeout) return true;
if (_stateTimer >= _getUpDuration) return true;
if (animator != null && animator.runtimeAnimatorController != null)
{
var info = animator.GetCurrentAnimatorStateInfo(0);
if (info.IsName(getUpStateName) && info.normalizedTime >= 1f) return true;
}
return false;
}
private void PlayableUpdate(float dt)
{
// 1) 입력 수집 — 터치 최우선
Vector2 input = ReadCombinedInput();
bool hasInput = input.sqrMagnitude > 0.0000001f;
// 1-1) 물 판정 — 수영 중에는 전투를 멈추고 수면을 따라간다 (PD #704 범위확정 1)
UpdateSwimState();
if (_isSwimming)
{
if (_comboStep > 0 || _attackTimer > 0f) CancelCombo(false);
_hurtTimer = 0f;
_actState = ActState.Swim;
SwimUpdate(input, dt);
return;
}
// 1-2) 피격 모션 진행 — 시간이 지나면 이동/대기 상태로 되돌린다
if (_hurtTimer > 0f)
{
if (!_hurtLocked) { _hurtLocked = true; _hurtLockStartPos = transform.position; }
_hurtTimer -= dt;
if (_hurtTimer <= 0f)
{
CrossFadeLocomotion();
Vector3 moved = transform.position - _hurtLockStartPos; moved.y = 0f;
Debug.Log("[PlayerController] HURT 종료 — 피격 중 수평 이동거리=" + moved.magnitude.ToString("F3") + "m t=" + Time.timeSinceLevelLoad.ToString("F2") + "s");
_hurtLocked = false;
}
else
{
// PD #735 — 피격 모션이 재생되는 동안은 움직이지 않는다(중력만 적용 · 입력·공격 시작·자동 접근 전부 무시)
_actState = ActState.Idle;
StopHorizontal(dt);
return;
}
}
// 2) 공격 진행 중 처리 (피해 적용 타이밍 + 콤보 전이)
if (_hitPending)
{
_hitDelayTimer -= dt;
if (_hitDelayTimer <= 0f)
{
_hitPending = false;
_comboHitCount++;
if (combat != null) combat.ApplyDamage(_target);
// PD #733 검증용 — 타격 시점의 시선 오차(전방 ↔ 대상 방향)와 이동 여부를 남긴다
if (_target != null)
{
Vector3 toT = _target.transform.position - transform.position; toT.y = 0f;
if (toT.sqrMagnitude > 0.0001f)
Debug.Log("[PlayerController] FACE 타격 시선오차=" + Vector3.Angle(transform.forward, toT).ToString("F1")
+ "° 이동중=" + (_currentSpeed > 0.05f) + " 속도=" + _currentSpeed.ToString("F2") + " t=" + Time.timeSinceLevelLoad.ToString("F2") + "s");
}
// PD #718 — 검 궤적 이펙트는 여기(히트 시점)가 아니라 **스윙 시작 시점**에 연다(PlayAttackStep).
}
}
if (_attackTimer > 0f)
{
_attackTimer -= dt;
// PD #737 — 이동 중 공격(#732)은 원래대로 되돌린다: 이동 입력이 들어오면 공격·콤보를 끊고 즉시 이동 우선.
// (#733 최근접 대상 조준은 제자리 공격에 그대로 적용 · #734 공격 중 이동 배율은 사용 경로가 없어져 보존만)
if (hasInput) { CancelCombo(true); }
else
{
FaceNearestTarget(dt); // PD #733 — 공격 중 가장 가까운 대상 방향 자동 조준
StopHorizontal(dt);
ComboAdvanceUpdate(); // 다음 단계로 이을지 판정 (PD #704 추가지시 1·2 · #732 대상 재탐색)
if (_attackTimer <= 0f) EndCombo("클립 종료");
return;
}
}
if (hasInput)
{
_actState = ActState.Move;
_target = null;
CancelIdleVariation(); // PD #715 ③
MoveByInput(input, dt);
return;
}
// 3) 무입력 → 자동 탐색 패턴
AutoBehaviourUpdate(dt);
}
// ───────────────────────────────── 입력
private Vector2 ReadCombinedInput()
{
// PD #711 — 캐릭터 교체 직후 잠금 구간. 버튼을 뗄 때의 잔여 터치가 이동으로 새는 것을 막는다.
if (_inputLockTimer > 0f) return Vector2.zero;
if (useDebugMoveInput) return Vector2.ClampMagnitude(debugMoveInput, 1f);
// 터치/마우스 (모바일 기본 조작)
if (_touch != null)
{
_touch.Tick(GetPlayerScreenPosition());
Vector2 t = _touch.ScreenMove;
if (t.sqrMagnitude > 0.0000001f) return Vector2.ClampMagnitude(t, 1f);
// 눌렀지만 데드존 안 → 정지(자동 패턴으로 넘기지 않음). UI 위에서 시작한 터치는 제외.
if (_touch.IsPressed && !_touch.IsBlockedByUI) return Vector2.zero;
}
if (enableKeyboardGamepad && _moveAction != null)
return Vector2.ClampMagnitude(_moveAction.ReadValue<Vector2>(), 1f);
return Vector2.zero;
}
private Vector2 GetPlayerScreenPosition()
{
var cam = cameraTransform != null ? cameraTransform.GetComponent<Camera>() : Camera.main;
if (cam == null) return new Vector2(Screen.width * 0.5f, Screen.height * 0.5f);
Vector3 sp = cam.WorldToScreenPoint(transform.position + Vector3.up * bodyScreenAnchorHeight);
if (sp.z < 0f) { sp.x = Screen.width - sp.x; sp.y = Screen.height - sp.y; }
return new Vector2(sp.x, sp.y);
}
private bool ReadSprint()
{
if (_touch != null && _touch.IsPressed && touchSettings != null)
{
// 패드 밖 터치(자동 이동)는 항상 달리기
if (_touch.CurrentMode == TouchInputProvider.Mode.PointAt) return touchSettings.autoMoveAlwaysRun;
return _touch.ScreenMove.magnitude >= touchSettings.sprintInputThreshold;
}
if (enableKeyboardGamepad && _sprintAction != null) return _sprintAction.IsPressed();
return false;
}
// ───────────────────────────────── 이동
/// <summary>
/// 드래그 크기 → 목표 속도. 임계값 미만은 걷기(미세 위치 조작 · 크기에 비례),
/// 임계값 이상은 걷기~달리기 구간을 선형 보간한다.
/// </summary>
private float ResolveTargetSpeed(float inputMag)
{
float walk = settings != null ? settings.walkSpeed : 2.2f;
float run = settings != null ? settings.runSpeed : 5.5f;
bool padDrag = _touch != null && _touch.IsPressed && _touch.CurrentMode == TouchInputProvider.Mode.VirtualPad;
if (!padDrag || touchSettings == null) return (ReadSprint() ? run : walk) * inputMag;
float thr = Mathf.Clamp(touchSettings.sprintInputThreshold, 0.01f, 0.99f);
if (inputMag < thr)
{
// 걷기 구간 — 짧은 드래그일수록 느리게(최소 비율까지)
if (!touchSettings.scaleWalkByDrag) return walk;
float t = inputMag / thr;
return walk * Mathf.Lerp(touchSettings.walkMinSpeedRatio, 1f, t);
}
// 달리기 구간 — 임계값에서 걷기 속도, 최대 드래그에서 달리기 속도
float u = Mathf.Clamp01((inputMag - thr) / Mathf.Max(1f - thr, 0.0001f));
return Mathf.Lerp(walk, run, u);
}
private void MoveByInput(Vector2 input, float dt)
{
float targetSpeed = ResolveTargetSpeed(input.magnitude) * _moveSpeedScale; // PD #734 — 공격 중에는 배율 축소
var fc = FollowCam;
// PD #719 ⑤ — 방향 변환 **전에** 패드 원본 입력을 보고한다.
// 카메라가 이 보고로 기준 요를 래치해, 누르고 있는 동안 이동 벡터가 카메라와 함께 휘는 순환을 끊는다.
if (fc != null) fc.ReportPadInput(input);
Vector3 dir = ScreenToWorldDirection(input);
// PD #713 ② — 카메라 요 추종 목표를 캐릭터 facing 이 아니라 이 입력 방향으로 삼게 보고한다
if (fc != null) fc.ReportMoveDirection(dir);
MoveTowards(dir, targetSpeed, dt);
}
/// <summary>추적 카메라. cameraTransform 에서 1회 찾아 캐시한다(없으면 null).</summary>
private FollowCamera FollowCam
{
get
{
if (_followCamCached) return _followCam;
_followCamCached = true;
if (cameraTransform != null) _followCam = cameraTransform.GetComponent<FollowCamera>();
if (_followCam == null) _followCam = Object.FindFirstObjectByType<FollowCamera>();
return _followCam;
}
}
/// <summary>
/// 화면 기준 입력(x=우, y=상)을 월드 방향으로 변환.
/// 기준 방향은 카메라가 정한다(PD #713 ②) — 요 추종 모드는 모드의 고정 요를 기준으로 삼아야
/// "패드 방향 → 월드 방향 → 카메라 요 → 다시 패드 기준" 순환이 끊기고 추종이 수렴한다.
/// </summary>
private Vector3 ScreenToWorldDirection(Vector2 input)
{
Vector3 fwd = Vector3.forward, right = Vector3.right;
var fcam = FollowCam;
if (fcam != null)
{
Quaternion q = Quaternion.Euler(0f, fcam.InputReferenceYaw, 0f);
fwd = q * Vector3.forward;
right = q * Vector3.right;
}
else if (cameraTransform != null)
{
fwd = Vector3.ProjectOnPlane(cameraTransform.forward, Vector3.up).normalized;
right = Vector3.ProjectOnPlane(cameraTransform.right, Vector3.up).normalized;
if (fwd.sqrMagnitude < 0.0001f) fwd = Vector3.ProjectOnPlane(cameraTransform.up, Vector3.up).normalized;
}
Vector3 d = fwd * input.y + right * input.x;
return d.sqrMagnitude > 1f ? d.normalized : d;
}
private void MoveTowards(Vector3 moveDir, float targetSpeed, float dt)
{
_currentSpeed = Mathf.SmoothDamp(_currentSpeed, targetSpeed, ref _speedDampVelocity,
settings != null ? settings.speedSmoothTime : 0.12f);
if (_currentSpeed < 0.01f) _currentSpeed = 0f;
if (moveDir.sqrMagnitude > 0.0001f && !_suppressMoveRotation)
{
float targetYaw = Mathf.Atan2(moveDir.x, moveDir.z) * Mathf.Rad2Deg;
float yaw = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetYaw, ref _rotationDampVelocity,
settings != null ? settings.rotationSmoothTime : 0.1f);
transform.rotation = Quaternion.Euler(0f, yaw, 0f);
}
ApplyGravityStep(dt);
if (_controller.isGrounded && enableKeyboardGamepad && _jumpAction != null && _jumpAction.WasPressedThisFrame())
{
float jh = settings != null ? settings.jumpHeight : 0f;
float g = settings != null ? settings.gravity : -19.62f;
if (jh > 0f) _verticalVelocity = Mathf.Sqrt(-2f * g * jh);
}
Vector3 velocity = (moveDir.sqrMagnitude > 0.0001f ? moveDir.normalized : Vector3.zero) * _currentSpeed;
velocity.y = _verticalVelocity;
_controller.Move(velocity * dt);
UpdateLocomotionAnimator();
}
private void StopHorizontal(float dt)
{
_currentSpeed = Mathf.SmoothDamp(_currentSpeed, 0f, ref _speedDampVelocity,
settings != null ? settings.speedSmoothTime : 0.12f);
if (_currentSpeed < 0.01f) _currentSpeed = 0f;
ApplyGravityStep(dt);
_controller.Move(new Vector3(0f, _verticalVelocity, 0f) * dt);
UpdateLocomotionAnimator();
}
// ───────────────────────────────── 무입력 자동 탐색 패턴
private void AutoBehaviourUpdate(float dt)
{
if (combat == null) { _actState = ActState.Idle; StopHorizontal(dt); return; }
_target = combat.FindTarget();
if (_target == null)
{
_actState = ActState.Idle;
StopHorizontal(dt);
IdleVariationUpdate(dt); // PD #715 ③ — 오래 서 있으면 대기 변형 1회
return;
}
CancelIdleVariation();
if (combat.IsInAttackRange(_target))
{
_actState = ActState.Attack;
FaceTarget(dt);
StopHorizontal(dt);
// 피격 모션이 재생 중이면 그 모션이 끝날 때까지 새 콤보를 시작하지 않는다
if (combat.CanAttack && _hurtTimer <= 0f && _comboStep == 0) BeginCombo();
return;
}
// 공격 범위 밖 → 공격 범위까지 접근
_actState = ActState.Chase;
Vector3 to = _target.transform.position - transform.position;
to.y = 0f;
float walk = settings != null ? settings.walkSpeed : 2.2f;
MoveTowards(to.normalized, walk, dt);
}
// ───────────────────────────────── 대기 변형 (PD #715 ③)
/// <summary>
/// 무입력·무전투가 이어지면 99_Sub 계열 대기 변형을 1회 재생한다.
/// 재생 슬롯은 기존 감정 상태(`cryStateName`)를 그대로 쓴다 — 베이스 컨트롤러에서 `Sub_Talk1` 이고,
/// 캐릭터별 Override 로 갈아끼울 수 있다(Kazuko = `Cry_wing`). 새 상태를 추가하지 않는다.
/// </summary>
private void IdleVariationUpdate(float dt)
{
float iv = settings != null ? settings.idleVariationSeconds : 0f;
if (_idleVariationPlaying)
{
_idleVariationTimer -= dt;
if (_idleVariationTimer <= 0f) { _idleVariationPlaying = false; _idleTimer = 0f; CrossFadeLocomotion(); }
return;
}
if (iv <= 0f || animator == null || animator.runtimeAnimatorController == null || string.IsNullOrEmpty(cryStateName)) return;
_idleTimer += dt;
if (_idleTimer < iv) return;
animator.CrossFade(cryStateName, settings != null ? settings.getUpToCryBlend : 0.12f, 0, 0f);
animator.Update(0f);
var info = animator.GetCurrentAnimatorStateInfo(0);
_idleVariationTimer = (info.length > 0f && !float.IsInfinity(info.length))
? info.length
: (settings != null ? settings.crySeconds : 1f);
_idleVariationPlaying = true;
}
/// <summary>대기 변형을 중단하고 타이머를 되돌린다. 입력·전투가 들어오면 호출.</summary>
private void CancelIdleVariation()
{
_idleTimer = 0f;
if (!_idleVariationPlaying) return;
_idleVariationPlaying = false;
_idleVariationTimer = 0f;
CrossFadeLocomotion();
}
// ───────────────────────────────── 콤보 공격 (PD #704 추가지시 1·2)
/// <summary>콤보 1단계를 시작한다. 이후 단계는 ComboAdvanceUpdate 가 잇는다.</summary>
private void BeginCombo()
{
_comboHitCount = 0;
PlayAttackStep(1);
}
/// <summary>지정 단계의 공격 클립을 재생하고, 그 단계의 피해 판정을 1회 예약한다(총 N단계 = N히트).</summary>
private void PlayAttackStep(int step)
{
var s = CombatConfig;
_comboStep = step;
_comboAdvanceResolved = false;
_actState = ActState.Attack;
string stateName = GetAttackStateName(step);
float blend = (step <= 1) ? attackBlendTime : (s != null ? s.comboBlendTime : 0.10f);
_attackClipLength = combat != null ? combat.AttackFallbackDuration : 0.8f;
if (animator != null && animator.runtimeAnimatorController != null && !string.IsNullOrEmpty(stateName))
{
animator.CrossFade(stateName, blend, 0, 0f);
animator.Update(0f);
var info = animator.GetCurrentAnimatorStateInfo(0);
if (info.IsName(stateName) && info.length > 0f && !float.IsInfinity(info.length))
_attackClipLength = info.length; // 폴백 절단 해소 — 실제 클립 길이를 그대로 쓴다
}
_attackTimer = _attackClipLength;
// 피해 시점 = 클립 길이 대비 정규화 시각(에셋 값). 에셋이 없을 때만 기존 절대 지연값을 쓴다.
float hitNorm = s != null ? s.comboHitNormalizedTime : -1f;
_hitDelayTimer = (hitNorm >= 0f) ? _attackClipLength * hitNorm
: (combat != null ? combat.AttackHitDelay : 0.25f);
_hitPending = true;
// PD #718 (2) — 스윙 창을 연다. 이펙트는 이 창 동안 검이 지나간 자리를 따라 그려진다.
if (slashVfx != null) slashVfx.OnAttackStarted(step, _attackClipLength);
Debug.Log("[PlayerController] COMBO " + step + "/" + ComboStepCount + " state=" + stateName
+ " 클립=" + _attackClipLength.ToString("F2") + "s 타격+" + _hitDelayTimer.ToString("F2") + "s"
+ " target=" + ((_target != null) ? _target.name : "-")
+ " t=" + Time.timeSinceLevelLoad.ToString("F2") + "s");
}
/// <summary>
/// 전이 시점에 닿으면 다음 단계로 이을지 판정한다.
/// PD #704 추가지시 2 — 대상이 사라졌거나 사거리를 벗어나면 잇지 않고, 현재 클립만 끝내고 복귀한다.
/// </summary>
private void ComboAdvanceUpdate()
{
if (_comboAdvanceResolved || _comboStep <= 0 || _attackClipLength <= 0f) return;
var s = CombatConfig;
float advanceAt = (s != null) ? s.comboAdvanceNormalizedTime : 0.78f;
float played = 1f - Mathf.Clamp01(_attackTimer / _attackClipLength);
if (played < advanceAt) return;
_comboAdvanceResolved = true;
bool targetAlive = _target != null && _target.IsAlive;
bool inRange = targetAlive && combat != null && combat.IsInAttackRange(_target);
if (!targetAlive || !inRange)
{
// PD #732 — 원래 대상이 죽었거나 벗어났어도 사거리 안에 다른 적이 있으면 그 적으로 콤보를 잇는다.
// 아무도 없으면 콤보를 중단하고, 다음 공격은 1타부터 다시 시작한다(EndCombo 가 단계를 0 으로 되돌린다).
var alt = combat != null ? combat.FindTarget() : null;
if (alt != null && alt.IsAlive && combat.IsInAttackRange(alt))
{
Debug.Log("[PlayerController] COMBO 대상 교체 " + _comboStep + "/" + ComboStepCount + " -> " + alt.name
+ " t=" + Time.timeSinceLevelLoad.ToString("F2") + "s");
_target = alt; targetAlive = true; inRange = true;
}
}
if (!targetAlive || !inRange)
{
Debug.Log("[PlayerController] COMBO 중단 " + _comboStep + "/" + ComboStepCount
+ " 사유=" + (!targetAlive ? "대상 없음(사망/소멸)" : "사거리 이탈")
+ " — 현재 클립까지만 재생하고 복귀(다음 공격은 1타부터) t=" + Time.timeSinceLevelLoad.ToString("F2") + "s");
return;
}
if (_comboStep >= ComboStepCount) return; // 마지막 단계 → 클립이 끝나면 자연 종료
PlayAttackStep(_comboStep + 1);
}
/// <summary>콤보를 정상 종료하고 대기/이동으로 돌아간다. 이 시점부터 공격 쿨타임이 돈다.</summary>
private void EndCombo(string why)
{
if (_comboStep > 0)
Debug.Log("[PlayerController] COMBO 종료(" + why + ") 마지막단계=" + _comboStep + "/" + ComboStepCount
+ " 실제타격=" + _comboHitCount + "회 t=" + Time.timeSinceLevelLoad.ToString("F2") + "s");
_comboStep = 0;
_attackTimer = 0f;
_hitPending = false;
_comboAdvanceResolved = false;
if (combat != null) combat.ConsumeAttack(); // 쿨타임은 콤보 단위로 소비한다
_actState = ActState.Idle;
CrossFadeLocomotion();
}
/// <summary>입력·수영 등으로 콤보를 즉시 끊는다. 예약된 피해도 취소한다.</summary>
private void CancelCombo(bool backToLocomotion)
{
if (_comboStep > 0)
Debug.Log("[PlayerController] COMBO 취소 단계=" + _comboStep + " 실제타격=" + _comboHitCount
+ "회 t=" + Time.timeSinceLevelLoad.ToString("F2") + "s");
_comboStep = 0;
_attackTimer = 0f;
_hitPending = false;
_comboAdvanceResolved = false;
if (slashVfx != null) slashVfx.CancelSwing(); // PD #718 (3) — 취소 시 이펙트 잔상 제거
if (combat != null) combat.ConsumeAttack();
if (backToLocomotion) CrossFadeLocomotion();
}
/// <summary>실제 콤보 단계 수. 설정값과 등록된 상태 이름 개수 중 작은 쪽.</summary>
private int ComboStepCount
{
get
{
var s = CombatConfig;
int want = (s != null) ? s.comboStepCount : 3;
int max = (attackStateNames != null) ? attackStateNames.Length : 0;
return (max <= 0) ? 0 : Mathf.Clamp(want, 1, max);
}
}
private string GetAttackStateName(int step)
{
if (attackStateNames == null || attackStateNames.Length == 0) return null;
return attackStateNames[Mathf.Clamp(step - 1, 0, attackStateNames.Length - 1)];
}
// ───────────────────────────────── 수영 (PD #704 범위확정 1)
/// <summary>물 깊이를 재서 수영 상태를 갱신한다. 경계에서 깜빡이지 않도록 진입/이탈 깊이를 다르게 둔다.</summary>
private void UpdateSwimState()
{
if (waterProbe == null) return;
float surface;
string zone;
bool overWater = waterProbe.TryGetSurfaceY(transform.position, out surface, out zone);
float depth = overWater ? surface - transform.position.y : float.NegativeInfinity;
float enter = (settings != null) ? settings.swimEnterDepth : 0.9f;
float exit = (settings != null) ? settings.swimExitDepth : 0.55f;
if (!_isSwimming)
{
if (overWater && depth >= enter)
{
_isSwimming = true;
_waterSurfaceY = surface;
_verticalVelocity = 0f;
if (animator != null && animator.runtimeAnimatorController != null && !string.IsNullOrEmpty(swimStateName))
animator.CrossFade(swimStateName, (settings != null) ? settings.swimBlendTime : 0.18f, 0, 0f);
Debug.Log("[PlayerController] SWIM 진입 수면y=" + surface.ToString("F2") + " 깊이=" + depth.ToString("F2")
+ "m zone=" + zone + " t=" + Time.timeSinceLevelLoad.ToString("F2") + "s");
}
return;
}
if (!overWater || depth < exit)
{
_isSwimming = false;
_actState = ActState.Idle;
CrossFadeLocomotion();
Debug.Log("[PlayerController] SWIM 이탈 깊이=" + (overWater ? depth.ToString("F2") + "m" : "물 없음")
+ " -> 이동 모션 복귀 t=" + Time.timeSinceLevelLoad.ToString("F2") + "s");
return;
}
_waterSurfaceY = surface;
}
/// <summary>수영 중 이동. 중력 대신 수면 높이를 따라간다.</summary>
private void SwimUpdate(Vector2 input, float dt)
{
float swim = (settings != null) ? settings.swimSpeed : 1.8f;
Vector3 dir = (input.sqrMagnitude > 0.0000001f) ? ScreenToWorldDirection(input) : Vector3.zero;
bool moving = dir.sqrMagnitude > 0.0001f;
_currentSpeed = Mathf.SmoothDamp(_currentSpeed, moving ? swim : 0f, ref _speedDampVelocity,
(settings != null) ? settings.speedSmoothTime : 0.12f);
if (_currentSpeed < 0.01f) _currentSpeed = 0f;
if (moving)
{
float targetYaw = Mathf.Atan2(dir.x, dir.z) * Mathf.Rad2Deg;
float yaw = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetYaw, ref _rotationDampVelocity,
(settings != null) ? settings.rotationSmoothTime : 0.1f);
transform.rotation = Quaternion.Euler(0f, yaw, 0f);
}
// 수면 유지 — 발밑을 수면보다 일정 깊이 아래에 붙인다(가라앉지 않게)
float submerge = (settings != null) ? settings.swimSurfaceSubmerge : 1.15f;
float follow = (settings != null) ? settings.swimSurfaceFollowSpeed : 6f;
float dy = Mathf.Clamp((_waterSurfaceY - submerge) - transform.position.y, -follow * dt, follow * dt);
Vector3 horizontal = moving ? dir.normalized * _currentSpeed : Vector3.zero;
_controller.Move(new Vector3(horizontal.x * dt, dy, horizontal.z * dt));
_verticalVelocity = 0f;
if (animator != null && _hasSpeedParam)
{
float blend = (swim > 0f) ? Mathf.Clamp01(_currentSpeed / swim) : 0f;
animator.SetFloat(_speedParamHash, blend, (settings != null) ? settings.animatorDampTime : 0.1f, Time.deltaTime);
}
}
// ───────────────────────────────── 사망 (PD #704 범위확정 4)
/// <summary>
/// 사망 연출. CombatSettings.playerCanDie 가 꺼져 있으면 TakeDamage 가 호출하지 않는다(체력 하한 1 유지).
/// </summary>
public void Die(Transform source = null)
{
if (_wakeState == WakeState.Dead) return;
_wakeState = WakeState.Dead;
_actState = ActState.Idle;
_isSwimming = false;
_comboStep = 0;
_attackTimer = 0f;
_hitPending = false;
_hurtTimer = 0f;
_currentSpeed = 0f;
var s = CombatConfig;
float dur = (s != null) ? s.playerDeathFallbackDuration : 1.0f;
float blend = (s != null) ? s.playerDeathBlendTime : 0.10f;
if (animator != null && animator.runtimeAnimatorController != null && !string.IsNullOrEmpty(deathStateName))
{
int hash = Animator.StringToHash(deathStateName);
if (animator.HasState(0, hash))
{
animator.CrossFade(hash, blend, 0, 0f);
animator.Update(0f);
var info = animator.GetCurrentAnimatorStateInfo(0);
if (info.IsName(deathStateName) && info.length > 0f && !float.IsInfinity(info.length)) dur = info.length;
}
else Debug.LogWarning("[PlayerController] 사망 상태를 애니메이터에서 찾지 못했습니다: " + deathStateName, this);
}
_deathTimer = dur;
Debug.Log("[PlayerController] DEATH 재생 " + dur.ToString("F2") + "s src=" + ((source != null) ? source.name : "-")
+ " t=" + Time.timeSinceLevelLoad.ToString("F2") + "s");
}
// ───────────────────────────────── 피격 (PD 지시 ③ · ⑥)
/// <summary>
/// 몬스터에게 피해를 받는다. 체력은 하한(CombatSettings.playerMinHp) 아래로 내려가지 않으며 사망하지 않는다(⑥).
/// 대기·이동 상태에서는 피격 모션을 재생하고, 공격 모션 재생 중이면 생략한다(③).
/// </summary>
public void TakeDamage(int amount, Transform source = null)
{
if (_wakeState == WakeState.Dead) return;
var s = CombatConfig;
if (_hp < 0f) _hp = s != null ? s.playerMaxHp : 100f;
float floorHp = s != null ? s.playerMinHp : 1f;
bool canDie = s != null && s.playerCanDie;
// playerCanDie 가 꺼져 있으면 기존 규칙대로 하한(기본 1) 에서 멈춘다.
_hp = canDie ? Mathf.Max(0f, _hp - amount) : Mathf.Max(floorHp, _hp - amount);
if (canDie && _hp <= 0f)
{
WL.UI.DamagePopupSpawner.Spawn(transform.position + Vector3.up * bodyScreenAnchorHeight,
amount, WL.UI.DamagePopupSpawner.Kind.ToPlayer);
Debug.Log("[PlayerController] DAMAGED " + amount + " -> HP 0/" + MaxHp.ToString("F0") + " 사망 처리 진입");
Die(source);
return;
}
// 데미지 숫자 연출 (PD #702-①) — 전투 코드는 UI 를 직접 알지 않고 스포너 창구만 호출한다
WL.UI.DamagePopupSpawner.Spawn(transform.position + Vector3.up * bodyScreenAnchorHeight,
amount, WL.UI.DamagePopupSpawner.Kind.ToPlayer);
bool attacking = _attackTimer > 0f;
bool playable = _wakeState == WakeState.Playable;
string why = attacking ? "공격 모션 중 — 피격 모션 생략"
: _isSwimming ? "수영 중 — 피격 모션 생략"
: (playable ? "피격 모션 재생" : "기상 시퀀스 중 — 피격 모션 생략");
// 판정 기준은 _actState(논리 상태)가 아니라 공격 '애니메이션' 잔여 시간이다(PD 지시 ③).
// 로그에도 잔여 시간을 함께 남겨 state=Attack 인데 재생되는 경우(모션은 이미 끝난 상태)를 구분한다.
Debug.Log("[PlayerController] DAMAGED " + amount + " -> HP " + _hp.ToString("F0") + "/" + MaxHp.ToString("F0")
+ " state=" + _actState + " 공격모션잔여=" + _attackTimer.ToString("F2") + "s " + why
+ " src=" + (source != null ? source.name : "-")
+ " t=" + Time.timeSinceLevelLoad.ToString("F2") + "s");
if (attacking || _isSwimming || !playable) return;
PlayHitMotion();
}
private void PlayHitMotion()
{
var s = CombatConfig;
float dur = s != null ? s.playerHitFallbackDuration : 1.07f;
float blend = s != null ? s.playerHitBlendTime : 0.06f;
if (animator != null && animator.runtimeAnimatorController != null && !string.IsNullOrEmpty(hitStateName))
{
int hash = Animator.StringToHash(hitStateName);
if (!animator.HasState(0, hash))
{
Debug.LogWarning("[PlayerController] 피격 상태를 애니메이터에서 찾지 못했습니다: " + hitStateName, this);
return;
}
animator.CrossFade(hash, blend, 0, 0f);
animator.Update(0f);
var info = animator.GetCurrentAnimatorStateInfo(0);
if (info.IsName(hitStateName) && info.length > 0f && !float.IsInfinity(info.length)) dur = info.length;
}
_hurtTimer = dur;
}
private void FaceTarget(float dt) { FaceToward(_target, dt); }
/// <summary>
/// PD #733 — 공격 중에는 시야 안에서 가장 가까운 생존 적 쪽을 본다.
/// 콤보 대상(_target)이 죽었거나 없으면 그 적을 새 대상으로 삼는다(타격 판정은 사거리 검사를 거친다).
/// </summary>
private void FaceNearestTarget(float dt)
{
Enemy nearest = combat != null ? combat.FindTarget() : null;
if (nearest == null) { FaceToward(_target, dt); return; }
if (_target == null || !_target.IsAlive) _target = nearest;
FaceToward(nearest, dt);
}
private void FaceToward(Enemy e, float dt)
{
if (e == null) return;
Vector3 to = e.transform.position - transform.position;
to.y = 0f;
if (to.sqrMagnitude < 0.0001f) return;
float targetYaw = Mathf.Atan2(to.x, to.z) * Mathf.Rad2Deg;
float yaw = Mathf.MoveTowardsAngle(transform.eulerAngles.y, targetYaw, aimTurnSpeed * dt);
transform.rotation = Quaternion.Euler(0f, yaw, 0f);
}
// ───────────────────────────────── 물리 / 애니메이터
private void ApplyGravityOnly(float dt)
{
ApplyGravityStep(dt);
_controller.Move(new Vector3(0f, _verticalVelocity, 0f) * dt);
}
private void ApplyGravityStep(float dt)
{
float g = settings != null ? settings.gravity : -19.62f;
float stick = settings != null ? settings.groundedStickVelocity : -2f;
float terminal = settings != null ? settings.terminalVelocity : -50f;
if (_controller.isGrounded && _verticalVelocity < 0f) _verticalVelocity = stick;
else _verticalVelocity = Mathf.Max(_verticalVelocity + g * dt, terminal);
}
/// <summary>현재 속도를 블렌드 트리 좌표(Idle 0 / Walk 임계값 / Run 1)로 환산해 전달한다.</summary>
private void UpdateLocomotionAnimator()
{
if (animator == null || !_hasSpeedParam) return;
float walk = settings != null ? settings.walkSpeed : 2.2f;
float run = settings != null ? settings.runSpeed : 5.5f;
float mid = settings != null ? settings.walkBlendThreshold : 0.5f;
float blend;
if (_currentSpeed <= walk)
blend = walk > 0f ? (_currentSpeed / walk) * mid : 0f;
else
blend = mid + Mathf.Clamp01((_currentSpeed - walk) / Mathf.Max(run - walk, 0.0001f)) * (1f - mid);
blend = Mathf.Clamp01(blend);
// PD #715 ③ — 감쇠 SetFloat 는 목표에 점근할 뿐 0 에 닿지 않아 걷기 클립이 미세하게 남는다.
// 정지(목표 0)일 때는 별도의 빠른 시간상수로 끌어내리고, 충분히 작아지면 0 으로 못박는다.
if (blend <= 0.0001f)
{
float snap = settings != null ? settings.idleSnapSeconds : 0.15f;
float cur = animator.GetFloat(_speedParamHash);
if (snap <= 0.0001f || cur <= 0.01f) animator.SetFloat(_speedParamHash, 0f);
else animator.SetFloat(_speedParamHash, 0f, snap, Time.deltaTime);
return;
}
animator.SetFloat(_speedParamHash, blend,
settings != null ? settings.animatorDampTime : 0.1f, Time.deltaTime);
}
/// <summary>자동 검증용: 이동 입력을 코드에서 주입한다.</summary>
public void SetDebugMove(bool enabled, Vector2 input)
{
useDebugMoveInput = enabled;
debugMoveInput = input;
}
// ───────────────────────────────── 화면 디버그 오버레이 (패드 영역 시각화)
private void OnGUI()
{
if (touchSettings == null || !touchSettings.drawDebugOverlay) return;
var old = GUI.color;
// PD #706 ② — 플로팅 모드에는 '고정 패드 영역' 개념이 없으므로 영역 상자를 그리지 않는다.
if (!touchSettings.floatingPadEverywhere)
{
Rect pad = touchSettings.GetPadRectPixels(Screen.width, Screen.height);
// GUI 좌표는 좌상단 원점 → 화면 좌표를 뒤집는다
Rect gui = new Rect(pad.x, Screen.height - pad.y - pad.height, pad.width, pad.height);
GUI.color = new Color(1f, 1f, 1f, 0.10f);
GUI.Box(gui, GUIContent.none);
GUI.color = new Color(1f, 1f, 1f, 0.75f);
GUI.Label(new Rect(gui.x + 6f, gui.yMax - 22f, 300f, 20f), "가상 패드 영역 (" + _actState + ")");
}
if (_touch != null && _touch.IsPressed)
{
float r = touchSettings.GetPadRadiusPixels(Screen.width, Screen.height);
Vector2 s = _touch.StartPosition, c = _touch.CurrentPosition;
GUI.color = _touch.CurrentMode == TouchInputProvider.Mode.VirtualPad
? new Color(0.4f, 1f, 0.5f, 0.85f) : new Color(1f, 0.85f, 0.3f, 0.85f);
GUI.Box(new Rect(s.x - r, Screen.height - s.y - r, r * 2f, r * 2f), GUIContent.none);
GUI.Box(new Rect(c.x - 14f, Screen.height - c.y - 14f, 28f, 28f), GUIContent.none);
}
GUI.color = old;
}
}
}