310 lines
15 KiB
C#
310 lines
15 KiB
C#
|
|
using UnityEngine;
|
||
|
|
using UnityEngine.InputSystem;
|
||
|
|
|
||
|
|
namespace WL.Player
|
||
|
|
{
|
||
|
|
/// <summary>
|
||
|
|
/// 모바일 터치(및 에디터 마우스) 입력을 화면 방향 벡터로 변환한다.
|
||
|
|
///
|
||
|
|
/// **현재 기본 = 플로팅 조이스틱 (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 가 소유하는 일반 클래스.
|
||
|
|
/// </summary>
|
||
|
|
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; } }
|
||
|
|
/// <summary>화면 기준 이동 입력 (x=우, y=상), 크기 0~1</summary>
|
||
|
|
public Vector2 ScreenMove { get { return _output; } }
|
||
|
|
/// <summary>눌린 터치가 UI 위에서 시작돼 이동 입력에서 제외됐는지</summary>
|
||
|
|
public bool IsBlockedByUI { get { return _blockedByUI; } }
|
||
|
|
|
||
|
|
/// <summary>마지막으로 읽은 포인터 장치 이름(진단용).</summary>
|
||
|
|
public string LastDeviceName { get { return _lastDevice; } }
|
||
|
|
/// <summary>마지막 프레임에 '새로 눌림'이 있었는지(진단용).</summary>
|
||
|
|
public bool LastJustPressed { get { return _lastJustPressed; } }
|
||
|
|
/// <summary>안전장치 적용 전, 장치가 눌려 있다고 보고했는지(진단용).</summary>
|
||
|
|
public bool LastRawDown { get { return _lastRawDown; } }
|
||
|
|
/// <summary>입력을 무시했다면 그 사유(진단용).</summary>
|
||
|
|
public string LastRejectReason { get { return _lastRejectReason; } }
|
||
|
|
|
||
|
|
public TouchInputProvider(PlayerTouchSettings settings)
|
||
|
|
{
|
||
|
|
_settings = settings;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>눌림 상태를 완전히 초기화한다. 손을 뗐을 때·무시할 때 호출.</summary>
|
||
|
|
private void ClearPress()
|
||
|
|
{
|
||
|
|
_pressed = false;
|
||
|
|
_blockedByUI = false;
|
||
|
|
_ignoreUntilRelease = false;
|
||
|
|
_mode = Mode.None;
|
||
|
|
_output = Vector2.zero;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>매 프레임 호출. playerScreenPos 는 카메라 WorldToScreenPoint 결과.</summary>
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 눌린 지점이 UI 위인지 판정한다 (PD #715 ①).
|
||
|
|
///
|
||
|
|
/// `EventSystem.IsPointerOverGameObject()` 만 쓰면 **그 프레임에 EventSystem 이 이미 갱신됐는지**에 결과가 달린다.
|
||
|
|
/// PlayerController.Update 가 EventSystem 보다 먼저 도는 프레임에는 직전 프레임 값(=false)이 돌아와,
|
||
|
|
/// **HUD 버튼을 누른 첫 프레임이 이동 패드로 잡히고 캐릭터가 제멋대로 움직였다**(카메라 모드 버튼 탭 시 재현).
|
||
|
|
/// → 실행 순서와 무관한 **직접 레이캐스트**로 먼저 판정하고, 그 다음에 기존 판정을 보조로 쓴다.
|
||
|
|
/// </summary>
|
||
|
|
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<UnityEngine.EventSystems.RaycastResult>(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<UnityEngine.EventSystems.RaycastResult> _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);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 터치스크린이 있으면 **터치만** 읽는다. 없을 때만 포인터(에디터 마우스)로 넘어간다.
|
||
|
|
/// Touchscreen 은 Pointer 를 상속하므로 폴백에서 같은 장치를 다시 읽으면
|
||
|
|
/// 유령 입력이 겹쳐 발화한다(PD #707 ①). 그래서 폴백에서 Touchscreen 은 배제한다.
|
||
|
|
/// justPressed = 이번 프레임에 새로 눌렸는지(잔류 입력 판별용).
|
||
|
|
/// </summary>
|
||
|
|
// ── 자동 검증용 포인터 주입 (기본 꺼짐 · 켜야만 동작한다)
|
||
|
|
private static bool _debugPointerEnabled;
|
||
|
|
private static bool _debugPointerDown;
|
||
|
|
private static bool _debugPointerJustPressed;
|
||
|
|
private static Vector2 _debugPointerPos;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 실제 장치 대신 포인터를 주입한다(자동 검증 전용 · PD #706 계측).
|
||
|
|
/// enabled 를 끄면 즉시 실제 장치 경로로 돌아간다. 게임 로직에는 관여하지 않는다.
|
||
|
|
/// </summary>
|
||
|
|
public static void SetDebugPointer(bool enabled, bool down, Vector2 screenPos, bool justPressed)
|
||
|
|
{
|
||
|
|
_debugPointerEnabled = enabled;
|
||
|
|
_debugPointerDown = down;
|
||
|
|
_debugPointerPos = screenPos;
|
||
|
|
_debugPointerJustPressed = justPressed;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>주입 포인터가 켜져 있는가.</summary>
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>화면 밖 좌표(에디터에서 커서가 게임 뷰를 벗어난 경우 등)는 입력으로 보지 않는다.</summary>
|
||
|
|
private static bool IsOnScreen(Vector2 v)
|
||
|
|
{
|
||
|
|
return v.x >= 0f && v.y >= 0f && v.x <= Screen.width && v.y <= Screen.height;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|