diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/CompositionPlayTests.cs b/Assets/MagnetismHeroes/Tests/PlayMode/CompositionPlayTests.cs index 8cc7c9e..9d549f6 100644 --- a/Assets/MagnetismHeroes/Tests/PlayMode/CompositionPlayTests.cs +++ b/Assets/MagnetismHeroes/Tests/PlayMode/CompositionPlayTests.cs @@ -33,7 +33,7 @@ namespace MagnetismHeroes.Tests 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 SpyStore(); _app.Initialize(_store); _touchscreen = InputSystem.AddDevice(); yield return null; + _store = new SpyStore(); _app.Initialize(_store); _app.Presenter.BindEnvironment(_app.Environment); _touchscreen = InputSystem.AddDevice(); yield return null; } [UnityTearDown] public IEnumerator TearDown() { @@ -183,15 +183,42 @@ namespace MagnetismHeroes.Tests yield return new WaitForSecondsRealtime(.4f); Assert.That(_app.Presenter.Balls.Values.All(ball => !ball.transform.Find("ContactLight").GetComponent().enabled), Is.True); } + private void OrbitUntilOriginalBallsAreFirstHits(params string[] ids) + { + var start = new Vector2(Screen.width * .1f, Screen.height * .8f); + if (_app.Presenter.Camera.transform.forward.y > -.1f) + { _app.Input.Begin(start, false); _app.Input.Move(start - new Vector2(0, 200)); _app.Input.End(start - new Vector2(0, 200)); } + for (int pitch = 0; pitch < 2; pitch++) + { + for (int yaw = 0; yaw < 12; yaw++) + { + Physics.SyncTransforms(); + if (Mathf.Abs(Vector3.Dot(_app.Presenter.Balls[ids[0]].transform.position - _app.Presenter.Balls[ids[1]].transform.position, _app.Presenter.Camera.transform.forward)) < .02f && ids.All(id => + { + var point = Point(id); + return point.x > 0 && point.x < Screen.width && point.y > 0 && point.y < Screen.height + && Physics.Raycast(_app.Presenter.Camera.ScreenPointToRay(point), out var hit, 100) + && hit.collider == _app.Presenter.Balls[id].GetComponent(); + })) return; + _app.Input.Begin(start, false); _app.Input.Move(start + new Vector2(120, 0)); _app.Input.End(start + new Vector2(120, 0)); + } + if (pitch == 0) + { _app.Input.Begin(start, false); _app.Input.Move(start - new Vector2(0, 40)); _app.Input.End(start - new Vector2(0, 40)); } + } + Assert.Fail("The original requested IDs must be reachable by an above-ground empty-space orbit."); + } [UnityTest] public IEnumerator FourteenDisconnectedSelectionThenMergeIsAcceptedByFinalNumbers() { _app.StartStage(14); yield return null; Physics.SyncTransforms(); var group = _app.Session.Board.Groups[0]; + OrbitUntilOriginalBallsAreFirstHits(group.ballIds[0], group.ballIds[2]); Tap(group.ballIds[0]); _app.Input.BeginSplit(); Tap(group.ballIds[0]); Tap(group.ballIds[2]); Assert.That(_app.Input.SelectedCount, Is.EqualTo(2)); var point = Point(group.ballIds[0]); var endpoint = point + new Vector2(0, 65); _app.Input.Begin(point, false); _app.Input.Move(endpoint); _app.Input.End(endpoint); yield return null; Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle)); Assert.That(_app.Session.Board.Groups.Select(value => value.ballIds.Length).OrderBy(value => value), Is.EqualTo(new[] { 1, 1, 2 })); - var singles = _app.Session.Board.Groups.Where(value => value.ballIds.Length == 1).ToArray(); var writes = _store.Writes; + Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True); + var singles = _app.Session.Board.Groups.Where(value => value.ballIds.Length == 1).ToArray(); + OrbitUntilOriginalBallsAreFirstHits(singles.Select(value => value.ballIds[0]).ToArray()); var writes = _store.Writes; point = Point(singles[0].ballIds[0]); endpoint = Point(singles[1].ballIds[0]) - new Vector2(35, 0); _app.Input.Begin(point, false); _app.Input.Move(endpoint); _app.Input.End(endpoint); yield return null; Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Writes, Is.EqualTo(writes + 1)); diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs b/Assets/MagnetismHeroes/Tests/PlayMode/FirstPuzzlePlayTests.cs index edf679f..699c33b 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); yield return null; + _store = new MemoryStore(); _app.Initialize(_store); _app.Presenter.BindEnvironment(_app.Environment); yield return null; } [UnityTearDown] public IEnumerator TearDown() { @@ -288,7 +288,7 @@ namespace MagnetismHeroes.Tests Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved)); } - [UnityTest] public IEnumerator BackgroundInteriorDropIsRejectedAndSurfaceContactRemainsPlayable() + [UnityTest] public IEnumerator BackgroundInteriorDragStopsAndCommitsLastSafeSurfacePosition() { _app.StartStage(1); yield return null; DragTo(new Vector3(1.8f, 0.8f, 0)); yield return null; Assert.That(_app.Session.Board.CollectedBallIds.Length, Is.EqualTo(1)); @@ -298,9 +298,12 @@ namespace MagnetismHeroes.Tests Assert.That(initial.y, Is.EqualTo(0.65f).Within(0.001)); Assert.That(initial.z, Is.EqualTo(1.1f).Within(0.001)); BallPoint(); var saved = JsonUtility.ToJson(_store.Saved); var camera = _app.Orbit.Snapshot(); DragTo(new Vector3(1.8f, -0.4f, 1.1f)); yield return null; - Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(initial), "The scene interior must not become a committed drop."); - Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.EqualTo(new Vector3(initial.x, initial.y, initial.z))); - Assert.That(_app.Orbit.Snapshot().rotation, Is.EqualTo(camera.rotation)); Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved)); + var stopped = _app.Session.Board.Balls.Single().position; + Assert.That(stopped.y, Is.LessThan(initial.y)); + Assert.That(stopped.y, Is.GreaterThanOrEqualTo(_app.Environment.PlaygroundBounds.min.y + .499f)); + Assert.That(_app.Presenter.IsSafePosition(stopped), Is.True); + Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.EqualTo(BoardPresenter.ToVector(stopped))); + Assert.That(_app.Orbit.Snapshot().rotation, Is.EqualTo(camera.rotation)); Assert.That(JsonUtility.ToJson(_store.Saved), Is.Not.EqualTo(saved)); Physics.SyncTransforms(); BallPoint(); } @@ -445,13 +448,19 @@ namespace MagnetismHeroes.Tests InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 41, phase = UnityEngine.InputSystem.TouchPhase.Ended, position = new Vector2(-1, point.y) }); yield return null; yield return null; Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(initial)); Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty); } - [UnityTest] public IEnumerator FlatFloorColliderMatchesTheVisibleMeshAndKeepsTheFriendReachable() + [UnityTest] public IEnumerator VisibleClearingColliderMatchesItsMeshAndHiddenLegacyFloorDoesNotBlock() { _app.StartStage(1); yield return null; Physics.SyncTransforms(); var floor = _app.transform.Find("Playground/PlaygroundIsland"); - Assert.That(floor.GetComponent(), Is.TypeOf()); - Assert.That(floor.GetComponent().bounds.max.y, Is.EqualTo(floor.GetComponent().bounds.max.y).Within(0.01)); - Assert.That(floor.GetComponent().bounds.max.y, Is.LessThan(0)); BallPoint(); + Assert.That(floor.GetComponent() == null, Is.True, "The hidden legacy floor must have no collider."); + var clearing = _app.Environment.GetComponentsInChildren().Single(collider => collider.name == "MeadowClearing"); + Assert.That(clearing.enabled && !clearing.isTrigger, Is.True); + Assert.That(clearing.sharedMesh, Is.SameAs(clearing.GetComponent().sharedMesh)); + var ray = new Ray(new Vector3(0, .1f, 0), Vector3.down); + Assert.That(clearing.Raycast(ray, out var surface, 2), Is.True); + Assert.That(Physics.Raycast(ray, out var actual, 2), Is.True); + Assert.That(actual.collider, Is.SameAs(clearing)); Assert.That(actual.point, Is.EqualTo(surface.point)); + Assert.That(surface.point.y, Is.LessThan(0)); BallPoint(); } } } diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundSafetyPlayTests.cs b/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundSafetyPlayTests.cs new file mode 100644 index 0000000..ec99ff2 --- /dev/null +++ b/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundSafetyPlayTests.cs @@ -0,0 +1,386 @@ +#if UNITY_EDITOR +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using MagnetismHeroes.Core; +using MagnetismHeroes.UnityRuntime.Bootstrap; +using MagnetismHeroes.UnityRuntime.Presentation; +using NUnit.Framework; +using UnityEditor; +using Unity.Collections; +using UnityEngine; +using UnityEngine.TestTools; + +namespace MagnetismHeroes.Tests +{ + public sealed class PlaygroundSafetyPlayTests + { + 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 List _obstacles = new 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(); + // Explicit test wiring until the single GameApp writer connects this production reference. + _app.Presenter.BindEnvironment(_app.Environment); + // Keep the real clearing; isolate movement cases from the unrelated authored props. + foreach (var collider in _app.Environment.GetComponentsInChildren()) + if (!collider.name.Contains("Clearing")) collider.enabled = false; + yield return null; + } + [UnityTearDown] public IEnumerator Teardown() + { + foreach (var obstacle in _obstacles) Object.Destroy(obstacle); + _obstacles.Clear(); Object.Destroy(_app.gameObject); yield return null; + } + private void Inject(Vector3[] positions, params int[] sizes) + { + // Boundary state injection, not normal stage progression: free-play balls/groups/camera only. + var balls = positions.Select((point, index) => new BallState { id = "safety-" + index, position = BoardPresenter.ToPosition(point) }).ToArray(); + int offset = 0; + var groups = sizes.Select((size, index) => { var ids = balls.Skip(offset).Take(size).Select(ball => ball.id).ToArray(); offset += size; return new GroupState { id = "group-" + index, ballIds = ids }; }).ToArray(); + _store.Save.latestFreePlay = new FreePlayState { sourceStageId = 6, balls = balls, groups = groups, camera = _app.Session.Camera }; + _app.Initialize(_store); _app.ContinueFreePlay(); _app.Voice.Clear(); Physics.SyncTransforms(); + } + private GameObject Wall(Vector3 center, Vector3 size, Quaternion rotation) + { + var wall = new GameObject("test-only-thin-obstacle"); wall.transform.SetPositionAndRotation(center, rotation); + wall.AddComponent().size = size; _obstacles.Add(wall); Physics.SyncTransforms(); return wall; + } + private Vector2 Point(string id) => _app.Presenter.Camera.WorldToScreenPoint(_app.Presenter.Balls[id].transform.position); + private void AssertUnchanged(string snapshot) => Assert.That(JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera)), Is.EqualTo(snapshot)); + + [UnityTest] public IEnumerator FastThinWallDragStopsThenReversesImmediatelyAndCommitsLastPreview() + { + Inject(new[] { new Vector3(0, 2, 0) }, 1); + var camera = _app.Presenter.Camera; var initial = _app.Presenter.Balls["safety-0"].transform.position; + Wall(initial + camera.transform.right * .8f, new Vector3(.02f, 12, 12), camera.transform.rotation); + var start = Point("safety-0"); var destination = start + new Vector2(150, 0); + _app.Input.Begin(start, false); _app.Input.Move(destination); + var stopped = _app.Presenter.Balls["safety-0"].transform.position; + float movement = Vector3.Dot(stopped - initial, camera.transform.right); + Assert.That(movement, Is.InRange(.1f, .291f), "A fast pointer move must sweep the sphere, not just test its endpoint."); + _app.Input.Move(destination - new Vector2(12, 0)); + var reversed = _app.Presenter.Balls["safety-0"].transform.position; + Assert.That(Vector3.Dot(reversed - stopped, camera.transform.right), Is.LessThan(-.01f), "Reverse input must act at the stopped point without consuming the blocked delta."); + _app.Input.End(destination - new Vector2(12, 0)); yield return null; + Assert.That(BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position), Is.EqualTo(reversed)); + Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo("safety-0")); + } + [UnityTest] public IEnumerator FastTenSphereTranslationStopsForTheLeadingMemberAndCommitsOneUnchangedShape() + { + var positions = Enumerable.Range(0, 10).Select(index => new Vector3(index % 5 - 2, 2 + index / 5, 0)).ToArray(); + Inject(positions, 10); Wall(new Vector3(3, 2, 0), new Vector3(.02f, 12, 12), Quaternion.identity); + _app.Presenter.PreviewGroup("group-0", Vector3.right * 20); + var delta = _app.Presenter.DropDelta; + Assert.That(delta.x, Is.InRange(.48f, .491f)); + var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position); + _app.CommitMove("group-0", BoardPresenter.ToPosition(delta)); yield return null; + Assert.That(_app.Session.Board.BallCount, Is.EqualTo(10)); + Assert.That(_app.Session.Board.Groups.Single().ballIds, Is.EquivalentTo(preview.Keys)); + foreach (var ball in _app.Session.Board.Balls) + { + Assert.That(BoardPresenter.ToVector(ball.position), Is.EqualTo(preview[ball.id])); + int index = int.Parse(ball.id.Substring(7)); Assert.That(preview[ball.id], Is.EqualTo(positions[index] + delta)); + } + } + [UnityTest] public IEnumerator SingleAndTenSpheresStopAtAllSixFiniteBoundsWithoutChangingShape() + { + foreach (int count in new[] { 1, 10 }) + foreach (var direction in new[] { Vector3.left, Vector3.right, Vector3.forward, Vector3.back, Vector3.up, Vector3.down }) + { + var positions = Enumerable.Range(0, count).Select(index => new Vector3(index % 5 - 2, 2 + index / 5, 0)).ToArray(); + Inject(positions, count); _app.Presenter.PreviewGroup("group-0", direction * 40); + var delta = _app.Presenter.DropDelta; + Assert.That(delta.magnitude, Is.GreaterThan(.1f)); Assert.That(delta.magnitude, Is.LessThan(40)); + var bounds = _app.Environment.PlaygroundBounds; + foreach (var ball in _app.Presenter.Balls.Values) + { + var point = ball.transform.position; + Assert.That(point.x - .5f, Is.GreaterThanOrEqualTo(bounds.min.x - .002f)); + Assert.That(point.x + .5f, Is.LessThanOrEqualTo(bounds.max.x + .002f)); + Assert.That(point.y - .5f, Is.GreaterThanOrEqualTo(bounds.min.y - .002f)); + Assert.That(point.y + .5f, Is.LessThanOrEqualTo(bounds.max.y + .002f)); + Assert.That(point.z - .5f, Is.GreaterThanOrEqualTo(bounds.min.z - .002f)); + Assert.That(point.z + .5f, Is.LessThanOrEqualTo(bounds.max.z + .002f)); + var index = int.Parse(ball.BallId.Substring(7)); + Assert.That(Vector3.Distance(point, positions[index] + delta), Is.LessThan(.0001f)); + } + Assert.That(_app.Presenter.CanDropGroup("group-0"), Is.True); + _app.Presenter.CancelPreview(); yield return null; + } + } + [UnityTest] public IEnumerator ActualGroundStopsTheFullRadiusAndSmallFoliageAllowsMovement() + { + Inject(new[] { new Vector3(0, 2, 0) }, 1); + Assert.That(Physics.Raycast(new Vector3(0, 2, 0), Vector3.down, out var ground, 10), Is.True); + _app.Presenter.PreviewGroup("group-0", Vector3.down * 20); + Assert.That(_app.Presenter.Balls["safety-0"].transform.position.y, Is.GreaterThanOrEqualTo(ground.point.y + .499f)); + Assert.That(_app.Presenter.DropDelta.y, Is.LessThan(-.1f)); _app.Presenter.CancelPreview(); + var foliage = Object.Instantiate(AssetDatabase.LoadAssetAtPath("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/GrassTuftMint.prefab")); + foliage.transform.position = new Vector3(1, 2, 0); _obstacles.Add(foliage); + Assert.That(foliage.GetComponentsInChildren(), Is.Empty); + _app.Presenter.PreviewGroup("group-0", Vector3.right * 2); + Assert.That(_app.Presenter.DropDelta, Is.EqualTo(Vector3.right * 2)); yield return null; + } + [UnityTest] public IEnumerator ContactCorrectionCannotCrossThinObstacleOrOfferFalseDepthMerge() + { + Inject(new[] { new Vector3(-1, 2, 0), new Vector3(1, 2, 0) }, 1, 1); + Wall(new Vector3(0, 2, 0), new Vector3(.02f, 10, 10), Quaternion.identity); + _app.Presenter.PreviewGroup("group-0", Vector3.right * 2); + Assert.That(_app.Presenter.Balls["safety-0"].transform.position.x, Is.LessThanOrEqualTo(-.509f)); + Assert.That(_app.Presenter.MergeCandidate, Is.Null); + _app.Presenter.CancelPreview(); _obstacles[0].SetActive(false); + Inject(new[] { new Vector3(0, 2, 0), new Vector3(0, 2, 2) }, 1, 1); + _app.Presenter.PreviewGroup("group-0", Vector3.right * .1f); + Assert.That(_app.Presenter.MergeCandidate, Is.Null, "Projected overlap is not three-dimensional contact."); yield return null; + } + [UnityTest] public IEnumerator SafePointerMoveRejectsContactSnapIntoAThinWall() + { + Inject(new[] { new Vector3(-1.2f, 2, 0), new Vector3(0, 2, 0) }, 1, 1); + Wall(new Vector3(-.6f, 2, 0), new Vector3(.02f, 10, 10), Quaternion.identity); + _app.Presenter.PreviewGroup("group-0", Vector3.right * .01f); + Assert.That(_app.Presenter.DropDelta.x, Is.EqualTo(.01f).Within(.0001f)); + Assert.That(_app.Presenter.MergeCandidate, Is.Null, "The contact snap must sweep and reject wall penetration even when the pointer path is safe."); + Assert.That(_app.Presenter.Balls.Values.All(ball => !ball.ContactCandidate), Is.True); yield return null; + } + private void SelectSplitBall(string id) + { + var point = Point(id); _app.Input.Begin(point, false); _app.Input.End(point); + _app.Input.BeginSplit(); _app.Input.Begin(point, false); _app.Input.End(point); + Assert.That(_app.Input.SelectedIds, Does.Contain(id)); + } + [UnityTest] public IEnumerator SplitFirstClearanceJumpCannotTeleportThroughAThinWall() + { + var positions = Enumerable.Range(0, 10).Select(index => new Vector3(index % 5 - 2, 2 + index / 5, 0)).ToArray(); + Inject(positions, 10); Wall(new Vector3(-4.9f, 2, 0), new Vector3(.02f, 12, 12), Quaternion.identity); + Assert.That(_app.Session.Board.PreviewSplit("group-0", new[] { "safety-0" }, new Position3(-.5f, 0, 0)).All(ball => _app.Presenter.IsSafePosition(ball.position)), Is.True, "The Core final arrays are safe; only their drag path crosses the wall."); + SelectSplitBall("safety-0"); + var snapshot = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera)); var writes = _store.Writes; + var point = Point("safety-0"); _app.Input.Begin(point, false); _app.Input.Move(point - new Vector2(30, 0)); + Assert.That(_app.Presenter.DropDelta, Is.EqualTo(Vector3.zero)); + Assert.That(_app.Presenter.CanDropGroup("group-0"), Is.False); + _app.Input.End(point - new Vector2(30, 0)); yield return null; + AssertUnchanged(snapshot); Assert.That(_store.Writes, Is.EqualTo(writes)); + foreach (var ball in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[ball.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(ball.position))); + } + [UnityTest] public IEnumerator SplitBlockedPathKeepsExactValidDeltaThenReversesAndCommitsItsPreview() + { + Inject(new[] { new Vector3(-3, 2, 0), new Vector3(-2, 2, 0), new Vector3(0, 2, 0) }, 3); + Wall(new Vector3(1.2f, 2, 0), new Vector3(.02f, 12, 12), Quaternion.identity); SelectSplitBall("safety-2"); + var point = Point("safety-2"); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(20, 0)); + Assert.That(_app.Presenter.CanDropGroup("group-0"), Is.True); + var validDelta = _app.Presenter.DropDelta; var validPoint = _app.Presenter.Balls["safety-2"].transform.position; + _app.Input.Move(point + new Vector2(140, 0)); + Assert.That(_app.Presenter.DropDelta, Is.EqualTo(validDelta)); + Assert.That(_app.Presenter.Balls["safety-2"].transform.position, Is.EqualTo(validPoint)); + _app.Input.Move(point + new Vector2(128, 0)); + var reverse = _app.Presenter.Balls["safety-2"].transform.position; + Assert.That(reverse.x, Is.LessThan(validPoint.x - .01f)); + var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position); + _app.Input.End(point + new Vector2(128, 0)); yield return null; + Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(2)); + foreach (var ball in _app.Session.Board.Balls) Assert.That(BoardPresenter.ToVector(ball.position), Is.EqualTo(preview[ball.id])); + } + [UnityTest] public IEnumerator SplitRejectsTheSelectedHalfBeyondEveryBoundary() + { + foreach (var direction in new[] { Vector3.left, Vector3.right, Vector3.forward, Vector3.back, Vector3.up, Vector3.down }) + { + Inject(new[] { new Vector3(-1, 2, 0), new Vector3(0, 2, 0), new Vector3(1, 2, 0) }, 3); + var snapshot = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera)); + Assert.That(_app.Presenter.PreviewSplit("group-0", new[] { "safety-0" }, direction * 40), Is.False); + Assert.That(_app.Presenter.CanDropGroup("group-0"), Is.False); + foreach (var ball in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[ball.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(ball.position))); + AssertUnchanged(snapshot); yield return null; + } + } + [UnityTest] public IEnumerator ApprovedClosedRockDeepInteriorIsRejectedAndProbeDiagnosticsAreRecorded() + { + var prefab = AssetDatabase.LoadAssetAtPath("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/SlateBoulder.prefab"); + var rock = Object.Instantiate(prefab, _app.Environment.transform); _obstacles.Add(rock); + // Approved runtime mesh, synthetic enlarged pose to put the entire unit sphere deep inside. + rock.transform.localScale = Vector3.one * 3; + var collider = rock.GetComponentInChildren(); + Physics.SyncTransforms(); rock.transform.position += new Vector3(0, 3, 0) - collider.bounds.center; Physics.SyncTransforms(); + var point = collider.bounds.center; + var source = _app.Presenter.Balls.Values.First().GetComponent(); + bool penetration = Physics.ComputePenetration(source, point, Quaternion.identity, collider, collider.transform.position, collider.transform.rotation, out _, out float depth); + bool overlap = Physics.OverlapSphere(point, .5f, ~0, QueryTriggerInteraction.Ignore).Contains(collider); + Debug.Log($"Deep approved mesh {collider.name}: center={point:R} bounds={collider.bounds} overlap={overlap} penetration={penetration} depth={depth:R} safe={_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point))}"); + var wall = Wall(new Vector3(0, 2, 0), Vector3.one, Quaternion.identity).GetComponent(); + var diagnostic = new GameObject("test-only-probe-diagnostic"); _obstacles.Add(diagnostic); + var sphere = diagnostic.AddComponent(); sphere.radius = .5f; sphere.enabled = false; + bool disabledResult = Physics.ComputePenetration(sphere, wall.bounds.center, Quaternion.identity, wall, wall.transform.position, wall.transform.rotation, out _, out float disabledDepth); + sphere.enabled = true; sphere.isTrigger = true; diagnostic.layer = 2; Physics.SyncTransforms(); + bool triggerResult = Physics.ComputePenetration(sphere, wall.bounds.center, Quaternion.identity, wall, wall.transform.position, wall.transform.rotation, out _, out float triggerDepth); + Debug.Log($"Probe direct comparison: disabled={disabledResult} depth={disabledDepth:R}; liveTrigger={triggerResult} depth={triggerDepth:R}"); + bool backfaces = Physics.queriesHitBackfaces; + Assert.That(_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point)), Is.False, "Old-save safety must reject the deep interior of an approved closed rock mesh."); + Assert.That(Physics.queriesHitBackfaces, Is.EqualTo(backfaces)); + Physics.queriesHitBackfaces = !backfaces; + try + { + Assert.That(_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point)), Is.False); + Assert.That(Physics.queriesHitBackfaces, Is.EqualTo(!backfaces)); + } + finally { Physics.queriesHitBackfaces = backfaces; } + yield return null; + } + [UnityTest] public IEnumerator ApprovedArchHoleAcceptsFullSphereAndOverlappingTreeSolidsRejectInterior() + { + var arch = Object.Instantiate(AssetDatabase.LoadAssetAtPath("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/OpenStoneArch.prefab"), _app.Environment.transform); + _obstacles.Add(arch); arch.transform.localScale = Vector3.one * 2; + var shell = arch.GetComponentInChildren(); Physics.SyncTransforms(); + arch.transform.position += new Vector3(0, 3, 0) - shell.bounds.center; Physics.SyncTransforms(); + bool foundHole = false; + for (float y = Mathf.Max(.6f, shell.bounds.min.y + .55f); y < shell.bounds.max.y - .55f; y += .2f) + { + var point = new Vector3(shell.bounds.center.x, y, shell.bounds.center.z); + var start = point - Vector3.forward * (shell.bounds.extents.z + 1); + if (Physics.SphereCastAll(start, .5f, Vector3.forward, shell.bounds.size.z + 2, ~0, QueryTriggerInteraction.Ignore).Any(hit => hit.collider == shell)) continue; + Assert.That(_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point)), Is.True, "The actual visible arch opening must accept a whole sphere."); + foundHole = true; Debug.Log("Approved arch unit-sphere hole point=" + point); break; + } + Assert.That(foundHole, Is.True); arch.SetActive(false); + var tree = Object.Instantiate(AssetDatabase.LoadAssetAtPath("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/RoundCanopyTree.prefab"), _app.Environment.transform); + _obstacles.Add(tree); tree.transform.localScale = Vector3.one * 2; + var mesh = tree.GetComponentInChildren(); Physics.SyncTransforms(); + tree.transform.position += new Vector3(0, 3, 0) - mesh.bounds.center; Physics.SyncTransforms(); + // The approved tree includes intersecting closed crown volumes. Search its central volume for two enclosing pieces. + Vector3[] vertices; int[] triangles; + using (var readOnly = MeshUtility.AcquireReadOnlyMeshData(mesh.sharedMesh)) + { + var data = readOnly[0]; var buffer = new NativeArray(data.vertexCount, Allocator.Temp); + data.GetVertices(buffer); vertices = buffer.ToArray(); buffer.Dispose(); + triangles = data.indexFormat == UnityEngine.Rendering.IndexFormat.UInt16 + ? data.GetIndexData().ToArray().Select(index => (int)index).ToArray() + : data.GetIndexData().ToArray().Select(index => (int)index).ToArray(); + } + bool foundOverlap = false; + for (float y = Mathf.Max(.6f, mesh.bounds.min.y + .5f); y < Mathf.Min(5.8f, mesh.bounds.max.y - .5f); y += .2f) + { + var point = new Vector3(mesh.bounds.center.x, y, mesh.bounds.center.z); + int exits = 0, entries = 0; var direction = new Vector3(1, .371f, .529f).normalized; + for (int index = 0; index < triangles.Length; index += 3) + { + var a = mesh.transform.TransformPoint(vertices[triangles[index]]); + var b = mesh.transform.TransformPoint(vertices[triangles[index + 1]]); + var c = mesh.transform.TransformPoint(vertices[triangles[index + 2]]); + var edge1 = b - a; var edge2 = c - a; var cross = Vector3.Cross(direction, edge2); + float determinant = Vector3.Dot(edge1, cross); + if (Mathf.Abs(determinant) < .000001f) continue; + var offset = point - a; float u = Vector3.Dot(offset, cross) / determinant; + var q = Vector3.Cross(offset, edge1); float v = Vector3.Dot(direction, q) / determinant; + float t = Vector3.Dot(edge2, q) / determinant; + if (u < 0 || v < 0 || u + v > 1 || t <= .001f) continue; + if (Vector3.Dot(Vector3.Cross(edge1, edge2), direction) > 0) exits++; else entries++; + } + if (exits - entries < 2) continue; + foundOverlap = true; bool backfaces = Physics.queriesHitBackfaces; + Assert.That(_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point)), Is.False, "Overlapping approved closed tree parts are solid even with an even crossing count."); + Assert.That(Physics.queriesHitBackfaces, Is.EqualTo(backfaces)); + Debug.Log($"Approved tree overlapping solids point={point} entries={entries} exits={exits}"); break; + } + Assert.That(foundOverlap, Is.True, "The approved tree fixture must actually include two overlapping closed volumes."); yield return null; + } + [UnityTest] public IEnumerator EveryOriginalStageBoardIsSafeAgainstActualSceneryAndFullSphereBounds() + { + for (int stage = 1; stage <= 30; stage++) + { + _app.StartStage(stage); _app.Voice.Clear(); yield return null; + Physics.SyncTransforms(); + Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True, "Original authored board stage " + stage); + foreach (var ball in _app.Session.Board.Balls) + Assert.That(_app.Presenter.IsSafePosition(ball.position), Is.True, "Stage " + stage + " sphere " + ball.id + " at " + BoardPresenter.ToVector(ball.position)); + } + } + [UnityTest] public IEnumerator SafeTwoPlusThreeContactAndCommitPreserveAllIdsAndBothShapes() + { + var positions = new[] { new Vector3(-3, 2, 0), new Vector3(-2, 2, 0), new Vector3(1, 2, 0), new Vector3(2, 2, 0), new Vector3(3, 2, 0) }; + Inject(positions, 2, 3); _app.Presenter.PreviewGroup("group-0", Vector3.right * 2.2f); + Assert.That(_app.Presenter.MergeCandidate, Is.EqualTo("group-1")); + var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position); + _app.CommitMove("group-0", BoardPresenter.ToPosition(_app.Presenter.DropDelta), _app.Presenter.MergeCandidate); yield return null; + Assert.That(_app.Session.Board.Groups.Single().ballIds.Length, Is.EqualTo(5)); + foreach (var ball in _app.Session.Board.Balls) Assert.That(BoardPresenter.ToVector(ball.position), Is.EqualTo(preview[ball.id])); + Assert.That(Vector3.Distance(preview["safety-0"], preview["safety-1"]), Is.EqualTo(1).Within(.001f)); + } + [UnityTest] public IEnumerator SplitRejectsUnsafeUnselectedHalfAndRestoresTheCommittedBoard() + { + Inject(new[] { new Vector3(-2, 2, 0), new Vector3(0, 2, 0), new Vector3(2, 2, 0) }, 3); + // Rest's compact arrangement moves from (-2, 2) to (-.5, .5); block just that final half. + Wall(new Vector3(-.5f, 2, 0), new Vector3(.05f, .05f, .05f), Quaternion.identity); + var snapshot = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera)); + Assert.That(_app.Presenter.PreviewSplit("group-0", new[] { "safety-1" }, Vector3.up * 2), Is.False); + Assert.That(_app.Presenter.CanDropGroup("group-0"), Is.False); + foreach (var ball in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[ball.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(ball.position))); + AssertUnchanged(snapshot); yield return null; + } + [UnityTest] public IEnumerator SafeSplitBothHalvesMatchCommittedCoreArrangement() + { + Inject(new[] { new Vector3(-1, 2, 0), new Vector3(0, 2, 0), new Vector3(1, 2, 0) }, 3); + var delta = Vector3.up * 2; + Assert.That(_app.Presenter.PreviewSplit("group-0", new[] { "safety-0" }, delta), Is.True); + var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position); + Assert.That(_app.Presenter.CanDropGroup("group-0"), Is.True); + Assert.That(_app.CommitSplit("group-0", new[] { "safety-0" }, BoardPresenter.ToPosition(delta)), Is.True); yield return null; + Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(2)); + foreach (var ball in _app.Session.Board.Balls) Assert.That(BoardPresenter.ToVector(ball.position), Is.EqualTo(preview[ball.id])); + } + [UnityTest] public IEnumerator SupplyUsesFiniteSafeSpaceAndNoSpaceLeavesIdsAndSaveUntouched() + { + Inject(new[] { new Vector3(0, 2, 0) }, 1); + _app.SpawnFriend(); Assert.That(_app.Session.Board.BallCount, Is.EqualTo(2)); + var spawned = _app.Session.Board.Balls.Single(ball => ball.id != "safety-0"); var point = BoardPresenter.ToVector(spawned.position); + var bounds = _app.Environment.PlaygroundBounds; + Assert.That(bounds.Contains(point + Vector3.up * .5f) && bounds.Contains(point - Vector3.up * .5f), Is.True); + Wall(bounds.center, bounds.size * 2, Quaternion.identity); + var snapshot = JsonUtility.ToJson(_store.Save); var writes = _store.Writes; + _app.SpawnFriend(); Assert.That(_app.Session.Board.BallCount, Is.EqualTo(2)); + Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(snapshot)); Assert.That(_store.Writes, Is.EqualTo(writes)); + Assert.That(_app.Instruction, Does.Contain("빈 곳")); yield return null; + } + [UnityTest] public IEnumerator CancelUIOutsidePinchPauseAndNavigationRestoreIdsCountsAndPositions() + { + foreach (var mode in new[] { "cancel", "ui", "outside", "pinch", "pause", "navigation" }) + { + Inject(new[] { new Vector3(0, 2, 0) }, 1); + var snapshot = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera)); + var point = Point("safety-0"); _app.Input.Begin(point, mode == "ui"); _app.Input.Move(point + new Vector2(60, 0)); + switch (mode) + { + case "cancel": _app.Input.End(point, true); break; + case "ui": _app.Input.End(point); break; + case "outside": _app.Input.End(new Vector2(-1, point.y)); break; + case "pinch": _app.Input.BeginPinch(100, false); _app.Input.Cancel(); break; + case "pause": _app.Input.SendMessage("OnApplicationPause", true); break; + case "navigation": _app.ShowHome(); _app.ContinueFreePlay(); break; + } + AssertUnchanged(snapshot); + foreach (var ball in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[ball.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(ball.position))); + yield return null; + } + } + [UnityTest] public IEnumerator FrontPropBlocksSelectingTheSphereBehindIt() + { + Inject(new[] { new Vector3(0, 2, 0) }, 1); + var point = Point("safety-0"); var ray = _app.Presenter.Camera.ScreenPointToRay(point); + Wall(ray.GetPoint(Vector3.Distance(ray.origin, new Vector3(0, 2, 0)) - 1), new Vector3(.5f, .5f, .5f), Quaternion.identity); + var snapshot = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera)); + _app.Input.Begin(point, false); _app.Input.End(point); + Assert.That(_app.Input.SelectedGroup, Is.Null); AssertUnchanged(snapshot); yield return null; + } + } +} +#endif + diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundSafetyPlayTests.cs.meta b/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundSafetyPlayTests.cs.meta new file mode 100644 index 0000000..4aabace --- /dev/null +++ b/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundSafetyPlayTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8bc02646e4e7a3b478da6c1a9aa2023d \ No newline at end of file diff --git a/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs b/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs index e5c0f2a..5cbcfae 100644 --- a/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs +++ b/Assets/MagnetismHeroes/UnityRuntime/Interaction/BoardInput.cs @@ -87,9 +87,22 @@ namespace MagnetismHeroes.UnityRuntime.Interaction var ray = _orbit.Camera.ScreenPointToRay(point); if (_dragPlane.Raycast(ray, out var distance)) { - _previewDelta = ray.GetPoint(distance) - _grabPoint; - if (_splitting) { if (_splitDrag) _presenter.PreviewSplit(_groupId, _selected.ToArray(), _previewDelta); } - else _presenter.PreviewGroup(_groupId, _previewDelta); + 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; + } } } else if (_dragged && _gesture == Gesture.Orbit) _orbit.Orbit(point - _last); @@ -103,10 +116,15 @@ namespace MagnetismHeroes.UnityRuntime.Interaction { if (_dragged) { - if (!_presenter.CanDropGroup(_groupId)) { if (_splitting) CancelSplit(); else Cancel(); return; } + if (!_presenter.CanDropGroup(_groupId)) + { + if (_splitting && _selected.Count == _app.Session.Board.Groups.First(group => group.id == _groupId).ballIds.Length) Cancel(); + else if (_splitting) CancelSplit(); else Cancel(); + return; + } if (_splitting) { - if (_splitDrag && _app.CommitSplit(_groupId, _selected.ToArray(), BoardPresenter.ToPosition(_previewDelta))) + if (_splitDrag && _app.CommitSplit(_groupId, _selected.ToArray(), BoardPresenter.ToPosition(_presenter.DropDelta))) { _splitting = false; _selected.Clear(); _selectedGroup = null; } else _presenter.CancelPreview(); _app.RefreshSelection(); diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardEnvironment.cs b/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardEnvironment.cs index 04cb78b..158927a 100644 --- a/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardEnvironment.cs +++ b/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardEnvironment.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using MagnetismHeroes.UnityRuntime.StageAssets; using UnityEngine; @@ -6,12 +7,16 @@ namespace MagnetismHeroes.UnityRuntime.Presentation public sealed class BoardEnvironment : MonoBehaviour { private int _stageId; + [SerializeField] private float _collisionTolerance = .001f; + private const float BallRadius = .5f; + private SphereCollider _safetySphere; public string Theme { get; private set; } public Bounds PlaygroundBounds { get; private set; } public void Apply(StageAsset stage) { if (_stageId == stage.StageId) return; foreach (Transform child in transform) { child.gameObject.SetActive(false); Destroy(child.gameObject); } + _safetySphere = null; foreach (var placement in stage.Decorations) { if (placement.prefab.name == "Island" && transform.parent.Find("PlaygroundIsland") != null) continue; @@ -38,5 +43,95 @@ namespace MagnetismHeroes.UnityRuntime.Presentation if (!PlaygroundBounds.Intersects(collider.bounds)) collider.enabled = false; } } + + private bool IsObstacle(Collider collider, Transform friendsRoot) => collider.enabled && collider.gameObject.activeInHierarchy + && !collider.isTrigger && !collider.transform.IsChildOf(friendsRoot) && collider != _safetySphere; + + public bool IsSafeSphere(Vector3 point, Transform friendsRoot) + { + var margin = Vector3.one * (BallRadius - _collisionTolerance); + if (!PlaygroundBounds.Contains(point - margin) || !PlaygroundBounds.Contains(point + margin)) return false; + if (_safetySphere == null) + { + var probe = new GameObject("PlacementSafetySphere"); probe.transform.SetParent(transform, false); + // A trigger on Ignore Raycast supplies a live sphere shape for ComputePenetration. + // All safety queries ignore triggers and it cannot become a pointer target. + _safetySphere = probe.AddComponent(); _safetySphere.radius = BallRadius; _safetySphere.isTrigger = true; probe.layer = 2; + probe.transform.localScale = new Vector3(1 / transform.lossyScale.x, 1 / transform.lossyScale.y, 1 / transform.lossyScale.z); + } + foreach (var collider in Physics.OverlapSphere(point, BallRadius, ~0, QueryTriggerInteraction.Ignore)) + if (IsObstacle(collider, friendsRoot) && Physics.ComputePenetration(_safetySphere, point, Quaternion.identity, + collider, collider.transform.position, collider.transform.rotation, out _, out float depth) && depth > _collisionTolerance) + return false; + foreach (var mesh in GetComponentsInChildren()) + if (IsObstacle(mesh, friendsRoot) && !mesh.convex && mesh.bounds.Contains(point) && IsInsideSolidMesh(mesh, point)) return false; + return true; + } + + private bool IsInsideSolidMesh(MeshCollider mesh, Vector3 point) + { + // Triangle meshes do not report a sphere wholly inside a closed surface. + // Count oriented crossings: two overlapping closed pieces remain solid, while an arch hole stays empty. + bool originalBackfaces = Physics.queriesHitBackfaces; int insideDirections = 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 = SurfaceCrossings(mesh, point, direction); + Physics.queriesHitBackfaces = true; + int crossings = SurfaceCrossings(mesh, point, direction); + if (crossings > entries * 2) insideDirections++; + } + } + finally { Physics.queriesHitBackfaces = originalBackfaces; } + return insideDirections >= 2; + } + + private int SurfaceCrossings(MeshCollider mesh, Vector3 point, Vector3 direction) + { + float remaining = mesh.bounds.size.magnitude + 1; int crossings = 0; + float step = Mathf.Max(.0001f, _collisionTolerance); + while (remaining > 0 && mesh.Raycast(new Ray(point, direction), out var hit, remaining)) + { + crossings++; float advance = hit.distance + step; + point += direction * advance; remaining -= advance; + } + return crossings; + } + + public Vector3 ConstrainTranslation(IReadOnlyList positions, Vector3 requested, Transform friendsRoot) + { + Physics.SyncTransforms(); + float length = requested.magnitude; + if (length < .000001f) return Vector3.zero; + var direction = requested / length; float distance = length; + var min = PlaygroundBounds.min + Vector3.one * BallRadius; + var max = PlaygroundBounds.max - Vector3.one * BallRadius; + foreach (var point in positions) + { + for (int axis = 0; axis < 3; axis++) + if (Mathf.Abs(direction[axis]) > .000001f) + { + float edge = direction[axis] > 0 ? max[axis] : min[axis]; + distance = Mathf.Min(distance, Mathf.Max(0, (edge - point[axis]) / direction[axis])); + } + foreach (var hit in Physics.SphereCastAll(point, BallRadius, direction, length, ~0, QueryTriggerInteraction.Ignore)) + if (IsObstacle(hit.collider, friendsRoot)) distance = Mathf.Min(distance, Mathf.Max(0, hit.distance - _collisionTolerance)); + } + var translation = direction * distance; + // Numerical contact error must never turn a safe preview into an interior drop. + bool safe = true; + foreach (var point in positions) safe &= IsSafeSphere(point + translation, friendsRoot); + if (safe) return translation; + float low = 0, high = distance; + for (int iteration = 0; iteration < 20; iteration++) + { + float middle = (low + high) * .5f; safe = true; + foreach (var point in positions) safe &= IsSafeSphere(point + direction * middle, friendsRoot); + if (safe) low = middle; else high = middle; + } + return direction * low; + } } } diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardPresenter.cs b/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardPresenter.cs index fd57bc4..4fe7bc0 100644 --- a/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardPresenter.cs +++ b/Assets/MagnetismHeroes/UnityRuntime/Presentation/BoardPresenter.cs @@ -14,6 +14,14 @@ namespace MagnetismHeroes.UnityRuntime.Presentation private readonly Dictionary _balls = new Dictionary(); private readonly List _badges = new List(); private BoardRules _board; + private BoardEnvironment _environment; + private string _previewGroup; + private bool _previewValid = true; + private bool _splitPreview; + public void BindEnvironment(BoardEnvironment environment) => _environment = environment; + public bool IsSafePosition(Position3 position) => _environment != null && _environment.IsSafeSphere(ToVector(position), transform); + public bool IsSafeBoard(BoardRules board) + { Physics.SyncTransforms(); return board != null && board.Balls.All(ball => IsSafePosition(ball.position)); } public Camera Camera => _camera; public IReadOnlyDictionary Balls => _balls; public string MergeCandidate { get; private set; } @@ -45,7 +53,7 @@ namespace MagnetismHeroes.UnityRuntime.Presentation foreach (var view in _balls.Values) { view.gameObject.SetActive(false); Destroy(view.gameObject); } foreach (var badge in _badges) { badge.gameObject.SetActive(false); Destroy(badge.gameObject); } _balls.Clear(); _badges.Clear(); - MergeCandidate = null; DropDelta = Vector3.zero; + MergeCandidate = null; DropDelta = Vector3.zero; _previewGroup = null; _previewValid = true; _splitPreview = false; if (board == null) return; foreach (var group in board.Groups) { @@ -74,48 +82,81 @@ namespace MagnetismHeroes.UnityRuntime.Presentation public void PreviewGroup(string groupId, Vector3 delta) { - MergeCandidate = null; DropDelta = delta; + if (_previewGroup != groupId || _splitPreview) CancelPreview(); + var previousDelta = DropDelta; + _previewGroup = groupId; _splitPreview = false; MergeCandidate = null; + var members = _board.Groups.First(group => group.id == groupId).ballIds; + var positions = members.Select(id => ToVector(_board.Balls.First(ball => ball.id == id).position) + previousDelta).ToArray(); + DropDelta = previousDelta + (_environment == null ? Vector3.zero : _environment.ConstrainTranslation(positions, delta - previousDelta, transform)); foreach (var state in _board.Balls) - _balls[state.id].transform.position = ToVector(state.position) + (_balls[state.id].GroupId == groupId ? delta : Vector3.zero); + _balls[state.id].transform.position = ToVector(state.position) + (members.Contains(state.id) ? DropDelta : Vector3.zero); foreach (var ball in _balls.Values) ball.ContactCandidate = false; + var moving = members.Select(id => _balls[id]).ToArray(); + var stationary = _balls.Values.Where(ball => ball.GroupId != groupId).ToArray(); float closest = 1.35f; BallView source = null, target = null; - foreach (var moving in _balls.Values.Where(ball => ball.GroupId == groupId)) - foreach (var other in _balls.Values.Where(ball => ball.GroupId != groupId)) + foreach (var ball in moving) + foreach (var other in stationary) { - float distance = Vector3.Distance(moving.transform.position, other.transform.position); - if (distance < closest) { closest = distance; source = moving; target = other; } + float distance = Vector3.Distance(ball.transform.position, other.transform.position); + if (distance < closest) { closest = distance; source = ball; target = other; } } - if (target != null) + if (target != null && _environment != null) { - MergeCandidate = target.GroupId; var direction = source.transform.position - target.transform.position; - if (direction.sqrMagnitude < 0.0001f) direction = -_camera.transform.right; - DropDelta += direction.normalized * (1 - closest); - foreach (var ball in _balls.Values.Where(ball => ball.GroupId == groupId)) ball.transform.position += DropDelta - delta; - var moving = _balls.Values.Where(ball => ball.GroupId == groupId).ToArray(); - var fixedBalls = _balls.Values.Where(ball => ball.GroupId != groupId).ToArray(); - var correction = direction.normalized * ContactPlacement.Clearance(moving.Select(ball => ball.transform.position).ToArray(), fixedBalls.Select(ball => ball.transform.position).ToArray(), direction.normalized); - DropDelta += correction; - foreach (var ball in moving) ball.transform.position += correction; - closest = float.PositiveInfinity; - foreach (var ball in moving) - foreach (var other in fixedBalls) + if (direction.sqrMagnitude < .0001f) direction = -_camera.transform.right; + direction.Normalize(); + var correction = direction * (1 - closest); + var adjusted = moving.Select(ball => ball.transform.position + correction).ToArray(); + correction += direction * ContactPlacement.Clearance(adjusted, stationary.Select(ball => ball.transform.position).ToArray(), direction); + var safeCorrection = _environment.ConstrainTranslation(moving.Select(ball => ball.transform.position).ToArray(), correction, transform); + if ((safeCorrection - correction).sqrMagnitude < .00000001f) + { + // Only offer an actual unit-sphere contact after the entire correction path is safe. + closest = float.PositiveInfinity; + foreach (var ball in moving) + foreach (var other in stationary) + { + float distance = Vector3.Distance(ball.transform.position + correction, other.transform.position); + if (distance < closest) { closest = distance; source = ball; target = other; } + } + if (closest >= .999f && closest <= 1.001f) { - float distance = Vector3.Distance(ball.transform.position, other.transform.position); - if (distance < closest) { closest = distance; source = ball; target = other; } + DropDelta += correction; + foreach (var ball in moving) ball.transform.position += correction; + MergeCandidate = target.GroupId; + source.ShowContact(target.transform.position); target.ShowContact(source.transform.position); } - MergeCandidate = target.GroupId; - source.ShowContact(target.transform.position); target.ShowContact(source.transform.position); + } } + _previewValid = moving.All(ball => _environment != null && _environment.IsSafeSphere(ball.transform.position, transform)); PositionBadges(); } public bool PreviewSplit(string groupId, IReadOnlyList selected, Vector3 delta) { - CancelPreview(); + if (_previewGroup != groupId || !_splitPreview) CancelPreview(); + bool hadValidPreview = _previewValid && DropDelta != Vector3.zero; + _previewGroup = groupId; _splitPreview = true; var positions = _board.PreviewSplit(groupId, selected, ToPosition(delta)); - if (positions == null) return false; + Physics.SyncTransforms(); + if (positions == null || _environment == null || positions.Any(ball => !_environment.IsSafeSphere(ToVector(ball.position), transform))) + { + CancelPreview(); _previewGroup = groupId; _splitPreview = true; _previewValid = false; + return false; + } + foreach (var ball in positions.Where(ball => selected.Contains(ball.id))) + { + var start = _balls[ball.id].transform.position; + var movement = ToVector(ball.position) - start; + if ((_environment.ConstrainTranslation(new[] { start }, movement, transform) - movement).sqrMagnitude > .00000001f) + { + // Keep exactly the last Core-produced split and its input delta; never commit a clipped shape. + _previewValid = hadValidPreview; + return hadValidPreview; + } + } foreach (var ball in positions) _balls[ball.id].transform.position = ToVector(ball.position); + DropDelta = delta; _previewValid = true; PositionBadges(); return true; } @@ -127,7 +168,7 @@ namespace MagnetismHeroes.UnityRuntime.Presentation public void CancelPreview() { if (_board == null) return; - MergeCandidate = null; DropDelta = Vector3.zero; + MergeCandidate = null; DropDelta = Vector3.zero; _previewGroup = null; _previewValid = true; _splitPreview = false; foreach (var state in _board.Balls) { _balls[state.id].transform.position = ToVector(state.position); _balls[state.id].ContactCandidate = false; _balls[state.id].ClearPulse(); } foreach (var badge in _badges) badge.ClearReaction(); PositionBadges(); @@ -136,21 +177,9 @@ namespace MagnetismHeroes.UnityRuntime.Presentation public bool CanDropGroup(string groupId) { Physics.SyncTransforms(); - foreach (var ball in _balls.Values.Where(ball => ball.GroupId == groupId)) - { - var sphere = ball.GetComponent(); - var scale = sphere.transform.lossyScale; - float radius = sphere.radius * Mathf.Max(Mathf.Abs(scale.x), Mathf.Abs(scale.y), Mathf.Abs(scale.z)); - foreach (var blocker in Physics.OverlapSphere(sphere.transform.TransformPoint(sphere.center), radius, ~0, QueryTriggerInteraction.Ignore)) - { - // Board friends and their face/count decorations are not background obstacles. - if (blocker.transform.IsChildOf(transform) || blocker.attachedRigidbody != null) continue; - if (Physics.ComputePenetration(sphere, sphere.transform.position, sphere.transform.rotation, - blocker, blocker.transform.position, blocker.transform.rotation, out _, out float depth) && depth > 0.001f) - return false; - } - } - return true; + return _previewValid && _environment != null && _balls.Values + .Where(ball => _splitPreview || ball.GroupId == groupId) + .All(ball => _environment.IsSafeSphere(ball.transform.position, transform)); } public bool TryFindSpawnPosition(out Position3 position) @@ -164,7 +193,7 @@ namespace MagnetismHeroes.UnityRuntime.Presentation var viewport = _camera.WorldToViewportPoint(point); if (viewport.z <= 0 || viewport.x < .08f || viewport.x > .92f || viewport.y < .3f || viewport.y > .78f) continue; if (_board.Balls.Any(ball => Vector3.Distance(ToVector(ball.position), point) < 1.1f)) continue; - if (Physics.OverlapSphere(point, .55f, ~0, QueryTriggerInteraction.Ignore).Any(collider => !collider.transform.IsChildOf(transform))) continue; + if (_environment == null || !_environment.IsSafeSphere(point, transform)) continue; position = ToPosition(point); return true; } position = default; return false;