using System.Collections.Generic; using UnityEngine; /// /// 手游触屏输入分发器:每帧遍历所有触摸点(及编辑器鼠标),用 Physics2D.OverlapPoint /// 命中带 TrackTouchZone 的透明 Collider2D,把按下/抬起转发给 InputManager。 /// /// 为什么用这种方式而不是 UI 的 IPointerDownHandler: /// - 轨道会平移+旋转晃动,点击区必须跟随晃动的 Collider(OverlapPoint 支持旋转过的碰撞体)。 /// - 需要多点触控:每根手指独立按住各自的轨道(靠 fingerId 配对按下/抬起)。 /// /// 判定逻辑完全不变:这里只调用现成的 PressTrack/ReleaseTrack, /// 判定时间戳仍由 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; // fingerId -> 当前按住的轨道索引。用于在抬起/取消时精确释放对应轨道。 private readonly Dictionary _activeTouches = new Dictionary(); // 鼠标模拟用的"手指 id",取一个不会和真实 fingerId 冲突的值。 private const int MouseFingerId = -100; private int _mouseHeldTrack = -1; private Camera Cam { get { if (gameplayCamera == null) gameplayCamera = Camera.main; return gameplayCamera; } } private void Update() { var im = InputManager.Instance; if (im == null) return; Camera cam = Cam; if (cam == null) return; ProcessTouches(im, cam); if (enableMouseFallback && Input.touchCount == 0) { ProcessMouse(im, cam); } } private void ProcessTouches(InputManager im, Camera cam) { for (int i = 0; i < Input.touchCount; i++) { Touch t = Input.GetTouch(i); switch (t.phase) { case TouchPhase.Began: { int track = ResolveTrack(cam, t.position); if (track >= 0) { _activeTouches[t.fingerId] = track; im.PressTrack(track); } break; } case TouchPhase.Ended: case TouchPhase.Canceled: { if (_activeTouches.TryGetValue(t.fingerId, out int track)) { _activeTouches.Remove(t.fingerId); im.ReleaseTrack(track); } break; } // Moved / Stationary:手指在轨道内保持按住即可,不重新判定归属, // 避免手指轻微滑动跨到相邻轨道时反复 Press/Release 造成误判。 } } } private void ProcessMouse(InputManager im, Camera cam) { 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; } } } /// /// 把屏幕坐标转换为轨道平面上的世界点,用 OverlapPoint 命中轨道点击区,返回轨道索引;无命中返回 -1。 /// /// 相机移动/晃动/推拉时,每帧都用当前相机状态转换,所以命中自动跟随相机。 /// - 正交相机:ScreenToWorldPoint 忽略深度,直接取 x/y。 /// - 透视相机:从相机发射线,求与轨道平面(Z = trackPlaneWorldZ)的交点, /// 避免因相机 Z 变化(如 cameraDash)导致命中点偏移。 /// private int ResolveTrack(Camera cam, Vector2 screenPos) { Vector2 point; if (forceOrthographicMode || cam.orthographic) { Vector3 world = cam.ScreenToWorldPoint(screenPos); point = new Vector2(world.x, world.y); } else { // 透视:射线与轨道平面(法线 +Z,过 z = trackPlaneWorldZ)求交。 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); } Collider2D hit = Physics2D.OverlapPoint(point, hitLayers); if (hit == null) return -1; TrackTouchZone zone = hit.GetComponent(); if (zone == null) zone = hit.GetComponentInParent(); return zone != null ? zone.trackIndex : -1; } 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; } }