using UnityEngine; using UnityEngine.InputSystem; using WL.Combat; namespace WL.Player { /// /// 플레이어 컨트롤러 (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). /// [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; } } /// 가상패드 UI 가 패드 영역·반경을 그릴 때 참조하는 설정 에셋. public PlayerTouchSettings TouchSettings { get { return touchSettings; } } /// 현재 체력. 하한(기본 1) 아래로 내려가지 않는다 — 아직 사망은 구현하지 않는다(PD 지시 ⑥). public float CurrentHp { get { return _hp < 0f ? MaxHp : _hp; } } public float MaxHp { get { var s = CombatConfig; return s != null ? s.playerMaxHp : 100f; } } /// 공격 모션 재생 중인가. 피격 모션 생략 판정에 쓴다(PD 지시 ③). public bool IsAttacking { get { return _attackTimer > 0f; } } /// 피격 모션 재생 중인가. public bool IsHurt { get { return _hurtTimer > 0f; } } /// 수영 중인가 (PD #704 범위확정 1). public bool IsSwimming { get { return _isSwimming; } } /// 사망 상태인가 (PD #704 범위확정 4). public bool IsDead { get { return _wakeState == WakeState.Dead; } } /// 현재 콤보 단계. 0 = 진행 중 아님, 1~3 = Attack1~3. public int CurrentComboStep { get { return _comboStep; } } /// 이번(또는 마지막) 콤보에서 실제로 들어간 타격 수. 검증용. public int LastComboHitCount { get { return _comboHitCount; } } /// 자동 검증용 — 실제 터치 대신 화면 기준 입력(x=우, y=상)을 주입한다. 끄면 실제 입력으로 복귀. public void SetDebugMoveInput(bool enabled, Vector2 screenInput) { useDebugMoveInput = enabled; debugMoveInput = enabled ? screenInput : Vector2.zero; } /// 주입 입력이 켜져 있는가. public bool DebugMoveInputEnabled { get { return useDebugMoveInput; } } /// 전투 수치 에셋. PlayerCombat 이 들고 있는 것을 그대로 쓴다. private CombatSettings CombatConfig { get { return combat != null ? combat.Settings : null; } } /// 각 단계 진입 시각(초, 씬 로드 기준). 아직 진입 전이면 -1. 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(); if (animator == null) animator = GetComponentInChildren(); if (combat == null) combat = GetComponent(); if (waterProbe == null) waterProbe = GetComponent(); if (slashVfx == null) slashVfx = GetComponent(); 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(); } /// 시작 시퀀스(쓰러짐→기상) 재생 여부. 교체로 투입되는 캐릭터는 끄고 켠다(PD #711). public void SetPlayIntroSequence(bool v) { playIntroSequence = v; } /// 체력을 그대로 옮긴다(캐릭터 교체 시 유지 · PD #711). public void SetHp(float value) { _hp = Mathf.Clamp(value, 0f, MaxHp); } /// 교체 직후 잠깐 입력을 막는다(초). 버튼을 뗄 때의 잔여 터치로 이동이 시작되는 것을 막는다. 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)"); } /// /// 기상 직후 Cry 모션을 설정된 시간만큼 재생한다. crySeconds 0 이면 건너뛴다. /// **PD #705 ② 로 현재 시퀀스에서는 호출하지 않는다** (미사용 · 복구용으로 보존). /// 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(), 1f); return Vector2.zero; } private Vector2 GetPlayerScreenPosition() { var cam = cameraTransform != null ? cameraTransform.GetComponent() : 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; } // ───────────────────────────────── 이동 /// /// 드래그 크기 → 목표 속도. 임계값 미만은 걷기(미세 위치 조작 · 크기에 비례), /// 임계값 이상은 걷기~달리기 구간을 선형 보간한다. /// 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); } /// 추적 카메라. cameraTransform 에서 1회 찾아 캐시한다(없으면 null). private FollowCamera FollowCam { get { if (_followCamCached) return _followCam; _followCamCached = true; if (cameraTransform != null) _followCam = cameraTransform.GetComponent(); if (_followCam == null) _followCam = Object.FindFirstObjectByType(); return _followCam; } } /// /// 화면 기준 입력(x=우, y=상)을 월드 방향으로 변환. /// 기준 방향은 카메라가 정한다(PD #713 ②) — 요 추종 모드는 모드의 고정 요를 기준으로 삼아야 /// "패드 방향 → 월드 방향 → 카메라 요 → 다시 패드 기준" 순환이 끊기고 추종이 수렴한다. /// 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 ③) /// /// 무입력·무전투가 이어지면 99_Sub 계열 대기 변형을 1회 재생한다. /// 재생 슬롯은 기존 감정 상태(`cryStateName`)를 그대로 쓴다 — 베이스 컨트롤러에서 `Sub_Talk1` 이고, /// 캐릭터별 Override 로 갈아끼울 수 있다(Kazuko = `Cry_wing`). 새 상태를 추가하지 않는다. /// 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; } /// 대기 변형을 중단하고 타이머를 되돌린다. 입력·전투가 들어오면 호출. private void CancelIdleVariation() { _idleTimer = 0f; if (!_idleVariationPlaying) return; _idleVariationPlaying = false; _idleVariationTimer = 0f; CrossFadeLocomotion(); } // ───────────────────────────────── 콤보 공격 (PD #704 추가지시 1·2) /// 콤보 1단계를 시작한다. 이후 단계는 ComboAdvanceUpdate 가 잇는다. private void BeginCombo() { _comboHitCount = 0; PlayAttackStep(1); } /// 지정 단계의 공격 클립을 재생하고, 그 단계의 피해 판정을 1회 예약한다(총 N단계 = N히트). 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"); } /// /// 전이 시점에 닿으면 다음 단계로 이을지 판정한다. /// PD #704 추가지시 2 — 대상이 사라졌거나 사거리를 벗어나면 잇지 않고, 현재 클립만 끝내고 복귀한다. /// 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); } /// 콤보를 정상 종료하고 대기/이동으로 돌아간다. 이 시점부터 공격 쿨타임이 돈다. 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(); } /// 입력·수영 등으로 콤보를 즉시 끊는다. 예약된 피해도 취소한다. 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(); } /// 실제 콤보 단계 수. 설정값과 등록된 상태 이름 개수 중 작은 쪽. 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) /// 물 깊이를 재서 수영 상태를 갱신한다. 경계에서 깜빡이지 않도록 진입/이탈 깊이를 다르게 둔다. 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; } /// 수영 중 이동. 중력 대신 수면 높이를 따라간다. 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) /// /// 사망 연출. CombatSettings.playerCanDie 가 꺼져 있으면 TakeDamage 가 호출하지 않는다(체력 하한 1 유지). /// 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 지시 ③ · ⑥) /// /// 몬스터에게 피해를 받는다. 체력은 하한(CombatSettings.playerMinHp) 아래로 내려가지 않으며 사망하지 않는다(⑥). /// 대기·이동 상태에서는 피격 모션을 재생하고, 공격 모션 재생 중이면 생략한다(③). /// 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); } /// /// PD #733 — 공격 중에는 시야 안에서 가장 가까운 생존 적 쪽을 본다. /// 콤보 대상(_target)이 죽었거나 없으면 그 적을 새 대상으로 삼는다(타격 판정은 사거리 검사를 거친다). /// 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); } /// 현재 속도를 블렌드 트리 좌표(Idle 0 / Walk 임계값 / Run 1)로 환산해 전달한다. 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); } /// 자동 검증용: 이동 입력을 코드에서 주입한다. 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; } } }