using UnityEngine; using UnityEngine.InputSystem; namespace WL.Player { /// /// 모바일 터치(및 에디터 마우스) 입력을 화면 방향 벡터로 변환한다. /// /// **현재 기본 = 플로팅 조이스틱 (PD #706 ②)** /// - `PlayerTouchSettings.floatingPadEverywhere = true` (기본) → /// 화면 **어디를 눌러도** 그 지점이 조이스틱 중심이 되고, 떼면 사라진다. 좌하단 1/4 제한 없음. /// - 토글을 끄면 구 방식 복귀 → 패드 영역 안 = 가상 조이스틱 / 영역 밖 = 터치 지점 방향 자동 이동(PointAt). /// PointAt 판정 코드는 PD 가 되돌릴 수 있도록 그대로 보존한다. /// /// **PD #707 ① — 무입력인데 패드가 생성·자동 이동하던 버그 방지 4중 잠금** /// 1) 패드는 **그 프레임에 새로 눌린**(wasPressedThisFrame) 포인터로만 생성한다. 잔류(이미 눌린 채 넘어온) 입력은 뗄 때까지 무시. /// 2) 씬 시작 후 `inputIgnoreSecondsAfterStart` 동안은 포인터를 아예 보지 않는다(Play 진입 순간의 잔류 클릭 차단). /// 3) 비포커스·화면 밖 좌표·UI 위에서 시작한 입력은 이동으로 쓰지 않는다. /// 4) 터치스크린이 있으면 그쪽만 읽는다. Touchscreen 은 Pointer 를 상속하므로, /// 폴백에서 같은 장치를 한 번 더 읽어 **유령 입력이 겹쳐 발화**하던 경로를 끊는다. /// /// MonoBehaviour 가 아니라 PlayerController 가 소유하는 일반 클래스. /// public class TouchInputProvider { public enum Mode { None, VirtualPad, PointAt } private readonly PlayerTouchSettings _settings; private bool _pressed; private bool _blockedByUI; private bool _ignoreUntilRelease; // 잔류(눌린 채 넘어온) 포인터를 뗄 때까지 무시 private Mode _mode = Mode.None; private Vector2 _startPos; private Vector2 _currentPos; private Vector2 _output; private float _firstTickTime = -1f; // 첫 Tick 시각 (시작 유예 계산 기준) // ── 진단용 (PD #707 실측 로그) private string _lastDevice = "-"; private bool _lastJustPressed; private bool _lastRawDown; private string _lastRejectReason = "-"; public Mode CurrentMode { get { return _mode; } } public bool IsPressed { get { return _pressed; } } public Vector2 StartPosition { get { return _startPos; } } public Vector2 CurrentPosition { get { return _currentPos; } } /// 화면 기준 이동 입력 (x=우, y=상), 크기 0~1 public Vector2 ScreenMove { get { return _output; } } /// 눌린 터치가 UI 위에서 시작돼 이동 입력에서 제외됐는지 public bool IsBlockedByUI { get { return _blockedByUI; } } /// 마지막으로 읽은 포인터 장치 이름(진단용). public string LastDeviceName { get { return _lastDevice; } } /// 마지막 프레임에 '새로 눌림'이 있었는지(진단용). public bool LastJustPressed { get { return _lastJustPressed; } } /// 안전장치 적용 전, 장치가 눌려 있다고 보고했는지(진단용). public bool LastRawDown { get { return _lastRawDown; } } /// 입력을 무시했다면 그 사유(진단용). public string LastRejectReason { get { return _lastRejectReason; } } public TouchInputProvider(PlayerTouchSettings settings) { _settings = settings; } /// 눌림 상태를 완전히 초기화한다. 손을 뗐을 때·무시할 때 호출. private void ClearPress() { _pressed = false; _blockedByUI = false; _ignoreUntilRelease = false; _mode = Mode.None; _output = Vector2.zero; } /// 매 프레임 호출. playerScreenPos 는 카메라 WorldToScreenPoint 결과. public void Tick(Vector2 playerScreenPos) { if (_firstTickTime < 0f) _firstTickTime = Time.timeSinceLevelLoad; Vector2 pos; bool justPressed; string device; bool down = TryGetPointer(out pos, out justPressed, out device); _lastRawDown = down; _lastJustPressed = justPressed; _lastDevice = device; _lastRejectReason = "-"; // (2) 씬 시작 직후 유예 — Play 진입 순간의 잔류 클릭/터치가 패드를 만드는 것을 막는다. float grace = _settings != null ? _settings.inputIgnoreSecondsAfterStart : 0.5f; if (Time.timeSinceLevelLoad - _firstTickTime < grace) { if (down) _lastRejectReason = "시작 유예(" + grace.ToString("F2") + "s) 내 입력"; ClearPress(); return; } // (3) 앱(에디터 게임 뷰)이 포커스를 잃은 동안의 포인터는 입력으로 보지 않는다. if (down && _settings != null && _settings.ignoreInputWhenUnfocused && !Application.isFocused) { _lastRejectReason = "비포커스"; down = false; } // (3) 화면 밖 좌표는 입력으로 보지 않는다(터치·마우스 공통). if (down && !IsOnScreen(pos)) { _lastRejectReason = "화면 밖 좌표 " + pos.ToString("F0"); down = false; } if (!down) { ClearPress(); return; } _currentPos = pos; if (!_pressed) { // (1) 패드는 '그 프레임에 새로 눌린' 포인터로만 생성한다. // 잔류 입력(release 를 놓쳤거나 이미 눌린 채 넘어온 것)은 뗄 때까지 무시한다. if (!justPressed) { _ignoreUntilRelease = true; _lastRejectReason = "새로 누른 입력이 아님(잔류) — 뗄 때까지 무시"; _mode = Mode.None; _output = Vector2.zero; return; } _pressed = true; _startPos = pos; // 누르기 시작한 지점이 UI 위면 그 터치는 이동에 쓰지 않는다(HUD 버튼 오작동 방지) _blockedByUI = IsPointerOverUI(pos); _mode = IsInsidePad(pos) ? Mode.VirtualPad : Mode.PointAt; } if (_ignoreUntilRelease) { _lastRejectReason = "잔류 입력 무시 중"; _output = Vector2.zero; return; } if (_blockedByUI) { _lastRejectReason = "UI 위에서 시작"; _output = Vector2.zero; return; } if (_settings == null) { _output = Vector2.zero; return; } float w = Screen.width, h = Screen.height; if (_mode == Mode.VirtualPad) { float radius = _settings.GetPadRadiusPixels(w, h); Vector2 drag = _currentPos - _startPos; float dead = radius * _settings.padDeadZoneRatio; float mag = drag.magnitude; if (mag <= dead || radius <= 0.0001f) { _output = Vector2.zero; return; } float norm = Mathf.Clamp01((mag - dead) / Mathf.Max(radius - dead, 0.0001f)); _output = drag / mag * norm; } else { // 구 방식(floatingPadEverywhere = false)에서만 도달한다. Vector2 toTouch = _currentPos - playerScreenPos; float dead = _settings.GetAutoMoveDeadZonePixels(w, h); float mag = toTouch.magnitude; if (mag <= dead) { _output = Vector2.zero; return; } if (_settings.autoMoveFullSpeed) { _output = toTouch / mag; } else { float radius = _settings.GetPadRadiusPixels(w, h); float norm = Mathf.Clamp01((mag - dead) / Mathf.Max(radius - dead, 0.0001f)); _output = toTouch / mag * norm; } } } /// /// 눌린 지점이 UI 위인지 판정한다 (PD #715 ①). /// /// `EventSystem.IsPointerOverGameObject()` 만 쓰면 **그 프레임에 EventSystem 이 이미 갱신됐는지**에 결과가 달린다. /// PlayerController.Update 가 EventSystem 보다 먼저 도는 프레임에는 직전 프레임 값(=false)이 돌아와, /// **HUD 버튼을 누른 첫 프레임이 이동 패드로 잡히고 캐릭터가 제멋대로 움직였다**(카메라 모드 버튼 탭 시 재현). /// → 실행 순서와 무관한 **직접 레이캐스트**로 먼저 판정하고, 그 다음에 기존 판정을 보조로 쓴다. /// private static bool IsPointerOverUI(Vector2 screenPos) { var es = UnityEngine.EventSystems.EventSystem.current; if (es == null) return false; if (_uiPointerData == null) _uiPointerData = new UnityEngine.EventSystems.PointerEventData(es); _uiPointerData.Reset(); _uiPointerData.position = screenPos; if (_uiHits == null) _uiHits = new System.Collections.Generic.List(8); _uiHits.Clear(); es.RaycastAll(_uiPointerData, _uiHits); if (_uiHits.Count > 0) return true; return es.IsPointerOverGameObject(); } private static UnityEngine.EventSystems.PointerEventData _uiPointerData; private static System.Collections.Generic.List _uiHits; private bool IsInsidePad(Vector2 screenPos) { if (_settings == null) return false; // PD #706 ② — 플로팅 조이스틱: 화면 어디를 눌러도 그 지점이 패드 중심이 된다. // (아래 영역 판정과 PointAt 경로는 토글을 끄면 그대로 되살아난다) if (_settings.floatingPadEverywhere) return true; return _settings.GetPadRectPixels(Screen.width, Screen.height).Contains(screenPos); } /// /// 터치스크린이 있으면 **터치만** 읽는다. 없을 때만 포인터(에디터 마우스)로 넘어간다. /// Touchscreen 은 Pointer 를 상속하므로 폴백에서 같은 장치를 다시 읽으면 /// 유령 입력이 겹쳐 발화한다(PD #707 ①). 그래서 폴백에서 Touchscreen 은 배제한다. /// justPressed = 이번 프레임에 새로 눌렸는지(잔류 입력 판별용). /// // ── 자동 검증용 포인터 주입 (기본 꺼짐 · 켜야만 동작한다) private static bool _debugPointerEnabled; private static bool _debugPointerDown; private static bool _debugPointerJustPressed; private static Vector2 _debugPointerPos; /// /// 실제 장치 대신 포인터를 주입한다(자동 검증 전용 · PD #706 계측). /// enabled 를 끄면 즉시 실제 장치 경로로 돌아간다. 게임 로직에는 관여하지 않는다. /// public static void SetDebugPointer(bool enabled, bool down, Vector2 screenPos, bool justPressed) { _debugPointerEnabled = enabled; _debugPointerDown = down; _debugPointerPos = screenPos; _debugPointerJustPressed = justPressed; } /// 주입 포인터가 켜져 있는가. public static bool DebugPointerEnabled { get { return _debugPointerEnabled; } } private static bool TryGetPointer(out Vector2 pos, out bool justPressed, out string device) { pos = Vector2.zero; justPressed = false; device = "-"; if (_debugPointerEnabled) { pos = _debugPointerPos; justPressed = _debugPointerJustPressed; _debugPointerJustPressed = false; // '새로 눌림'은 한 프레임만 device = "주입 포인터"; return _debugPointerDown; } var ts = Touchscreen.current; if (ts != null && ts.enabled) { var touches = ts.touches; for (int i = 0; i < touches.Count; i++) { var t = touches[i]; if (!t.press.isPressed) continue; // PD #707 ① — press 비트만 믿으면 안 된다. // 이미 Ended/Canceled 로 끝난 터치가 press 비트만 남아 '유령 입력'이 되어 // 손을 대지 않았는데 패드가 생기고 캐릭터가 저절로 달리던 원인. // 살아 있는 터치 = phase 가 Began/Moved/Stationary 인 것만. var ph = t.phase.ReadValue(); if (ph != UnityEngine.InputSystem.TouchPhase.Began && ph != UnityEngine.InputSystem.TouchPhase.Moved && ph != UnityEngine.InputSystem.TouchPhase.Stationary) continue; pos = t.position.ReadValue(); // PD #737 — Began 단계인 터치는 정의상 '새로 눌린' 터치다. 디바이스 시뮬레이터(Unity 6 게임 뷰)에서는 // press 비트가 이전 터치에서 남아 wasPressedThisFrame 이 false 로 나와 첫 터치가 "잔류 입력"으로 거부됐고 // (주입 터치 실측: phase=Began · rawDown=True · justPressed=False → 뗄 때까지 무시) 그 결과 가상패드가 뜨지 않았다. justPressed = t.press.wasPressedThisFrame || ph == UnityEngine.InputSystem.TouchPhase.Began; device = ts.name + " touch[" + i + "] phase=" + ph; return true; } // 터치스크린이 붙어 있으면 여기서 끝낸다(마우스 폴백으로 새지 않는다) device = ts.name + " (살아있는 터치 없음)"; return false; } var p = Pointer.current; if (p != null && !(p is Touchscreen) && p.press.isPressed) { Vector2 v = p.position.ReadValue(); if (IsOnScreen(v)) { pos = v; justPressed = p.press.wasPressedThisFrame; device = p.name; return true; } } if (p != null) device = p.name + " (미눌림)"; return false; } /// 화면 밖 좌표(에디터에서 커서가 게임 뷰를 벗어난 경우 등)는 입력으로 보지 않는다. private static bool IsOnScreen(Vector2 v) { return v.x >= 0f && v.y >= 0f && v.x <= Screen.width && v.y <= Screen.height; } } }