using System.Collections.Generic; using UnityEngine; #if ENABLE_INPUT_SYSTEM using UnityEngine.InputSystem; using UnityEngine.InputSystem.EnhancedTouch; using ETouch = UnityEngine.InputSystem.EnhancedTouch.Touch; #endif /// /// 手游触屏输入分发器:每帧遍历所有触摸点(及编辑器鼠标),用 Physics2D.OverlapPoint /// 命中带 TrackTouchZone 的透明 Collider2D,把按下/抬起转发给 InputManager。 /// /// 【多点触控根因】项目 activeInputHandler = Both(同时启用新旧输入系统)。 /// 在这种配置下,旧版 UnityEngine.Input.touches 走的是兼容 shim,同时按多指时 /// 常常只上报一个触点 → 只能触发一个轨道。所以这里优先用新输入系统的 /// EnhancedTouch(Touch.activeTouches),它能可靠上报所有并发手指; /// 仅在没有新输入系统时回退到旧版 Input.touches。判定逻辑完全不变。 /// /// 为什么用物理命中而不是 UI 的 IPointerDownHandler: /// - 轨道会平移+旋转晃动,点击区必须跟随晃动的 Collider(OverlapPoint 支持旋转过的碰撞体)。 /// - 需要多点触控:每根手指独立按住各自的轨道(靠 touchId 配对按下/抬起)。 /// /// 判定时间戳仍由 Note.HandlePress 读取 GameplayClock.NowSongTime(dspTime)。 /// /// 用法:场景里放一个空物体挂本脚本,把主相机拖到 gameplayCamera(留空则自动取 Camera.main)。 /// 5 块透明轨道点击区各挂 TrackTouchZone 并设置 trackIndex。 /// public class TrackTouchInput : MonoBehaviour { [Tooltip("渲染 gameplay 的相机。留空则自动使用 Camera.main。")] [SerializeField] private Camera gameplayCamera; [Tooltip("是否在编辑器/PC 上用鼠标模拟单点触摸(方便调试)。")] [SerializeField] private bool enableMouseFallback = true; [Tooltip("命中检测使用的层。默认 Everything;如需只检测点击区层可在此限制。")] [SerializeField] private LayerMask hitLayers = ~0; [Tooltip("透视相机必填:轨道点击区所在平面的世界 Z 坐标(轨道 Collider 的 Z)。\n" + "透视相机下屏幕点转世界点依赖深度,用它把射线交到轨道平面,相机怎么晃/推拉都精确。\n" + "正交相机可忽略此项。")] [SerializeField] private float trackPlaneWorldZ = 0f; [Tooltip("相机是否为正交投影。正交相机忽略深度,命中转换更简单。\n" + "留空自动按相机 orthographic 判断;一般不用手动改。")] [SerializeField] private bool forceOrthographicMode = false; // 触点 id -> 当前按住的轨道索引。用于在抬起/取消时精确释放对应轨道。 private readonly Dictionary _activeTouches = new Dictionary(); private int _mouseHeldTrack = -1; private Camera Cam { get { if (gameplayCamera == null) gameplayCamera = Camera.main; return gameplayCamera; } } #if ENABLE_INPUT_SYSTEM private void OnEnable() { // 开启增强触摸,Touch.activeTouches 才会被填充。 EnhancedTouchSupport.Enable(); } #endif private void Update() { #if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN // Windows 平台:禁用轨道点击/触摸轮询,只依赖按键输入。 // 编辑器(Win)也一并禁用,便于在 PC 上以键盘方式测试真实 Windows 行为。 return; #else var im = InputManager.Instance; if (im == null) return; Camera cam = Cam; if (cam == null) return; int touchCount = ProcessTouches(im, cam); if (enableMouseFallback && touchCount == 0) { ProcessMouse(im, cam); } #endif } /// 处理所有并发触点,返回本帧触点数量。 private int ProcessTouches(InputManager im, Camera cam) { #if ENABLE_INPUT_SYSTEM // 优先走新输入系统的增强触摸:可靠上报所有并发手指。 var touches = ETouch.activeTouches; int count = touches.Count; for (int i = 0; i < count; i++) { ETouch t = touches[i]; switch (t.phase) { case UnityEngine.InputSystem.TouchPhase.Began: HandleTouchBegan(im, cam, t.touchId, t.screenPosition); break; case UnityEngine.InputSystem.TouchPhase.Ended: case UnityEngine.InputSystem.TouchPhase.Canceled: HandleTouchEnded(im, t.touchId); break; // Moved / Stationary:手指在轨道内保持按住即可,不重新判定归属。 } } return count; #else // 回退:旧版 Input.touches(仅在未启用新输入系统时使用)。 int count = Input.touchCount; for (int i = 0; i < count; i++) { Touch t = Input.GetTouch(i); switch (t.phase) { case TouchPhase.Began: HandleTouchBegan(im, cam, t.fingerId, t.position); break; case TouchPhase.Ended: case TouchPhase.Canceled: HandleTouchEnded(im, t.fingerId); break; } } return count; #endif } private void HandleTouchBegan(InputManager im, Camera cam, int touchId, Vector2 screenPos) { int track = ResolveTrack(cam, screenPos); if (track >= 0) { _activeTouches[touchId] = track; im.PressTrack(track); } } private void HandleTouchEnded(InputManager im, int touchId) { if (_activeTouches.TryGetValue(touchId, out int track)) { _activeTouches.Remove(touchId); im.ReleaseTrack(track); } } private void ProcessMouse(InputManager im, Camera cam) { #if ENABLE_INPUT_SYSTEM var mouse = Mouse.current; if (mouse == null) return; if (mouse.leftButton.wasPressedThisFrame) { int track = ResolveTrack(cam, mouse.position.ReadValue()); if (track >= 0) { _mouseHeldTrack = track; im.PressTrack(track); } } else if (mouse.leftButton.wasReleasedThisFrame) { if (_mouseHeldTrack >= 0) { im.ReleaseTrack(_mouseHeldTrack); _mouseHeldTrack = -1; } } #else if (Input.GetMouseButtonDown(0)) { int track = ResolveTrack(cam, Input.mousePosition); if (track >= 0) { _mouseHeldTrack = track; im.PressTrack(track); } } else if (Input.GetMouseButtonUp(0)) { if (_mouseHeldTrack >= 0) { im.ReleaseTrack(_mouseHeldTrack); _mouseHeldTrack = -1; } } #endif } /// /// 把屏幕坐标转换为轨道平面上的世界点,用 OverlapPoint 命中轨道点击区,返回轨道索引;无命中返回 -1。 /// /// 相机移动/晃动/推拉时,每帧都用当前相机状态转换,所以命中自动跟随相机。 /// - 正交相机:ScreenToWorldPoint 忽略深度,直接取 x/y。 /// - 透视相机:从相机发射线,求与轨道平面(Z = trackPlaneWorldZ)的交点。 /// private int ResolveTrack(Camera cam, Vector2 screenPos) { EnsureOverlapFilter(); Vector2 point; if (forceOrthographicMode || cam.orthographic) { Vector3 world = cam.ScreenToWorldPoint(screenPos); point = new Vector2(world.x, world.y); } else { Ray ray = cam.ScreenPointToRay(screenPos); float denom = ray.direction.z; if (Mathf.Abs(denom) < 1e-6f) { return -1; // 射线与平面近乎平行,无有效交点 } float t = (trackPlaneWorldZ - ray.origin.z) / denom; if (t < 0f) { return -1; // 交点在相机背后 } Vector3 world = ray.origin + ray.direction * t; point = new Vector2(world.x, world.y); } // 用 OverlapPointAll 遍历所有命中的碰撞体,挑出带 TrackTouchZone 的那个。 // 关键修复:判定线附近同一屏幕点常密集着 note 的 Collider2D 与 judgeline 触发器, // 单值版 OverlapPoint 只返回其中一个,若不是 tap zone 就丢判定(=点了轨道却没反应)。 // 遍历全部命中即可穿透这些遮挡,可靠找到轨道点击区。 int count = Physics2D.OverlapPoint(point, _overlapFilter, _overlapResults); for (int i = 0; i < count; i++) { Collider2D hit = _overlapResults[i]; if (hit == null) continue; TrackTouchZone zone = hit.GetComponent(); if (zone == null) zone = hit.GetComponentInParent(); if (zone != null) return zone.trackIndex; } // 兜底:没有精确命中任何 zone(点落在轨道间缝隙或紧邻边缘)。 // 取横向最近的 zone,只要触点在其 Collider 的纵向范围内即算命中—— // 等效于把相邻轨道点击区在横向补满、消除缝隙,且不误触远离轨道区的点击。 return ResolveNearestZone(point); } // 横向最近轨道兜底:在所有 zone 中找触点纵向落在其包围盒内、且横向距离最近的一个。 private int ResolveNearestZone(Vector2 point) { var zones = TrackTouchZone.All; int best = -1; float bestDist = float.MaxValue; for (int i = 0; i < zones.Count; i++) { TrackTouchZone z = zones[i]; if (z == null || z.Collider == null) continue; Bounds b = z.Collider.bounds; // 纵向必须落在该点击区范围内(留一点容差),避免点屏幕别处也触发轨道。 if (point.y < b.min.y - 0.1f || point.y > b.max.y + 0.1f) continue; float dx = Mathf.Abs(point.x - b.center.x); if (dx < bestDist) { bestDist = dx; best = z.trackIndex; } } return best; } // OverlapPoint 的复用缓冲与过滤器(避免每次触摸分配 GC)。 private static readonly Collider2D[] _overlapResults = new Collider2D[16]; private ContactFilter2D _overlapFilter; private bool _filterReady; private void EnsureOverlapFilter() { if (_filterReady) return; _overlapFilter = new ContactFilter2D(); _overlapFilter.useLayerMask = true; _overlapFilter.SetLayerMask(hitLayers); _overlapFilter.useTriggers = true; // tap zone 可能是触发器,需包含 _filterReady = true; } private void OnDisable() { // 组件被关闭/切场景时,释放所有还按住的轨道,避免音符卡在 held 状态。 var im = InputManager.Instance; if (im != null) { foreach (var kv in _activeTouches) { im.ReleaseTrack(kv.Value); } if (_mouseHeldTrack >= 0) im.ReleaseTrack(_mouseHeldTrack); } _activeTouches.Clear(); _mouseHeldTrack = -1; #if ENABLE_INPUT_SYSTEM EnhancedTouchSupport.Disable(); #endif } }