1127 lines
57 KiB
C#
1127 lines
57 KiB
C#
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|