From 4f39d621cd7c7722ac5220407cac825a09bb2238 Mon Sep 17 00:00:00 2001 From: NINI Date: Sat, 10 Oct 2026 03:54:08 +0900 Subject: [PATCH] Frame actual friend drags with safe reversible camera assistance --- .../Tests/PlayMode/DragCameraPlayTests.cs | 420 ++++++++++++++++++ .../PlayMode/DragCameraPlayTests.cs.meta | 2 + .../Tests/PlayMode/FirstPuzzlePlayTests.cs | 5 +- .../Tests/PlayMode/VisualUpgradePlayTests.cs | 46 +- .../UnityRuntime/Interaction/BoardInput.cs | 53 +-- .../UnityRuntime/Interaction/OrbitCamera.cs | 245 +++++++++- .../Presentation/BoardPresenter.cs | 33 +- 7 files changed, 739 insertions(+), 65 deletions(-) create mode 100644 Assets/MagnetismHeroes/Tests/PlayMode/DragCameraPlayTests.cs create mode 100644 Assets/MagnetismHeroes/Tests/PlayMode/DragCameraPlayTests.cs.meta diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/DragCameraPlayTests.cs b/Assets/MagnetismHeroes/Tests/PlayMode/DragCameraPlayTests.cs new file mode 100644 index 0000000..3cd1ae9 --- /dev/null +++ b/Assets/MagnetismHeroes/Tests/PlayMode/DragCameraPlayTests.cs @@ -0,0 +1,420 @@ +#if UNITY_EDITOR +using System.Collections; +using System.Linq; +using MagnetismHeroes.Core; +using MagnetismHeroes.UnityRuntime.Bootstrap; +using MagnetismHeroes.UnityRuntime.Presentation; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; + +namespace MagnetismHeroes.Tests +{ + public sealed class DragCameraPlayTests + { + private sealed class MemoryStore : IProgressStore + { + public ProgressSave Save = new ProgressSave { completedStageIds = Enumerable.Range(1, 30).ToArray(), seenTutorialIds = new[] { "merge", "split" } }; + public int Writes; + public bool TryLoad(out ProgressSave save, out string error) { save = Save; error = null; return true; } + public bool TrySave(ProgressSave save, out string error) { Save = save; Writes++; error = null; return true; } + } + private GameApp _app; + private MemoryStore _store; + private readonly System.Collections.Generic.List _disabledScenery = new System.Collections.Generic.List(); + [UnitySetUp] public IEnumerator Setup() + { + foreach (var listener in Object.FindObjectsByType(FindObjectsSortMode.None)) listener.enabled = false; + foreach (var camera in Object.FindObjectsByType(FindObjectsSortMode.None)) camera.enabled = false; + _app = Object.Instantiate(AssetDatabase.LoadAssetAtPath("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab")).GetComponent(); + _store = new MemoryStore(); _app.Initialize(_store); _app.StartStage(6); _app.Voice.Clear(); + _app.Presenter.BindEnvironment(_app.Environment); + _app.Orbit.BindEnvironment(_app.Environment); + _disabledScenery.Clear(); + foreach (var collider in _app.Environment.GetComponentsInChildren()) + if (!collider.name.Contains("Clearing") && collider.enabled) { _disabledScenery.Add(collider); collider.enabled = false; } + yield return null; + } + [UnityTearDown] public IEnumerator Teardown() { Object.Destroy(_app.gameObject); yield return null; } + private void Inject(params Vector3[] positions) + { + var rotation = Quaternion.Euler(12, 0, 0); + var camera = new CameraState { target = new Position3(0, 2, 0), distance = 8, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) }; + var balls = positions.Select((point, index) => new BallState { id = "camera-" + index, position = BoardPresenter.ToPosition(point) }).ToArray(); + _store.Save.latestFreePlay = new FreePlayState { sourceStageId = 6, balls = balls, + groups = balls.Select((ball, index) => new GroupState { id = "group-" + index, ballIds = new[] { ball.id } }).ToArray(), camera = camera }; + _app.Initialize(_store); _app.ContinueFreePlay(); _app.Voice.Clear(); Physics.SyncTransforms(); + } + private Vector2 Point(string id) => _app.Presenter.Camera.WorldToScreenPoint(_app.Presenter.Balls[id].transform.position); + [UnityTest] public IEnumerator ActualDragMovesCameraWhileStationaryFingerPreservesPreviewAndCommittedBoard() + { + Inject(new Vector3(-1, 2, 0), new Vector3(.5f, 2, 2)); + var camera = _app.Presenter.Camera; var initialRotation = camera.transform.rotation; + var saved = JsonUtility.ToJson(_store.Save); int writes = _store.Writes; + var board = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera)); + var manual = JsonUtility.ToJson(_app.Orbit.Snapshot()); + var start = Point("camera-0"); var pointer = start + new Vector2(22, 0); + _app.Input.Begin(start, false); _app.Input.Move(pointer); + var preview = _app.Presenter.Balls["camera-0"].transform.position; + float until = Time.realtimeSinceStartup + .7f; float sampleAt = 0; + var frames = new System.Collections.Generic.List { "elapsed,cameraX,cameraY,cameraZ,rotationX,rotationY,rotationZ,rotationW,previewX,previewY,previewZ,saveWrites" }; + while (Time.realtimeSinceStartup < until) + { + yield return null; _app.Input.Move(pointer); + if (Time.realtimeSinceStartup < sampleAt) continue; + sampleAt = Time.realtimeSinceStartup + .02f; + var pose = camera.transform; var position = _app.Presenter.Balls["camera-0"].transform.position; + frames.Add($"{Time.realtimeSinceStartup},{pose.position.x},{pose.position.y},{pose.position.z},{pose.rotation.x},{pose.rotation.y},{pose.rotation.z},{pose.rotation.w},{position.x},{position.y},{position.z},{_store.Writes}"); + } + WriteRecords("camera-motion-stationary-finger.csv", frames); + Assert.That(Quaternion.Angle(initialRotation, camera.transform.rotation), Is.GreaterThan(3), "A real friend drag must smoothly choose a view that reveals depth."); + Assert.That(_app.Presenter.Balls["camera-0"].transform.position, Is.EqualTo(preview)); + Assert.That(JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera)), Is.EqualTo(board)); + Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(manual)); + Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); Assert.That(_store.Writes, Is.EqualTo(writes)); + _app.Input.Cancel(); + yield return new WaitForSecondsRealtime(1.5f); + Assert.That(Quaternion.Angle(initialRotation, camera.transform.rotation), Is.LessThan(.1f)); + Assert.That(_app.Presenter.Balls["camera-0"].transform.position, Is.EqualTo(BoardPresenter.ToVector(_app.Session.Board.Balls[0].position))); + } + [UnityTest] public IEnumerator DeepFriendsCanActuallyMeetAndMergeThroughTheRebasedInputPlane() + { + Inject(new Vector3(-1.5f, 2, -1), new Vector3(.5f, 2, 2)); + var initial = _app.Session.Board.Balls.Select(ball => ball.id).ToArray(); + var start = Point("camera-0"); var pointer = start + new Vector2(20, 0); + _app.Input.Begin(start, false); _app.Input.Move(pointer); + yield return new WaitForSecondsRealtime(.6f); + var camera = _app.Presenter.Camera; + var records = new System.Collections.Generic.List { "frame,worldX,worldY,worldZ,pointerX,pointerY,cameraX,cameraY,cameraZ,distance,candidate" }; + float until = Time.realtimeSinceStartup + 4; int frame = 0; + while (_app.Presenter.MergeCandidate == null && Time.realtimeSinceStartup < until) + { + var source = _app.Presenter.Balls["camera-0"].transform.position; + var target = _app.Presenter.Balls["camera-1"].transform.position; + var screenDelta = (Vector2)(camera.WorldToScreenPoint(target) - camera.WorldToScreenPoint(source)); + pointer += Vector2.ClampMagnitude(screenDelta, 3); + _app.Input.Move(pointer); + float distance = Vector3.Distance(_app.Presenter.Balls["camera-0"].transform.position, target); + if (frame++ % 20 == 0) records.Add($"{frame},{source.x},{source.y},{source.z},{pointer.x},{pointer.y},{camera.transform.position.x},{camera.transform.position.y},{camera.transform.position.z},{distance},{_app.Presenter.MergeCandidate}"); + yield return null; + } + WriteRecords("depth-drag.csv", records); + Assert.That(_app.Presenter.MergeCandidate, Is.EqualTo("group-1"), "Screen overlap is insufficient: the actual safe 3D spheres must reach contact."); + var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position); + Assert.That(Vector3.Distance(preview["camera-0"], preview["camera-1"]), Is.EqualTo(1).Within(.001)); + Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(2)); + _app.Input.End(pointer); yield return null; + Assert.That(_app.Session.Board.Groups.Single().ballIds, Is.EquivalentTo(initial)); + foreach (var ball in _app.Session.Board.Balls) Assert.That(BoardPresenter.ToVector(ball.position), Is.EqualTo(preview[ball.id])); + } + private static void WriteRecords(string name, System.Collections.Generic.IEnumerable records) + { + string directory = System.Environment.GetEnvironmentVariable("MAGNETISM_DRAG_CAMERA_EVIDENCE"); + if (string.IsNullOrEmpty(directory)) return; + System.IO.Directory.CreateDirectory(directory); System.IO.File.WriteAllLines(System.IO.Path.Combine(directory, name), records); + } + [UnityTest] public IEnumerator CandidateHysteresisAvoidsFrameByFrameSwitchingAndFarFriendsAreNotTracked() + { + Inject(new Vector3(-1, 2, 0), new Vector3(1, 2, 1), new Vector3(1.05f, 2, -1)); + var pointer = Point("camera-0"); _app.Input.Begin(pointer, false); pointer += new Vector2(22, 0); _app.Input.Move(pointer); + yield return new WaitForSecondsRealtime(.1f); + string candidate = _app.Orbit.TrackedFriend; Assert.That(candidate, Is.Not.Null); + for (int index = 0; index < 20; index++) + { + pointer += new Vector2(index % 2 == 0 ? 1 : -1, 0); _app.Input.Move(pointer); yield return null; + Assert.That(_app.Orbit.TrackedFriend, Is.EqualTo(candidate)); + } + _app.Input.Cancel(); + Inject(new Vector3(-4, 2, 0), new Vector3(4, 2, 0)); + pointer = Point("camera-0"); _app.Input.Begin(pointer, false); _app.Input.Move(pointer + new Vector2(22, 0)); + yield return new WaitForSecondsRealtime(.2f); + Assert.That(_app.Orbit.TrackedFriend, Is.Null); + } + [UnityTest] public IEnumerator TapThresholdUiAndSplitSelectionDoNotStartCameraAssistance() + { + Inject(new Vector3(-1, 2, 0), new Vector3(1, 2, 0)); + var camera = _app.Presenter.Camera; var position = camera.transform.position; var rotation = camera.transform.rotation; + var point = Point("camera-0"); + _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(10, 0)); yield return new WaitForSecondsRealtime(.1f); _app.Input.End(point); + Assert.That(_app.Orbit.IsAssisting, Is.False); + _app.Input.Begin(point, true); _app.Input.Move(point + new Vector2(100, 0)); yield return new WaitForSecondsRealtime(.1f); _app.Input.End(point); + Assert.That(camera.transform.position, Is.EqualTo(position)); Assert.That(camera.transform.rotation, Is.EqualTo(rotation)); + _store.Save.latestFreePlay.groups = new[] { new GroupState { id = "group-0", ballIds = new[] { "camera-0", "camera-1" } } }; + _app.Initialize(_store); _app.ContinueFreePlay(); _app.Voice.Clear(); Physics.SyncTransforms(); + point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.End(point); _app.Input.BeginSplit(); + _app.Input.Begin(point, false); _app.Input.End(point); yield return new WaitForSecondsRealtime(.1f); + Assert.That(_app.Input.SelectedIds, Is.EquivalentTo(new[] { "camera-0" })); Assert.That(_app.Orbit.IsAssisting, Is.False); + Assert.That(camera.transform.position, Is.EqualTo(position)); Assert.That(camera.transform.rotation, Is.EqualTo(rotation)); + } + [UnityTest] public IEnumerator SplitDragAssistsAndReturnsWithPreviewExactlyMatchingCommittedBothHalves() + { + Inject(new Vector3(-1, 2, 0), new Vector3(0, 2, 0), new Vector3(1, 2, 0)); + _store.Save.latestFreePlay.groups = new[] { new GroupState { id = "group-0", ballIds = new[] { "camera-0", "camera-1", "camera-2" } } }; + _app.Initialize(_store); _app.ContinueFreePlay(); _app.Voice.Clear(); Physics.SyncTransforms(); + var camera = _app.Presenter.Camera; var original = camera.transform.rotation; var manual = JsonUtility.ToJson(_app.Orbit.Snapshot()); + var point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.End(point); _app.Input.BeginSplit(); + _app.Input.Begin(point, false); _app.Input.End(point); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(-55, 25)); + yield return new WaitForSecondsRealtime(.4f); + Assert.That(_app.Orbit.IsAssisting, Is.True); Assert.That(Quaternion.Angle(original, camera.transform.rotation), Is.GreaterThan(2)); + Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(1)); + var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position); + Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(manual)); + _app.Input.End(point + new Vector2(-55, 25)); yield return null; + Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(2)); + Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EquivalentTo(preview.Keys)); + foreach (var ball in _app.Session.Board.Balls) Assert.That(BoardPresenter.ToVector(ball.position), Is.EqualTo(preview[ball.id])); + Assert.That(JsonUtility.ToJson(_store.Save.latestFreePlay.camera), Is.EqualTo(manual)); + yield return new WaitForSecondsRealtime(1.5f); Assert.That(Quaternion.Angle(original, camera.transform.rotation), Is.LessThan(.1)); + } + [UnityTest] public IEnumerator CancelPinchOutsidePauseAndNavigationRestorePreviewCameraAndSavedState() + { + foreach (var mode in new[] { "cancel", "pinch", "outside", "pause", "navigation" }) + { + Inject(new Vector3(-1, 2, 0), new Vector3(1, 2, 1)); + var camera = _app.Presenter.Camera; var original = camera.transform.rotation; + var saved = JsonUtility.ToJson(_store.Save); var manual = JsonUtility.ToJson(_app.Orbit.Snapshot()); + var point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(24, 0)); + yield return new WaitForSecondsRealtime(.25f); + Assert.That(Quaternion.Angle(original, camera.transform.rotation), Is.GreaterThan(2), mode); + switch (mode) + { + case "cancel": _app.Input.Cancel(); break; + case "pinch": _app.Input.BeginPinch(100, false); _app.Input.Pinch(125); _app.Input.End(point, true); break; + case "outside": _app.Input.Move(new Vector2(-1, point.y)); break; + case "pause": _app.Input.SendMessage("OnApplicationPause", true); break; + case "navigation": _app.ShowHome(); _app.ContinueFreePlay(); break; + } + yield return new WaitForSecondsRealtime(1.5f); + Assert.That(_app.Orbit.IsAssisting, Is.False, mode); Assert.That(Quaternion.Angle(original, camera.transform.rotation), Is.LessThan(.1), mode); + Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(manual), mode); + Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved), mode); + foreach (var ball in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[ball.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(ball.position)), mode); + } + } + [UnityTest] public IEnumerator RegrabDuringReturnDoesNotSnapCameraOrChangeFirstHitSelection() + { + Inject(new Vector3(-1, 2, 0), new Vector3(1, 2, 1)); + var point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(25, 0)); + yield return new WaitForSecondsRealtime(.4f); _app.Input.Cancel(); yield return null; + var camera = _app.Presenter.Camera; var position = camera.transform.position; var rotation = camera.transform.rotation; + point = Point("camera-0"); Physics.SyncTransforms(); + Assert.That(Physics.Raycast(camera.ScreenPointToRay(point), out var hit, 100), Is.True); + Assert.That(hit.collider.GetComponent().BallId, Is.EqualTo("camera-0")); + _app.Input.Begin(point, false); + Assert.That(camera.transform.position, Is.EqualTo(position)); Assert.That(camera.transform.rotation, Is.EqualTo(rotation)); + _app.Input.End(point); Assert.That(_app.Input.SelectedGroup, Is.EqualTo("group-0")); + } + [UnityTest] public IEnumerator ValidManualRolledPoseRoundTripsAndInsidePropPoseIsCorrected() + { + Inject(new Vector3(0, 2, 0)); + var rotation = Quaternion.Euler(25, 135, 45); + var state = new CameraState { target = new Position3(0, 2, 0), distance = 7, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) }; + _app.Orbit.Apply(state); + Assert.That(Quaternion.Angle(rotation, _app.Presenter.Camera.transform.rotation), Is.LessThan(.001f)); + _app.Session.CommitCamera(_app.Orbit.Snapshot()); + var snapshot = JsonUtility.ToJson(_app.Orbit.Snapshot()); _app.ShowHome(); _app.ContinueFreePlay(); + Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(snapshot)); + var obstacle = new GameObject("camera-test-solid"); obstacle.transform.SetParent(_app.Environment.transform); obstacle.transform.position = _app.Presenter.Camera.transform.position; + obstacle.AddComponent().size = Vector3.one * 2; Physics.SyncTransforms(); + var balls = _app.Session.Board.Balls.Select(ball => (ball.id, ball.position)).ToArray(); + _app.Orbit.Apply(state); yield return null; + Assert.That(obstacle.GetComponent().bounds.Contains(_app.Presenter.Camera.transform.position), Is.False); + Assert.That(_app.Session.Board.Balls.Select(ball => (ball.id, ball.position)), Is.EqualTo(balls)); + Object.Destroy(obstacle); + } + [UnityTest] public IEnumerator PinchTakesVisiblePoseWithoutSnapAndSavesOnlyAfterActualManualZoom() + { + Inject(new Vector3(-1, 2, 0), new Vector3(1, 2, 1)); + var point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(25, 0)); + yield return new WaitForSecondsRealtime(.4f); + var camera = _app.Presenter.Camera; var position = camera.transform.position; var rotation = camera.transform.rotation; + var saved = JsonUtility.ToJson(_store.Save); int writes = _store.Writes; + _app.Input.BeginPinch(100, false); + Assert.That(camera.transform.position, Is.EqualTo(position)); Assert.That(camera.transform.rotation, Is.EqualTo(rotation)); + Assert.That(_app.Orbit.IsAssisting, Is.False); Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); + _app.Input.Pinch(110); + Assert.That(Quaternion.Angle(camera.transform.rotation, rotation), Is.LessThan(.001f)); + Assert.That(Vector3.Distance(camera.transform.position, position), Is.LessThan(1)); + var manual = JsonUtility.ToJson(_app.Orbit.Snapshot()); Assert.That(_store.Writes, Is.EqualTo(writes)); + _app.Input.End(point); Assert.That(_store.Writes, Is.EqualTo(writes + 1)); + Assert.That(JsonUtility.ToJson(_store.Save.latestFreePlay.camera), Is.EqualTo(manual)); + _app.ShowHome(); _app.ContinueFreePlay(); Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(manual)); + } + [UnityTest] public IEnumerator NearbyFriendBehindAThinPropIsNotAnInteractionCameraCandidate() + { + Inject(new Vector3(-1, 2, 0), new Vector3(1.5f, 2, 1)); + var wall = new GameObject("camera-test-blocked-approach"); wall.transform.SetParent(_app.Environment.transform); + wall.transform.position = new Vector3(.3f, 2, .5f); wall.AddComponent().size = new Vector3(.02f, 5, 5); Physics.SyncTransforms(); + var point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(22, 0)); + yield return new WaitForSecondsRealtime(.2f); + Assert.That(_app.Orbit.IsAssisting, Is.True); Assert.That(_app.Orbit.TrackedFriend, Is.Null); Assert.That(_app.Presenter.MergeCandidate, Is.Null); + _app.Input.Cancel(); Object.Destroy(wall); + } + [UnityTest] public IEnumerator EntireRequestedPitchOrbitBlockedByLargePropStillFindsAnExteriorCamera() + { + Inject(new Vector3(0, 2, 0)); + var state = _app.Orbit.Snapshot(); var box = new GameObject("camera-test-large-terrain"); box.transform.SetParent(_app.Environment.transform); + box.transform.position = new Vector3(0, 10, -7); box.AddComponent().size = new Vector3(12, 25, 12); Physics.SyncTransforms(); + _app.Orbit.Apply(state); yield return null; + var camera = _app.Presenter.Camera; + Assert.That(box.GetComponent().bounds.Contains(camera.transform.position), Is.False); + Assert.That(camera.transform.position.y, Is.GreaterThan(_app.Environment.PlaygroundBounds.min.y)); + Assert.That(_app.Orbit.Snapshot().distance, Is.InRange(3, 15)); + Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo("camera-0")); + Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(new Position3(0, 2, 0))); + Object.Destroy(box); + } + [UnityTest] public IEnumerator PhoneAndTabletAssistanceKeepsGrabAndNearbyFriendInFrameWithVisibleNumbers() + { + foreach (var size in new[] { new Vector2Int(2400, 1080), new Vector2Int(1600, 1200) }) + { + Inject(new Vector3(-1, 2, 0), new Vector3(1, 2, 2)); + var camera = _app.Presenter.Camera; camera.aspect = (float)size.x / size.y; + var point = Point("camera-0"); Assert.That(Physics.Raycast(camera.ScreenPointToRay(point), out var hit, 100), Is.True); + var offset = hit.point - _app.Presenter.Balls["camera-0"].transform.position; + _app.Input.Begin(point, false); var pointer = point + new Vector2(25, 0); _app.Input.Move(pointer); + var preview = _app.Presenter.Balls["camera-0"].transform.position; + float until = Time.realtimeSinceStartup + .6f; + while (Time.realtimeSinceStartup < until) + { + yield return null; _app.Input.Move(pointer); + Assert.That(_app.Presenter.Balls["camera-0"].transform.position, Is.EqualTo(preview)); + Assert.That(Vector2.Distance(camera.WorldToScreenPoint(preview + offset), pointer), Is.LessThan(.1f), "The grabbed surface remains under the stationary finger while camera moves."); + } + foreach (var ball in _app.Presenter.Balls.Values) + { + var viewport = camera.WorldToViewportPoint(ball.transform.position); + Assert.That(viewport.z, Is.GreaterThan(camera.nearClipPlane)); + Assert.That(viewport.x, Is.InRange(.12f, .88f)); Assert.That(viewport.y, Is.InRange(.12f, .88f)); + } + _app.Presenter.SendMessage("LateUpdate"); + foreach (var badge in _app.Presenter.GetComponentsInChildren()) + { + var number = badge.GetComponentInChildren(); number.ForceMeshUpdate(); + Assert.That(number.text, Is.EqualTo("1")); Assert.That(number.textInfo.characterInfo[0].isVisible, Is.True); + var character = number.textInfo.characterInfo[0]; + foreach (var vertex in new[] { character.bottomLeft, character.topLeft, character.topRight, character.bottomRight }) + { + var view = camera.WorldToViewportPoint(number.transform.TransformPoint(vertex)); + Assert.That(view.z, Is.GreaterThan(camera.nearClipPlane)); Assert.That(view.x, Is.InRange(0, 1)); Assert.That(view.y, Is.InRange(0, 1)); + } + } + Capture(size, "drag-assisted-" + size.x + "x" + size.y); + _app.Input.Cancel(); + } + } + private void Capture(Vector2Int size, string name) + { + string directory = System.Environment.GetEnvironmentVariable("MAGNETISM_DRAG_CAMERA_EVIDENCE"); + if (string.IsNullOrEmpty(directory)) return; + var camera = _app.Presenter.Camera; var canvas = _app.GetComponentInChildren(); + var renderMode = canvas.renderMode; var canvasCamera = canvas.worldCamera; float planeDistance = canvas.planeDistance; + var target = new RenderTexture(size.x, size.y, 24); var oldTarget = camera.targetTexture; var active = RenderTexture.active; + var pixels = new Texture2D(size.x, size.y, TextureFormat.RGB24, false); + try + { + canvas.renderMode = RenderMode.ScreenSpaceCamera; canvas.worldCamera = camera; canvas.planeDistance = .5f; + camera.targetTexture = target; camera.Render(); RenderTexture.active = target; + pixels.ReadPixels(new Rect(0, 0, size.x, size.y), 0, 0); pixels.Apply(); + System.IO.File.WriteAllBytes(System.IO.Path.Combine(directory, name + ".png"), pixels.EncodeToPNG()); + WriteRecords(name + ".txt", new[] { "Actual GameApp prefab Camera.Render with simulated viewport; Editor result, not physical phone/tablet input or performance.", + "camera=" + camera.transform.position + ";rotation=" + camera.transform.rotation + ";tracked=" + _app.Orbit.TrackedFriend + ";manual=" + JsonUtility.ToJson(_app.Orbit.Snapshot()) }); + } + finally + { + camera.targetTexture = oldTarget; RenderTexture.active = active; target.Release(); Object.Destroy(target); Object.Destroy(pixels); + canvas.renderMode = renderMode; canvas.worldCamera = canvasCamera; canvas.planeDistance = planeDistance; + } + } + [UnityTest] public IEnumerator ThirtyOriginalStageBoardsRemainDiscoverableFromValidAboveGroundManualViews() + { + foreach (var collider in _disabledScenery) if (collider != null) collider.enabled = true; + Physics.SyncTransforms(); + var discoveries = new System.Collections.Generic.List(); + for (int stage = 1; stage <= 30; stage++) + { + _app.StartStage(stage); _app.Voice.Clear(); yield return null; + var before = _app.Session.Board.Balls.Select(ball => (ball.id, ball.position)).ToArray(); + var visible = new System.Collections.Generic.HashSet(); var original = _app.Orbit.Snapshot(); + foreach (float pitch in new[] { 15f, 35f, 60f, 80f }) + for (float yaw = 0; yaw < 360; yaw += 15) + { + var rotation = Quaternion.Euler(pitch, yaw, 0); + _app.Orbit.Apply(new CameraState { target = original.target, distance = 12, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) }); + var camera = _app.Presenter.Camera; Physics.SyncTransforms(); _app.Presenter.SendMessage("LateUpdate"); + var badges = _app.Presenter.GetComponentsInChildren(); + Assert.That(camera.transform.position.y, Is.GreaterThan(_app.Environment.PlaygroundBounds.min.y)); + foreach (var ball in _app.Session.Board.Balls) + { + var world = BoardPresenter.ToVector(ball.position); var view = camera.WorldToViewportPoint(world); + if (view.z <= camera.nearClipPlane || view.x < .04f || view.x > .96f || view.y < .25f || view.y > .84f) continue; + if (!Physics.Raycast(camera.ScreenPointToRay(camera.WorldToScreenPoint(world)), out var hit, 100) || hit.collider.GetComponent()?.BallId != ball.id) continue; + var group = _app.Session.Board.Groups.First(item => item.ballIds.Contains(ball.id)); + int groupIndex = System.Array.FindIndex(_app.Session.Board.Groups, item => item.id == group.id); + var number = badges[groupIndex].GetComponentInChildren(); number.ForceMeshUpdate(); + Assert.That(number.text, Is.EqualTo(group.ballIds.Length.ToString()), "The actual group number must match membership in every explored original stage."); + bool readable = true; + foreach (var character in number.textInfo.characterInfo.Take(number.textInfo.characterCount)) + { + readable &= character.isVisible; + foreach (var vertex in new[] { character.bottomLeft, character.topLeft, character.topRight, character.bottomRight }) + { + var glyphPoint = camera.WorldToViewportPoint(number.transform.TransformPoint(vertex)); + readable &= glyphPoint.z > camera.nearClipPlane && glyphPoint.x > 0 && glyphPoint.x < 1 && glyphPoint.y > .2f && glyphPoint.y < .9f; + } + } + var glyphCenter = number.transform.position; var glyphRay = new Ray(camera.transform.position, (glyphCenter - camera.transform.position).normalized); + readable &= !Physics.Raycast(glyphRay, Vector3.Distance(camera.transform.position, glyphCenter) - .01f); + if (!readable) continue; + if (visible.Add(ball.id)) discoveries.Add($"stage={stage};id={ball.id};position={world};camera={camera.transform.position};pitch={pitch};yaw={yaw};visibleGlyph={number.text};glyph={number.transform.position}"); + } + } + Assert.That(visible, Is.EquivalentTo(_app.Session.Board.Balls.Select(ball => ball.id)), "Actual sphere first-hit discovery stage " + stage); + Assert.That(_app.Session.Board.Balls.Select(ball => (ball.id, ball.position)), Is.EqualTo(before)); + } + WriteRecords("thirty-stage-first-hit-discovery.txt", discoveries); + } + [UnityTest] public IEnumerator ObstructedReturnRetracesTheActualSafeAssistPathWithoutCameraSnap() + { + Inject(new Vector3(-1.5f, 2, -1), new Vector3(.5f, 2, 2)); + var camera = _app.Presenter.Camera; var manualPosition = camera.transform.position; var manualRotation = camera.transform.rotation; + var point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(20, 0)); + yield return new WaitForSecondsRealtime(.7f); var assisted = camera.transform.position; + _app.Input.Cancel(); yield return new WaitForSecondsRealtime(1.5f); + var obstacle = new GameObject("camera-test-return-shortcut-obstacle"); obstacle.transform.SetParent(_app.Environment.transform); + obstacle.transform.position = Vector3.Lerp(manualPosition, assisted, .5f); obstacle.AddComponent().size = Vector3.one * .15f; Physics.SyncTransforms(); + point = Point("camera-0"); _app.Input.Begin(point, false); var pointer = point + new Vector2(20, 0); _app.Input.Move(pointer); + yield return new WaitForSecondsRealtime(.8f); + Assert.That(Vector3.Distance(camera.transform.position, assisted), Is.LessThan(.1f), "The original curved assistance path must remain traversable with a blocked straight return chord."); + Assert.That(Physics.SphereCastAll(camera.transform.position, .35f, (manualPosition - camera.transform.position).normalized, Vector3.Distance(camera.transform.position, manualPosition)).Any(hit => hit.collider == obstacle.GetComponent()), Is.True); + var saved = JsonUtility.ToJson(_store.Save); _app.Input.Cancel(); float until = Time.realtimeSinceStartup + 4; + var records = new System.Collections.Generic.List(); + while (_app.Orbit.IsReturning && Time.realtimeSinceStartup < until) + { + var previous = camera.transform.position; yield return null; + records.Add("previous=" + previous + ";position=" + camera.transform.position + ";rotation=" + camera.transform.rotation); + bool crossed = Physics.SphereCastAll(previous, .35f, (camera.transform.position - previous).normalized, Vector3.Distance(previous, camera.transform.position)).Any(hit => hit.collider == obstacle.GetComponent()); + WriteRecords("obstructed-return-frames.txt", records); + Assert.That(crossed, Is.False); + Assert.That(Vector3.Distance(previous, manualPosition) > .1f && Vector3.Distance(camera.transform.position, manualPosition) < .001f, Is.False, "A blocked return cannot instantly snap to the baseline camera."); + + } + WriteRecords("safe-obstructed-return.txt", records); + Assert.That(_app.Orbit.IsReturning, Is.False); Assert.That(Vector3.Distance(camera.transform.position, manualPosition), Is.LessThan(.002f)); + Assert.That(Quaternion.Angle(camera.transform.rotation, manualRotation), Is.LessThan(.02)); Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); + Object.Destroy(obstacle); + } + [UnityTest] public IEnumerator UndergroundSavedViewIsCorrectedWithoutChangingBallIdsOrPositions() + { + Inject(new Vector3(0, 2, 0)); + var board = JsonUtility.ToJson(_app.Session.Board.Balls); + var rotation = Quaternion.Euler(-90, 0, 0); + _app.Orbit.Apply(new CameraState { target = new Position3(0, 2, 0), distance = 8, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) }); + yield return null; + Assert.That(_app.Presenter.Camera.transform.position.y, Is.GreaterThan(0)); + Assert.That(JsonUtility.ToJson(_app.Session.Board.Balls), Is.EqualTo(board)); + } + } +} +#endif diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/DragCameraPlayTests.cs.meta b/Assets/MagnetismHeroes/Tests/PlayMode/DragCameraPlayTests.cs.meta new file mode 100644 index 0000000..e856181 --- /dev/null +++ b/Assets/MagnetismHeroes/Tests/PlayMode/DragCameraPlayTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1fed67558eff50b4a85be0f7cf14aa35 \ No newline at end of file diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs b/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs index 699c33b..2d0cc44 100644 --- a/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs +++ b/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs @@ -48,7 +48,7 @@ namespace MagnetismHeroes.Tests foreach (var camera in Object.FindObjectsByType(FindObjectsSortMode.None)) camera.enabled = false; var prefab = AssetDatabase.LoadAssetAtPath("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab"); Assert.That(prefab, Is.Not.Null); _app = Object.Instantiate(prefab).GetComponent(); - _store = new MemoryStore(); _app.Initialize(_store); _app.Presenter.BindEnvironment(_app.Environment); yield return null; + _store = new MemoryStore(); _app.Initialize(_store); _app.Presenter.BindEnvironment(_app.Environment); _app.Orbit.BindEnvironment(_app.Environment); yield return null; } [UnityTearDown] public IEnumerator TearDown() { @@ -219,7 +219,7 @@ namespace MagnetismHeroes.Tests } [UnityTest] public IEnumerator GroupNumberFrontViewStaysAboveAndVisible() => CheckGroupNumber(Quaternion.identity); - [UnityTest] public IEnumerator GroupNumberBelowViewStaysAboveAndVisible() => CheckGroupNumber(Quaternion.Euler(-90, 0, 0)); + [UnityTest] public IEnumerator GroupNumberCorrectedUndergroundViewStaysAboveAndVisible() => CheckGroupNumber(Quaternion.Euler(-90, 0, 0)); [UnityTest] public IEnumerator GroupNumberRolledViewStaysAboveAndVisible() => CheckGroupNumber(Quaternion.Euler(0, 0, 90)); [UnityTest] public IEnumerator GroupNumberPreviewFollowsTheFriendWithoutChangingCommittedState() => CheckGroupNumber(Quaternion.Euler(-90, 0, 0), true); @@ -233,6 +233,7 @@ namespace MagnetismHeroes.Tests var committedCamera = JsonUtility.ToJson(_app.Session.Camera); _app.Orbit.Apply(new CameraState { target = new Position3(4, 0.6f, 0), distance = 8, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) }); + Assert.That(_app.Presenter.Camera.transform.position.y, Is.GreaterThan(-.45f), "Saved underground views must be corrected above the clearing."); if (preview) _app.Presenter.PreviewGroup(_app.Session.Board.Groups.Single().id, new Vector3(0.8f, 0, 0.3f)); yield return null; Physics.SyncTransforms(); var camera = _app.Presenter.Camera; var badge = _app.Presenter.GetComponentInChildren(); diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/VisualUpgradePlayTests.cs b/Assets/MagnetismHeroes/Tests/PlayMode/VisualUpgradePlayTests.cs index 9c4fd27..d47b45b 100644 --- a/Assets/MagnetismHeroes/Tests/PlayMode/VisualUpgradePlayTests.cs +++ b/Assets/MagnetismHeroes/Tests/PlayMode/VisualUpgradePlayTests.cs @@ -44,6 +44,7 @@ namespace MagnetismHeroes.Tests _app = Object.FindFirstObjectByType(); Assert.That(_app, Is.Not.Null, "Actual Main scene must initialize its app."); _store = new Store(); _app.Initialize(_store); + _app.Presenter.BindEnvironment(_app.Environment); _app.Orbit.BindEnvironment(_app.Environment); _evidence = Environment.GetEnvironmentVariable("MAGNETISM_VISUAL_EVIDENCE") ?? Path.GetFullPath("artifacts/verification/VisualUpgrade/editor-play"); Directory.CreateDirectory(_evidence); yield return null; @@ -221,45 +222,20 @@ namespace MagnetismHeroes.Tests } } - [UnityTest] public IEnumerator BelowGroundComparisonKeepsOriginalPhysicalOcclusionAndActualBoardUnchanged() + [UnityTest] public IEnumerator UndergroundViewIsCorrectedAboveGroundWithoutChangingActualBoard() { _app.StartStage(21); _app.Voice.Clear(); yield return null; + var balls = _app.Session.Board.Balls.Select(ball => (ball.id, ball.position)).ToArray(); + var groups = _app.Session.Board.Groups.Select(group => (group.id, ids: group.ballIds.ToArray())).ToArray(); var rotation = Quaternion.Euler(-45, 210, 0); _app.Orbit.Apply(new CameraState { target = _app.Orbit.Snapshot().target, distance = 15, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) }); - Physics.SyncTransforms(); - var camera = _app.Presenter.Camera; - var floor = _app.transform.Find("Playground/PlaygroundIsland"); - var originalRenderer = floor.GetComponent(); var originalCollider = floor.GetComponent(); - var clearing = _app.Environment.GetComponentsInChildren().Single(item => item.name == "MeadowClearing(Clone)"); - var groundRenderers = clearing.GetComponentsInChildren(); - var board = JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Orbit.Snapshot())); - var proof = new System.Collections.Generic.List(); - var rays = _app.Session.Board.Balls.Select(ball => new Ray(camera.transform.position, - (BoardPresenter.ToVector(ball.position) - camera.transform.position).normalized)).ToArray(); - var priorHits = rays.Select(ray => { Assert.That(Physics.Raycast(ray, out var hit, 100), Is.True); return hit; }).ToArray(); - Capture("belowground-approved-clearing", "Actual stage21 board/camera; approved ground rendered, unchanged original collider"); - try - { - foreach (var renderer in groundRenderers) renderer.enabled = false; - originalRenderer.enabled = true; - Capture("belowground-original-island", "Same actual board/camera/colliders; temporary original floor renderer instead of clearing"); - for (int index = 0; index < rays.Length; index++) - { - Assert.That(Physics.Raycast(rays[index], out var hit, 100), Is.True); - Assert.That(hit.collider, Is.SameAs(priorHits[index].collider)); - Assert.That(hit.distance, Is.EqualTo(priorHits[index].distance).Within(.0001f)); - Assert.That(originalCollider.Raycast(rays[index], out var floorHit, 100), Is.True, - "Original floor physically occludes this below-ground board ray."); - var sphere = _app.Presenter.Balls[_app.Session.Board.Balls[index].id].GetComponent(); - Assert.That(sphere.Raycast(rays[index], out var sphereHit, 100), Is.True); - Assert.That(floorHit.distance, Is.LessThan(sphereHit.distance)); - proof.Add($"id={_app.Session.Board.Balls[index].id}; firstHit={hit.collider.name}; floorDepth={floorHit.distance}; sphereDepth={sphereHit.distance}; originalBounds={originalCollider.bounds}; clearingBounds={groundRenderers[0].bounds}"); - } - Assert.That(JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Orbit.Snapshot())), Is.EqualTo(board)); - File.WriteAllLines(Path.Combine(_evidence, "belowground-raycast-comparison.txt"), proof); - } - finally { foreach (var renderer in groundRenderers) renderer.enabled = true; originalRenderer.enabled = false; } + yield return null; Physics.SyncTransforms(); + Assert.That(_app.Presenter.Camera.transform.position.y, Is.GreaterThan(_app.Environment.PlaygroundBounds.min.y)); + Assert.That(_app.Session.Board.Balls.Select(ball => (ball.id, ball.position)), Is.EqualTo(balls)); + Assert.That(_app.Session.Board.Groups.Select(group => group.id), Is.EqualTo(groups.Select(group => group.id))); + for (int index = 0; index < groups.Length; index++) Assert.That(_app.Session.Board.Groups[index].ballIds, Is.EqualTo(groups[index].ids)); + Capture("corrected-underground-view", "Actual stage21; legacy below-ground saved view corrected above clearing; board IDs, groups and positions unchanged"); } [UnityTest] public IEnumerator AllWorldsProduceActualSoftShadowPixelsWithUnchangedBoardAndMaterials() @@ -303,7 +279,7 @@ namespace MagnetismHeroes.Tests } var normalView = _app.Orbit.Snapshot(); foreach (var view in new[] { (name: "orbit95-pitch45-distance6", yaw: 95f, pitch: 45f, distance: 6f), - (name: "orbit210-pitch-45-distance15", yaw: 210f, pitch: -45f, distance: 15f) }) + (name: "orbit210-pitch25-distance15", yaw: 210f, pitch: 25f, distance: 15f) }) { var rotation = Quaternion.Euler(view.pitch, view.yaw, 0); _app.Orbit.Apply(new CameraState { target = normalView.target, distance = view.distance, diff --git a/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs b/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs index 5cbcfae..6eacb0b 100644 --- a/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs +++ b/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs @@ -28,8 +28,7 @@ namespace MagnetismHeroes.UnityRuntime.Interaction public int SelectedCount => _selected.Count; public string SelectedGroup => _selectedGroup; public IReadOnlyCollection SelectedIds => _selected; - private Plane _dragPlane; - private Vector3 _grabPoint, _previewDelta; + private Vector3 _grabOffset, _previewDelta; private float _pinchDistance; private int _fingerId = -1; private bool _waitForAllTouchesReleased; @@ -72,8 +71,8 @@ namespace MagnetismHeroes.UnityRuntime.Interaction _app.Sound?.Pickup(); if (_splitting && _selectedGroup != _groupId) { _selected.Clear(); _selectedGroup = _groupId; _app.RefreshSelection(); } _splitDrag = _splitting && _selected.Contains(_ballId); - _dragPlane = new Plane(_orbit.Camera.transform.forward, hit.point); - _grabPoint = hit.point; + _grabOffset = hit.point - ball.transform.position; + _orbit.SuspendReturn(); } else _gesture = Gesture.Orbit; } @@ -81,29 +80,30 @@ namespace MagnetismHeroes.UnityRuntime.Interaction public void Move(Vector2 point) { if (_gesture == Gesture.None || _gesture == Gesture.Blocked || _gesture == Gesture.Pinch) return; + if (!IsInside(point)) { if (_splitting) CancelSplit(); else Cancel(); return; } + bool wasDragged = _dragged; _dragged |= (point - _start).sqrMagnitude >= _dragThreshold * _dragThreshold; - if (_dragged && _gesture == Gesture.Friend) + if (_dragged && _gesture == Gesture.Friend && (!_splitting || _splitDrag)) { - var ray = _orbit.Camera.ScreenPointToRay(point); - if (_dragPlane.Raycast(ray, out var distance)) + if (!wasDragged) { - var nextPoint = ray.GetPoint(distance); - var step = nextPoint - _grabPoint; _grabPoint = nextPoint; - if (_splitting) - { - _previewDelta += step; - if (_splitDrag) - { - _presenter.PreviewSplit(_groupId, _selected.ToArray(), _previewDelta); - _previewDelta = _presenter.DropDelta; - } - } - else - { - _presenter.PreviewGroup(_groupId, _presenter.DropDelta + step); - _previewDelta = _presenter.DropDelta; - } + var members = _splitting ? _selected.ToArray() : _app.Session.Board.Groups.First(group => group.id == _groupId).ballIds; + _orbit.BeginDrag(_presenter, _ballId, members, _grabOffset, point); } + // Rebase both pointer endpoints in the CURRENT camera's plane. Camera motion alone has zero delta. + var anchor = _presenter.Balls[_ballId].transform.position + _grabOffset; + var plane = new Plane(_orbit.Camera.transform.forward, anchor); + var previousPoint = wasDragged ? _last : _start; + var previousRay = _orbit.Camera.ScreenPointToRay(previousPoint); + var ray = _orbit.Camera.ScreenPointToRay(point); + if (point != previousPoint && plane.Raycast(previousRay, out var previousDistance) && plane.Raycast(ray, out var distance)) + { + var step = ray.GetPoint(distance) - previousRay.GetPoint(previousDistance); + if (_splitting) _presenter.PreviewSplit(_groupId, _selected.ToArray(), _previewDelta + step); + else _presenter.PreviewGroup(_groupId, _presenter.DropDelta + step); + _previewDelta = _presenter.DropDelta; + } + _orbit.DragPointer(point); } else if (_dragged && _gesture == Gesture.Orbit) _orbit.Orbit(point - _last); _last = point; @@ -140,13 +140,15 @@ namespace MagnetismHeroes.UnityRuntime.Interaction else { _selectedGroup = _groupId; _app.CountBall(_ballId); _app.RefreshSelection(); } } else if (_gesture == Gesture.Orbit && _dragged) _app.Session.CommitCamera(_orbit.Snapshot()); - else if (_gesture == Gesture.Pinch) _app.Session.CommitCamera(_orbit.Snapshot()); + else if (_gesture == Gesture.Pinch) { _orbit.EndManualControl(); _app.Session.CommitCamera(_orbit.Snapshot()); } + _orbit.EndDrag(); _gesture = Gesture.None; _dragged = false; _previewDelta = Vector3.zero; _fingerId = -1; } public void BeginPinch(float distance, bool uiBlocked) { if (_splitting) CancelSplit(); else Cancel(); + if (!uiBlocked && AcceptInput) _orbit.TakeManualControl(); _gesture = uiBlocked || !AcceptInput ? Gesture.Blocked : Gesture.Pinch; _pinchDistance = distance; } @@ -158,6 +160,7 @@ namespace MagnetismHeroes.UnityRuntime.Interaction public void Cancel() { if (_gesture == Gesture.Friend) _presenter.CancelPreview(); + _orbit.EndDrag(); if ((_gesture == Gesture.Orbit || _gesture == Gesture.Pinch) && _app.Session != null) _orbit.Apply(_app.Session.Camera); _gesture = Gesture.None; _dragged = false; _previewDelta = Vector3.zero; _fingerId = -1; _waitForAllTouchesReleased = false; _pinchFirstId = _pinchSecondId = -1; @@ -272,7 +275,7 @@ namespace MagnetismHeroes.UnityRuntime.Interaction if (mouse.leftButton.isPressed) Move(position); if (mouse.leftButton.wasReleasedThisFrame) End(position); if (mouse.scroll.ReadValue().y != 0 && !IsUI(position)) - { _orbit.Zoom(Mathf.Exp(mouse.scroll.ReadValue().y * 0.001f)); _app.Session.CommitCamera(_orbit.Snapshot()); } + { if (_splitting) CancelSplit(); else Cancel(); _orbit.Zoom(Mathf.Exp(mouse.scroll.ReadValue().y * 0.001f)); _app.Session.CommitCamera(_orbit.Snapshot()); } } private void OnEnable() { diff --git a/Assets/MagnetismHeroes/UnityRuntime/Interaction/OrbitCamera.cs b/Assets/MagnetismHeroes/UnityRuntime/Interaction/OrbitCamera.cs index 4b499c4..18a5f42 100644 --- a/Assets/MagnetismHeroes/UnityRuntime/Interaction/OrbitCamera.cs +++ b/Assets/MagnetismHeroes/UnityRuntime/Interaction/OrbitCamera.cs @@ -1,4 +1,7 @@ +using System.Collections.Generic; +using System.Linq; using MagnetismHeroes.Core; +using MagnetismHeroes.UnityRuntime.Presentation; using UnityEngine; namespace MagnetismHeroes.UnityRuntime.Interaction @@ -9,28 +12,266 @@ namespace MagnetismHeroes.UnityRuntime.Interaction [SerializeField] private float _minDistance = 3; [SerializeField] private float _maxDistance = 15; [SerializeField] private float _degreesPerPixel = 0.25f; + [SerializeField] private float _minElevation = -89; + [SerializeField] private float _maxElevation = 89; + [SerializeField] private float _collisionMargin = .35f; + [SerializeField] private float _assistSpeed = 6; + [SerializeField] private float _assistAngle = 90; + [SerializeField] private float _assistElevation = 30; + [SerializeField] private float _nearbyDistance = 5; + [SerializeField] private float _candidateHysteresis = .4f; + [SerializeField] private float _screenMargin = .12f; private Vector3 _target; private Quaternion _rotation; private float _distance; + private BoardEnvironment _environment; + private BoardPresenter _dragPresenter; + private string _grabBall, _candidate; + private string[] _movingIds; + private Vector3 _grabOffset; + private Vector2 _pointer; + private bool _returning, _heldReturn, _manualPending; + private float _assistYaw; + private readonly List _assistPath = new List(); public Camera Camera => _camera; + public string TrackedFriend => _candidate; + public bool IsAssisting => _dragPresenter != null; + public bool IsReturning => _returning; + public void BindEnvironment(BoardEnvironment environment) => _environment = environment; public void Apply(CameraState state) { + _dragPresenter = null; _candidate = null; _returning = false; _heldReturn = false; _manualPending = false; + _assistPath.Clear(); _target = new Vector3(state.target.x, state.target.y, state.target.z); _rotation = new Quaternion(state.rotation.x, state.rotation.y, state.rotation.z, state.rotation.w); _distance = Mathf.Clamp(state.distance, _minDistance, _maxDistance); UpdateCamera(); } public void Orbit(Vector2 delta) { + if (delta == Vector2.zero) return; + TakeManualControl(); AdoptVisiblePose(); _rotation = Quaternion.AngleAxis(delta.x * _degreesPerPixel, Vector3.up) * _rotation * Quaternion.AngleAxis(-delta.y * _degreesPerPixel, Vector3.right); UpdateCamera(); } public void Zoom(float ratio) - { if (ratio > 0 && float.IsFinite(ratio)) { _distance = Mathf.Clamp(_distance / ratio, _minDistance, _maxDistance); UpdateCamera(); } } + { + if (ratio > 0 && float.IsFinite(ratio) && ratio != 1) + { TakeManualControl(); AdoptVisiblePose(); _distance = Mathf.Clamp(_distance / ratio, _minDistance, _maxDistance); UpdateCamera(); } + } + // Only the confirmed manual pose is eligible for persistence, including during assistance/return. public CameraState Snapshot() => new CameraState { target = new Position3(_target.x, _target.y, _target.z), rotation = new Rotation4(_rotation.x, _rotation.y, _rotation.z, _rotation.w), distance = _distance }; + + public void BeginDrag(BoardPresenter presenter, string ballId, IReadOnlyCollection movingIds, Vector3 grabOffset, Vector2 pointer) + { + // Interrupt a return at the visible pose. Begin never snaps before the first-hit grab. + if (!_returning && !_heldReturn) _assistPath.Clear(); + if (_assistPath.Count == 0) _assistPath.Add(new Pose(_camera.transform.position, _camera.transform.rotation)); + _returning = false; _heldReturn = false; _dragPresenter = presenter; _grabBall = ballId; + _movingIds = movingIds.ToArray(); _grabOffset = grabOffset; _pointer = pointer; _candidate = null; + } + public void DragPointer(Vector2 pointer) => _pointer = pointer; + public void SuspendReturn() { _heldReturn = _returning; _returning = false; } + public void EndDrag() + { + if (_dragPresenter == null && !_heldReturn) return; + _heldReturn = false; + _dragPresenter = null; _candidate = null; _returning = true; + } + public void TakeManualControl() + { + // Pinch/wheel/empty-space orbit takes ownership at the visible pose without persisting an auto pose. + _manualPending |= _dragPresenter != null || _returning || _heldReturn; + _dragPresenter = null; _candidate = null; _returning = false; _heldReturn = false; _assistPath.Clear(); + } + private void AdoptVisiblePose() + { + if (!_manualPending) return; + _rotation = _camera.transform.rotation; + _target = _camera.transform.position + _rotation * Vector3.forward * _distance - Vector3.up * .65f; + _manualPending = false; + } + public void EndManualControl() + { + if (!_manualPending) return; + _manualPending = false; _returning = true; + } + private Vector3 Pivot => _target + Vector3.up * .65f; + private float Ground => _environment != null && _environment.PlaygroundBounds.size != Vector3.zero ? _environment.PlaygroundBounds.min.y : -.45f; + private Vector3 Position(Quaternion rotation, float distance) => Pivot - rotation * Vector3.forward * distance; + private Quaternion SafeRotation(Quaternion rotation, float distance) + { + float elevation = Mathf.Asin(Mathf.Clamp((rotation * Vector3.back).y, -1, 1)) * Mathf.Rad2Deg; + float groundElevation = Mathf.Asin(Mathf.Clamp((Ground + _collisionMargin - Pivot.y) / distance, -1, 1)) * Mathf.Rad2Deg; + float corrected = Mathf.Clamp(elevation, Mathf.Max(_minElevation, groundElevation), _maxElevation); + var pitchAxis = Vector3.Cross(rotation * Vector3.back, Vector3.up).normalized; + if (pitchAxis.sqrMagnitude < .01f) pitchAxis = Vector3.ProjectOnPlane(rotation * Vector3.right, Vector3.up).normalized; + rotation = Quaternion.AngleAxis(corrected - elevation, pitchAxis) * rotation; + // Lift along the same orbit only as far as needed to clear large scenery; preserve valid roll/yaw. + for (float lift = 0; lift <= _maxElevation - corrected; lift += 2) + { + var candidate = Quaternion.AngleAxis(lift, pitchAxis) * rotation; + if (IsSafeCamera(Position(candidate, distance))) return candidate; + } + // A large prop can fill the entire requested orbit. Search nearby distances/yaws, validating each pose. + for (float distanceOffset = 1; distanceOffset <= _maxDistance - _minDistance; distanceOffset++) + foreach (float direction in new[] { 1f, -1f }) + { + float alternativeDistance = Mathf.Clamp(distance + distanceOffset * direction, _minDistance, _maxDistance); + for (float yaw = 0; yaw <= 180; yaw += 15) + foreach (float side in new[] { 1f, -1f }) + for (float pitch = Mathf.Max(corrected, 0); pitch <= _maxElevation; pitch += 5) + { + var candidate = Quaternion.AngleAxis(yaw * side, Vector3.up) + * Quaternion.AngleAxis(pitch - corrected, pitchAxis) * rotation; + if (!IsSafeCamera(Position(candidate, alternativeDistance))) continue; + _distance = alternativeDistance; return candidate; + } + } + // No exterior orbit exists: retain a previously visible safe pose, with no board mutation. + if (IsSafeCamera(_camera.transform.position)) + { + _target = _camera.transform.position + _camera.transform.forward * _distance - Vector3.up * .65f; + return _camera.transform.rotation; + } + // The board's aerial bounds can themselves be completely enclosed. An exterior viewpoint remains valid. + var bounds = _environment.PlaygroundBounds; + var exterior = bounds.max + Vector3.one * (_maxDistance + _collisionMargin); + foreach (var collider in _environment.GetComponentsInChildren()) + if (collider.enabled && collider.gameObject.activeInHierarchy && !collider.isTrigger) + exterior = Vector3.Max(exterior, collider.bounds.max + Vector3.one * (_maxDistance + _collisionMargin)); + var exteriorRotation = Quaternion.LookRotation(bounds.center - exterior); + _target = exterior + exteriorRotation * Vector3.forward * _distance - Vector3.up * .65f; + return exteriorRotation; + } + private bool IsSafeCamera(Vector3 point) + { + if (point.y < Ground + _collisionMargin - .001f) return false; + if (_environment == null) return true; + var overlapping = Physics.OverlapSphere(point, _collisionMargin, ~0, QueryTriggerInteraction.Ignore); + foreach (var collider in _environment.GetComponentsInChildren()) + { + if (!collider.enabled || !collider.gameObject.activeInHierarchy || collider.isTrigger) continue; + if (overlapping.Contains(collider)) return false; + if (collider is MeshCollider mesh && !mesh.convex && mesh.bounds.Contains(point) && InsideMesh(mesh, point)) return false; + } + return true; + } + private bool IsSafeCameraPath(Vector3 from, Vector3 to) + { + if (_environment == null || (to - from).sqrMagnitude < .000001f) return true; + foreach (var hit in Physics.SphereCastAll(from, _collisionMargin, (to - from).normalized, Vector3.Distance(from, to), ~0, QueryTriggerInteraction.Ignore)) + if (hit.collider.enabled && hit.collider.transform.IsChildOf(_environment.transform)) return false; + return true; + } + private static bool InsideMesh(MeshCollider mesh, Vector3 point) + { + bool backfaces = Physics.queriesHitBackfaces; int inside = 0; + try + { + foreach (var direction in new[] { new Vector3(1, .371f, .529f).normalized, new Vector3(.217f, 1, .613f).normalized, new Vector3(.419f, .293f, 1).normalized }) + { + Physics.queriesHitBackfaces = false; int entries = Crossings(mesh, point, direction); + Physics.queriesHitBackfaces = true; + if (Crossings(mesh, point, direction) > entries * 2) inside++; + } + } + finally { Physics.queriesHitBackfaces = backfaces; } + return inside >= 2; + } + private static int Crossings(MeshCollider mesh, Vector3 point, Vector3 direction) + { + int count = 0; float remaining = mesh.bounds.size.magnitude + 1; + while (remaining > 0 && mesh.Raycast(new Ray(point, direction), out var hit, remaining)) + { count++; float step = hit.distance + .001f; point += direction * step; remaining -= step; } + return count; + } private void UpdateCamera() - { _camera.transform.SetPositionAndRotation(_target + Vector3.up * .65f + _rotation * new Vector3(0, 0, -_distance), _rotation); } + { + _rotation = SafeRotation(_rotation, _distance); + _camera.transform.SetPositionAndRotation(Position(_rotation, _distance), _rotation); + } + private void RecordPath(Vector3 position, Quaternion rotation) + { + var last = _assistPath[_assistPath.Count - 1]; + if (Vector3.Distance(last.position, position) < .02f && Quaternion.Angle(last.rotation, rotation) < .2f) return; + var pose = new Pose(position, rotation); + // Collinear/clear stretches occupy one segment, even during a long drag or stationary camera easing. + if (_assistPath.Count > 1 && IsSafeCameraPath(_assistPath[_assistPath.Count - 2].position, position)) _assistPath[_assistPath.Count - 1] = pose; + else _assistPath.Add(pose); + } + private void LateUpdate() + { + if (_dragPresenter != null) + { + if (!_dragPresenter.Balls.TryGetValue(_grabBall, out var grabbed)) { EndDrag(); return; } + var moving = new Bounds(grabbed.transform.position, Vector3.one); + foreach (var id in _movingIds) moving.Encapsulate(new Bounds(_dragPresenter.Balls[id].transform.position, Vector3.one)); + string previousCandidate = _candidate; + bool nearby = _dragPresenter.TryGetNearbyGroup(_movingIds, _candidate, _nearbyDistance, _candidateHysteresis, out _candidate, out var friend); + var framing = moving; if (nearby) framing.Encapsulate(friend); + var rotation = _rotation; + if (nearby) + { + var separation = friend.center - moving.center; + // A stable oblique direction exposes front/back overlap without changing the drag's actual geometry. + var flatSeparation = Vector3.ProjectOnPlane(separation, Vector3.up); + var flatRight = Vector3.ProjectOnPlane(_rotation * Vector3.right, Vector3.up); + float yaw = Vector3.SignedAngle(flatRight, flatSeparation, Vector3.up); + // Facing either direction along the same screen plane reveals the pair; keep the nearer rotation. + if (yaw > 90) yaw -= 180; else if (yaw < -90) yaw += 180; + yaw = Mathf.Clamp(yaw, -_assistAngle, _assistAngle); + _assistYaw = _candidate != previousCandidate ? yaw : Mathf.LerpAngle(_assistYaw, yaw, 1 - Mathf.Exp(-_assistSpeed * Time.unscaledDeltaTime)); + rotation = Quaternion.AngleAxis(_assistYaw, Vector3.up) * _rotation; + } + float elevation = Mathf.Asin(Mathf.Clamp((rotation * Vector3.back).y, -1, 1)) * Mathf.Rad2Deg; + rotation = Quaternion.AngleAxis(Mathf.Max(0, _assistElevation - elevation), Vector3.Cross(rotation * Vector3.back, Vector3.up).normalized) * rotation; + float distance = Mathf.Max(_distance, framing.extents.magnitude / Mathf.Tan(_camera.fieldOfView * Mathf.Deg2Rad * .5f) / (1 - _screenMargin * 2)); + distance = Mathf.Clamp(distance, _minDistance, _maxDistance); + var desiredPosition = framing.center - rotation * Vector3.forward * distance; + float blend = 1 - Mathf.Exp(-_assistSpeed * Time.unscaledDeltaTime); + rotation = Quaternion.Slerp(_camera.transform.rotation, rotation, blend); + var position = Vector3.Lerp(_camera.transform.position, desiredPosition, blend); + // Pan the visible camera so the actual grabbed surface point remains under this pointer. + var anchor = grabbed.transform.position + _grabOffset; + var oldPosition = _camera.transform.position; var oldRotation = _camera.transform.rotation; + _camera.transform.SetPositionAndRotation(position, rotation); + var ray = _camera.ScreenPointToRay(_pointer); + var plane = new Plane(rotation * Vector3.forward, anchor); + if (plane.Raycast(ray, out var depth)) position += anchor - ray.GetPoint(depth); + if (IsSafeCamera(position) && IsSafeCameraPath(oldPosition, position)) + { _camera.transform.SetPositionAndRotation(position, rotation); RecordPath(position, rotation); } + else _camera.transform.SetPositionAndRotation(oldPosition, oldRotation); + } + else if (_returning) + { + var desired = Position(_rotation, _distance); var desiredRotation = _rotation; + if (!IsSafeCameraPath(_camera.transform.position, desired)) + { + // Replay only the path this camera actually traversed; shorten it with verified safe shortcuts. + for (int index = 0; index < _assistPath.Count; index++) + { + var pose = _assistPath[index]; + if (!IsSafeCameraPath(_camera.transform.position, pose.position)) continue; + desired = pose.position; desiredRotation = pose.rotation; + if (Vector3.Distance(_camera.transform.position, desired) < .02f) + { + // Reach the tested waypoint before discarding it; a near-corner shortcut can still be blocked. + _camera.transform.SetPositionAndRotation(desired, desiredRotation); + _assistPath.RemoveRange(index, _assistPath.Count - index); + } + break; + } + } + float blend = 1 - Mathf.Exp(-_assistSpeed * Time.unscaledDeltaTime); + var position = Vector3.Lerp(_camera.transform.position, desired, blend); + var rotation = Quaternion.Slerp(_camera.transform.rotation, desiredRotation, blend); + if (IsSafeCamera(position) && IsSafeCameraPath(_camera.transform.position, position)) _camera.transform.SetPositionAndRotation(position, rotation); + if (Vector3.Distance(_camera.transform.position, Position(_rotation, _distance)) < .001f && Quaternion.Angle(_camera.transform.rotation, _rotation) < .01f) + { _returning = false; _assistPath.Clear(); UpdateCamera(); } + } + } } } diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardPresenter.cs b/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardPresenter.cs index 94a76cd..6f20c55 100644 --- a/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardPresenter.cs +++ b/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardPresenter.cs @@ -27,6 +27,37 @@ namespace MagnetismHeroes.UnityRuntime.Presentation public string MergeCandidate { get; private set; } public Vector3 DropDelta { get; private set; } + public bool TryGetNearbyGroup(IReadOnlyCollection movingIds, string previous, float range, float hysteresis, out string candidate, out Bounds bounds) + { + candidate = null; bounds = default; + if (_environment == null || movingIds.Count == 0) return false; + Physics.SyncTransforms(); + var moving = movingIds.Select(id => _balls[id].transform.position).ToArray(); + float nearest = range, previousDistance = float.PositiveInfinity; + foreach (var group in _balls.Values.Where(ball => !movingIds.Contains(ball.BallId)).GroupBy(ball => ball.GroupId)) + { + float distance = float.PositiveInfinity; Vector3 source = default, target = default; + foreach (var point in moving) + foreach (var other in group) + { + float separation = Vector3.Distance(point, other.transform.position); + if (separation < distance) { distance = separation; source = point; target = other.transform.position; } + } + if (distance > range) continue; + var approach = (target - source).normalized * Mathf.Max(0, distance - 1); + if ((_environment.ConstrainTranslation(moving, approach, transform) - approach).sqrMagnitude > .000001f) continue; + if (group.Key == previous) previousDistance = distance; + if (distance < nearest) { nearest = distance; candidate = group.Key; } + } + if (previousDistance <= range && previousDistance <= nearest + hysteresis) candidate = previous; + if (candidate == null) return false; + string selectedCandidate = candidate; + var members = _balls.Values.Where(ball => ball.GroupId == selectedCandidate && !movingIds.Contains(ball.BallId)).ToArray(); + bounds = new Bounds(members[0].transform.position, Vector3.one); + foreach (var member in members) bounds.Encapsulate(new Bounds(member.transform.position, Vector3.one)); + return true; + } + public void Render(BoardRules board) { bool transformed = board != null && _balls.Count == board.BallCount && _badges.Count != board.Groups.Length @@ -122,7 +153,7 @@ namespace MagnetismHeroes.UnityRuntime.Presentation if (closest >= .999f && closest <= 1.001f) { DropDelta += correction; - foreach (var ball in moving) ball.transform.position += correction; + foreach (var ball in moving) ball.transform.position = ToVector(_board.Balls.First(state => state.id == ball.BallId).position) + DropDelta; MergeCandidate = target.GroupId; source.ShowContact(target.transform.position); target.ShowContact(source.transform.position); }