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;
}
}
}