Keep playground drags and split previews on safe sphere paths

This commit is contained in:
NINI
2026-10-10 03:12:44 +09:00
parent a7439bd49b
commit 477bbcdda6
7 changed files with 625 additions and 59 deletions
@@ -33,7 +33,7 @@ namespace MagnetismHeroes.Tests
foreach (var listener in Object.FindObjectsByType<AudioListener>(FindObjectsSortMode.None)) listener.enabled = false;
foreach (var camera in Object.FindObjectsByType<Camera>(FindObjectsSortMode.None)) camera.enabled = false;
_app = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab")).GetComponent<GameApp>();
_store = new SpyStore(); _app.Initialize(_store); _touchscreen = InputSystem.AddDevice<Touchscreen>(); yield return null;
_store = new SpyStore(); _app.Initialize(_store); _app.Presenter.BindEnvironment(_app.Environment); _touchscreen = InputSystem.AddDevice<Touchscreen>(); 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<LineRenderer>().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<SphereCollider>();
})) 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));
@@ -48,7 +48,7 @@ namespace MagnetismHeroes.Tests
foreach (var camera in Object.FindObjectsByType<Camera>(FindObjectsSortMode.None)) camera.enabled = false;
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
Assert.That(prefab, Is.Not.Null); _app = Object.Instantiate(prefab).GetComponent<GameApp>();
_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<Collider>(), Is.TypeOf<MeshCollider>());
Assert.That(floor.GetComponent<Collider>().bounds.max.y, Is.EqualTo(floor.GetComponent<Renderer>().bounds.max.y).Within(0.01));
Assert.That(floor.GetComponent<Collider>().bounds.max.y, Is.LessThan(0)); BallPoint();
Assert.That(floor.GetComponent<Collider>() == null, Is.True, "The hidden legacy floor must have no collider.");
var clearing = _app.Environment.GetComponentsInChildren<MeshCollider>().Single(collider => collider.name == "MeadowClearing");
Assert.That(clearing.enabled && !clearing.isTrigger, Is.True);
Assert.That(clearing.sharedMesh, Is.SameAs(clearing.GetComponent<MeshFilter>().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();
}
}
}
@@ -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<GameObject> _obstacles = new List<GameObject>();
[UnitySetUp] public IEnumerator Setup()
{
foreach (var listener in Object.FindObjectsByType<AudioListener>(FindObjectsSortMode.None)) listener.enabled = false;
foreach (var camera in Object.FindObjectsByType<Camera>(FindObjectsSortMode.None)) camera.enabled = false;
_app = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab")).GetComponent<GameApp>();
_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<Collider>())
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<BoxCollider>().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<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/GrassTuftMint.prefab"));
foliage.transform.position = new Vector3(1, 2, 0); _obstacles.Add(foliage);
Assert.That(foliage.GetComponentsInChildren<Collider>(), 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<GameObject>("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<MeshCollider>();
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<SphereCollider>();
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<BoxCollider>();
var diagnostic = new GameObject("test-only-probe-diagnostic"); _obstacles.Add(diagnostic);
var sphere = diagnostic.AddComponent<SphereCollider>(); 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<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/OpenStoneArch.prefab"), _app.Environment.transform);
_obstacles.Add(arch); arch.transform.localScale = Vector3.one * 2;
var shell = arch.GetComponentInChildren<MeshCollider>(); 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<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/RoundCanopyTree.prefab"), _app.Environment.transform);
_obstacles.Add(tree); tree.transform.localScale = Vector3.one * 2;
var mesh = tree.GetComponentInChildren<MeshCollider>(); 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<Vector3>(data.vertexCount, Allocator.Temp);
data.GetVertices(buffer); vertices = buffer.ToArray(); buffer.Dispose();
triangles = data.indexFormat == UnityEngine.Rendering.IndexFormat.UInt16
? data.GetIndexData<ushort>().ToArray().Select(index => (int)index).ToArray()
: data.GetIndexData<uint>().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
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8bc02646e4e7a3b478da6c1a9aa2023d
@@ -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();
@@ -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<SphereCollider>(); _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<MeshCollider>())
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<Vector3> 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;
}
}
}
@@ -14,6 +14,14 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
private readonly Dictionary<string, BallView> _balls = new Dictionary<string, BallView>();
private readonly List<GroupBadge> _badges = new List<GroupBadge>();
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<string, BallView> 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))
{
float distance = Vector3.Distance(moving.transform.position, other.transform.position);
if (distance < closest) { closest = distance; source = moving; target = other; }
}
if (target != 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)
foreach (var other in stationary)
{
float distance = Vector3.Distance(ball.transform.position, other.transform.position);
if (distance < closest) { closest = distance; source = ball; target = other; }
}
if (target != null && _environment != null)
{
var direction = source.transform.position - target.transform.position;
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)
{
DropDelta += correction;
foreach (var ball in moving) ball.transform.position += correction;
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<string> 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<SphereCollider>();
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;