diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs b/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs index 2d0cc44..54cf8d6 100644 --- a/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs +++ b/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs @@ -31,9 +31,15 @@ namespace MagnetismHeroes.Tests Saved = Copy(save); return true; } } - private GameApp _app; private MemoryStore _store; private Touchscreen _touchscreen; + private GameApp _app; private MemoryStore _store; private Touchscreen _touchscreen, _otherTouchscreen; private InputSettings _originalInputSettings, _testInputSettings; private HideFlags _originalSettingsFlags; + private readonly System.Collections.Generic.List _touchTrace = new System.Collections.Generic.List(); + private void TraceTouch(UnityEngine.InputSystem.EnhancedTouch.Finger finger) + { + var touch = finger.lastTouch; + _touchTrace.Add($"{Time.frameCount},{finger.screen.deviceId},{Touchscreen.current?.deviceId},{touch.touchId},{touch.phase},{touch.screenPosition.x:R},{touch.screenPosition.y:R}"); + } [UnitySetUp] public IEnumerator SetUp() { // Batch tests have no focused Game View; route synthetic pointers into the actual player loop. @@ -44,6 +50,11 @@ namespace MagnetismHeroes.Tests _testInputSettings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView; _testInputSettings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus; InputSystem.settings = _testInputSettings; + UnityEngine.InputSystem.EnhancedTouch.EnhancedTouchSupport.Enable(); + _touchTrace.Clear(); _touchTrace.Add("frame,sourceDevice,currentDevice,touchId,phase,pointerX,pointerY"); + UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerDown += TraceTouch; + UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerMove += TraceTouch; + UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerUp += TraceTouch; foreach (var listener in Object.FindObjectsByType(FindObjectsSortMode.None)) listener.enabled = false; foreach (var camera in Object.FindObjectsByType(FindObjectsSortMode.None)) camera.enabled = false; var prefab = AssetDatabase.LoadAssetAtPath("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab"); @@ -52,7 +63,18 @@ namespace MagnetismHeroes.Tests } [UnityTearDown] public IEnumerator TearDown() { - if (_touchscreen != null) InputSystem.RemoveDevice(_touchscreen); Object.Destroy(_app.gameObject); + UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerDown -= TraceTouch; + UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerMove -= TraceTouch; + UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerUp -= TraceTouch; + UnityEngine.InputSystem.EnhancedTouch.EnhancedTouchSupport.Disable(); + var evidence = System.Environment.GetEnvironmentVariable("MAGNETISM_TOUCH_EVIDENCE"); + if (!string.IsNullOrEmpty(evidence) && _touchTrace.Count > 1) + { + System.IO.Directory.CreateDirectory(evidence); + System.IO.File.WriteAllLines(System.IO.Path.Combine(evidence, TestContext.CurrentContext.Test.Name + ".csv"), _touchTrace); + } + if (_touchscreen != null) InputSystem.RemoveDevice(_touchscreen); + if (_otherTouchscreen != null) InputSystem.RemoveDevice(_otherTouchscreen); Object.Destroy(_app.gameObject); InputSystem.settings = _originalInputSettings; _originalInputSettings.hideFlags = _originalSettingsFlags; Object.Destroy(_testInputSettings); yield return null; } @@ -71,6 +93,91 @@ namespace MagnetismHeroes.Tests private void QueueTouch(int id, UnityEngine.InputSystem.TouchPhase phase, Vector2 position) => InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = id, phase = phase, position = position }); + [UnityTest] public IEnumerator FirstAndSecondDragAfterAnotherTouchscreenBothUseTheActualPointerDelta() + { + _touchscreen = InputSystem.AddDevice(); _otherTouchscreen = InputSystem.AddDevice(); + _app.StartStage(1); yield return null; Physics.SyncTransforms(); + InputSystem.QueueStateEvent(_otherTouchscreen, new TouchState { touchId = 201, phase = UnityEngine.InputSystem.TouchPhase.Began, position = new Vector2(5, 5) }); + InputSystem.QueueStateEvent(_otherTouchscreen, new TouchState { touchId = 201, phase = UnityEngine.InputSystem.TouchPhase.Ended, position = new Vector2(5, 5) }); + yield return null; yield return null; + Assert.That(Touchscreen.current, Is.SameAs(_otherTouchscreen)); + var camera = JsonUtility.ToJson(_app.Session.Camera); var id = _app.Session.Board.Balls.Single().id; + for (int attempt = 0; attempt < 2; attempt++) + { + Physics.SyncTransforms(); var start = BallPoint(); var end = start + new Vector2(-40, 0); + var before = BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position); + var ray = _app.Presenter.Camera.ScreenPointToRay(start); Physics.Raycast(ray, out var hit, 100); + var plane = new Plane(_app.Presenter.Camera.transform.forward, hit.point); + Assert.That(plane.Raycast(ray, out var from), Is.True); + var endRay = _app.Presenter.Camera.ScreenPointToRay(end); Assert.That(plane.Raycast(endRay, out var to), Is.True); + var expected = before + endRay.GetPoint(to) - ray.GetPoint(from); + QueueTouch(202 + attempt, UnityEngine.InputSystem.TouchPhase.Began, start); + QueueTouch(202 + attempt, UnityEngine.InputSystem.TouchPhase.Moved, end); + QueueTouch(202 + attempt, UnityEngine.InputSystem.TouchPhase.Ended, end); + yield return null; yield return null; + Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo(id)); + Assert.That(Vector3.Distance(BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position), expected), Is.LessThan(.001f), "Both the first and second real queued drag must retain Began and the pointer direction."); + Assert.That(JsonUtility.ToJson(_app.Session.Camera), Is.EqualTo(camera)); Assert.That(_app.Session.Board.CountedCount, Is.Zero); + yield return new WaitForSecondsRealtime(1.5f); + } + } + + [UnityTest] public IEnumerator SwitchedTouchscreenBoundaryDragAndHeldReleaseCommitTheSameLastSafePosition() + { + _touchscreen = InputSystem.AddDevice(); _otherTouchscreen = InputSystem.AddDevice(); + _store.Saved.completedStageIds = new[] { 1 }; + for (int attempt = 0; attempt < 2; attempt++) + { + _store.Saved.latestFreePlay = new FreePlayState { sourceStageId = 1, + balls = new[] { new BallState { id = "native-drag-0", position = new Position3(-6.8f, 4, 0) } }, + groups = new[] { new GroupState { id = "native-drag-group", ballIds = new[] { "native-drag-0" } } }, + camera = new CameraState { target = new Position3(-6.8f, 3.35f, 0), rotation = new Rotation4(0, 0, 0, 1), distance = 14 } }; + _app.Initialize(_store); _app.ContinueFreePlay(); yield return null; Physics.SyncTransforms(); + InputSystem.QueueStateEvent(_otherTouchscreen, new TouchState { touchId = 1, phase = UnityEngine.InputSystem.TouchPhase.Began, position = new Vector2(5, 5) }); + InputSystem.QueueStateEvent(_otherTouchscreen, new TouchState { touchId = 1, phase = UnityEngine.InputSystem.TouchPhase.Ended, position = new Vector2(5, 5) }); + yield return null; yield return null; + var saved = JsonUtility.ToJson(_store.Saved); var camera = JsonUtility.ToJson(_app.Session.Camera); + var start = BallPoint(); var end = start - new Vector2(Screen.width * 193f / 2400f, 0); + QueueTouch(1, UnityEngine.InputSystem.TouchPhase.Began, start); + QueueTouch(1, UnityEngine.InputSystem.TouchPhase.Moved, end); + yield return null; yield return null; + var preview = _app.Presenter.Balls.Single().Value.transform.position; + Assert.That(preview.x, Is.EqualTo(-8).Within(.004f)); Assert.That(preview.y, Is.EqualTo(4).Within(.004f)); Assert.That(preview.z, Is.EqualTo(0).Within(.004f)); + yield return new WaitForSecondsRealtime(.7f); + Assert.That(_app.Presenter.Balls.Single().Value.transform.position, Is.EqualTo(preview)); + Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved), "Camera assistance and a held pointer cannot save a preview."); + QueueTouch(1, UnityEngine.InputSystem.TouchPhase.Ended, end); yield return null; yield return null; + Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo("native-drag-0")); + Assert.That(_app.Session.Board.Groups.Single().ballIds, Is.EqualTo(new[] { "native-drag-0" })); + Assert.That(Vector3.Distance(BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position), preview), Is.LessThan(.001f)); + Assert.That(JsonUtility.ToJson(_app.Session.Camera), Is.EqualTo(camera)); + Assert.That(_store.Saved.completedStageIds, Is.EqualTo(new[] { 1 })); Assert.That(_store.Saved.puzzleStates, Is.Empty); + yield return new WaitForSecondsRealtime(1.5f); + } + } + + [UnityTest] public IEnumerator AnotherTouchscreenWithTheSameIdCannotMovePinchOrEndTheHeldFriend() + { + _touchscreen = InputSystem.AddDevice(); _otherTouchscreen = InputSystem.AddDevice(); + _app.StartStage(1); yield return null; Physics.SyncTransforms(); + var start = BallPoint(); var saved = JsonUtility.ToJson(_store.Saved); var camera = JsonUtility.ToJson(_app.Session.Camera); + QueueTouch(1, UnityEngine.InputSystem.TouchPhase.Began, start); yield return null; yield return null; + QueueTouch(1, UnityEngine.InputSystem.TouchPhase.Moved, start + new Vector2(-40, 0)); yield return null; yield return null; + var preview = _app.Presenter.Balls.Values.Single().transform.position; + foreach (var phase in new[] { UnityEngine.InputSystem.TouchPhase.Began, UnityEngine.InputSystem.TouchPhase.Moved, UnityEngine.InputSystem.TouchPhase.Ended }) + { + InputSystem.QueueStateEvent(_otherTouchscreen, new TouchState { touchId = 1, phase = phase, position = start + new Vector2(100, 30) }); + InputSystem.QueueStateEvent(_otherTouchscreen, new TouchState { touchId = 2, phase = phase, position = start + new Vector2(200, 30) }); + yield return null; yield return null; + Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.EqualTo(preview)); + Assert.That(_app.Orbit.IsAssisting, Is.True); Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved)); + } + QueueTouch(1, UnityEngine.InputSystem.TouchPhase.Canceled, start + new Vector2(-40, 0)); yield return null; yield return null; + Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty); Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved)); + Assert.That(JsonUtility.ToJson(_app.Session.Camera), Is.EqualTo(camera)); + Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.EqualTo(BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position))); + } + [UnityTest] public IEnumerator FailedMoveCameraAndPauseSavesShowAVisibleNoticeAndRecoveryClearsIt() { _app.StartStage(1); yield return null; diff --git a/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs b/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs index 6eacb0b..93caefe 100644 --- a/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs +++ b/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs @@ -33,13 +33,15 @@ namespace MagnetismHeroes.UnityRuntime.Interaction private int _fingerId = -1; private bool _waitForAllTouchesReleased; private int _pinchFirstId = -1, _pinchSecondId = -1; + private Touchscreen _touchscreen; private readonly struct TouchContact { + public readonly Touchscreen Screen; public readonly int Id; public readonly UnityEngine.InputSystem.TouchPhase Phase; public readonly Vector2 Position; - public TouchContact(int id, UnityEngine.InputSystem.TouchPhase phase, Vector2 position) - { Id = id; Phase = phase; Position = position; } + public TouchContact(Touchscreen screen, int id, UnityEngine.InputSystem.TouchPhase phase, Vector2 position) + { Screen = screen; Id = id; Phase = phase; Position = position; } public bool InProgress => Phase == UnityEngine.InputSystem.TouchPhase.Began || Phase == UnityEngine.InputSystem.TouchPhase.Moved || Phase == UnityEngine.InputSystem.TouchPhase.Stationary; } private readonly Queue _touchEvents = new Queue(); @@ -186,10 +188,11 @@ namespace MagnetismHeroes.UnityRuntime.Interaction private void RecordTouch(Finger finger) { - if (!AcceptInput || finger.screen != Touchscreen.current) return; + if (!AcceptInput) return; // Copy each official history callback before later events overwrite the native touch slot. + // Input System makes this device current AFTER its EnhancedTouch callbacks. var touch = finger.lastTouch; - _touchEvents.Enqueue(new TouchContact(touch.touchId, touch.phase, touch.screenPosition)); + _touchEvents.Enqueue(new TouchContact(finger.screen, touch.touchId, touch.phase, touch.screenPosition)); } private void ProcessTouches() @@ -252,11 +255,19 @@ namespace MagnetismHeroes.UnityRuntime.Interaction private void Update() { - if (!AcceptInput) { _touchEvents.Clear(); _touches.Clear(); return; } + if (!AcceptInput) { _touchEvents.Clear(); _touches.Clear(); _touchscreen = null; return; } bool hadTouches = _touchEvents.Count > 0, suppressRestOfFrame = false; while (_touchEvents.Count > 0) { - var touch = _touchEvents.Dequeue(); _touches[touch.Id] = touch; + var touch = _touchEvents.Dequeue(); + if (_touchscreen == null) + { + if (touch.Phase != UnityEngine.InputSystem.TouchPhase.Began) continue; + _touchscreen = touch.Screen; + } + // Touch IDs are local to a device; another device cannot replace a held finger. + if (touch.Screen != _touchscreen) continue; + _touches[touch.Id] = touch; if (!suppressRestOfFrame) { bool ownsGesture = touch.Id == _fingerId || (_gesture == Gesture.Pinch && (touch.Id == _pinchFirstId || touch.Id == _pinchSecondId)); @@ -265,6 +276,7 @@ namespace MagnetismHeroes.UnityRuntime.Interaction ProcessTouches(); suppressRestOfFrame = ownsGesture && canceled; } if (!touch.InProgress) _touches.Remove(touch.Id); + if (_touches.Count == 0) _touchscreen = null; } if (suppressRestOfFrame) _waitForAllTouchesReleased = _touches.Count > 0; if (hadTouches || _touches.Count > 0) return; @@ -284,13 +296,13 @@ namespace MagnetismHeroes.UnityRuntime.Interaction UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerMove += RecordTouch; UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerUp += RecordTouch; } - private void OnApplicationPause(bool paused) { if (paused) { Cancel(); _touchEvents.Clear(); _touches.Clear(); } } + private void OnApplicationPause(bool paused) { if (paused) { Cancel(); _touchEvents.Clear(); _touches.Clear(); _touchscreen = null; } } private void OnDisable() { UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerDown -= RecordTouch; UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerMove -= RecordTouch; UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerUp -= RecordTouch; - EnhancedTouchSupport.Disable(); Cancel(); _touchEvents.Clear(); _touches.Clear(); + EnhancedTouchSupport.Disable(); Cancel(); _touchEvents.Clear(); _touches.Clear(); _touchscreen = null; } } }