Keep playground drags and split previews on safe sphere paths
This commit is contained in:
@@ -33,7 +33,7 @@ namespace MagnetismHeroes.Tests
|
|||||||
foreach (var listener in Object.FindObjectsByType<AudioListener>(FindObjectsSortMode.None)) listener.enabled = false;
|
foreach (var listener in Object.FindObjectsByType<AudioListener>(FindObjectsSortMode.None)) listener.enabled = false;
|
||||||
foreach (var camera in Object.FindObjectsByType<Camera>(FindObjectsSortMode.None)) camera.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>();
|
_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()
|
[UnityTearDown] public IEnumerator TearDown()
|
||||||
{
|
{
|
||||||
@@ -183,15 +183,42 @@ namespace MagnetismHeroes.Tests
|
|||||||
yield return new WaitForSecondsRealtime(.4f);
|
yield return new WaitForSecondsRealtime(.4f);
|
||||||
Assert.That(_app.Presenter.Balls.Values.All(ball => !ball.transform.Find("ContactLight").GetComponent<LineRenderer>().enabled), Is.True);
|
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()
|
[UnityTest] public IEnumerator FourteenDisconnectedSelectionThenMergeIsAcceptedByFinalNumbers()
|
||||||
{
|
{
|
||||||
_app.StartStage(14); yield return null; Physics.SyncTransforms(); var group = _app.Session.Board.Groups[0];
|
_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]);
|
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]);
|
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;
|
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.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 }));
|
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);
|
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;
|
_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));
|
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;
|
foreach (var camera in Object.FindObjectsByType<Camera>(FindObjectsSortMode.None)) camera.enabled = false;
|
||||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
|
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
|
||||||
Assert.That(prefab, Is.Not.Null); _app = Object.Instantiate(prefab).GetComponent<GameApp>();
|
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()
|
[UnityTearDown] public IEnumerator TearDown()
|
||||||
{
|
{
|
||||||
@@ -288,7 +288,7 @@ namespace MagnetismHeroes.Tests
|
|||||||
Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved));
|
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;
|
_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));
|
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();
|
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();
|
var saved = JsonUtility.ToJson(_store.Saved); var camera = _app.Orbit.Snapshot();
|
||||||
DragTo(new Vector3(1.8f, -0.4f, 1.1f)); yield return null;
|
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.");
|
var stopped = _app.Session.Board.Balls.Single().position;
|
||||||
Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.EqualTo(new Vector3(initial.x, initial.y, initial.z)));
|
Assert.That(stopped.y, Is.LessThan(initial.y));
|
||||||
Assert.That(_app.Orbit.Snapshot().rotation, Is.EqualTo(camera.rotation)); Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved));
|
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();
|
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;
|
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);
|
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();
|
_app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||||
var floor = _app.transform.Find("Playground/PlaygroundIsland");
|
var floor = _app.transform.Find("Playground/PlaygroundIsland");
|
||||||
Assert.That(floor.GetComponent<Collider>(), Is.TypeOf<MeshCollider>());
|
Assert.That(floor.GetComponent<Collider>() == null, Is.True, "The hidden legacy floor must have no collider.");
|
||||||
Assert.That(floor.GetComponent<Collider>().bounds.max.y, Is.EqualTo(floor.GetComponent<Renderer>().bounds.max.y).Within(0.01));
|
var clearing = _app.Environment.GetComponentsInChildren<MeshCollider>().Single(collider => collider.name == "MeadowClearing");
|
||||||
Assert.That(floor.GetComponent<Collider>().bounds.max.y, Is.LessThan(0)); BallPoint();
|
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);
|
var ray = _orbit.Camera.ScreenPointToRay(point);
|
||||||
if (_dragPlane.Raycast(ray, out var distance))
|
if (_dragPlane.Raycast(ray, out var distance))
|
||||||
{
|
{
|
||||||
_previewDelta = ray.GetPoint(distance) - _grabPoint;
|
var nextPoint = ray.GetPoint(distance);
|
||||||
if (_splitting) { if (_splitDrag) _presenter.PreviewSplit(_groupId, _selected.ToArray(), _previewDelta); }
|
var step = nextPoint - _grabPoint; _grabPoint = nextPoint;
|
||||||
else _presenter.PreviewGroup(_groupId, _previewDelta);
|
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);
|
else if (_dragged && _gesture == Gesture.Orbit) _orbit.Orbit(point - _last);
|
||||||
@@ -103,10 +116,15 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
|
|||||||
{
|
{
|
||||||
if (_dragged)
|
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 (_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; }
|
{ _splitting = false; _selected.Clear(); _selectedGroup = null; }
|
||||||
else _presenter.CancelPreview();
|
else _presenter.CancelPreview();
|
||||||
_app.RefreshSelection();
|
_app.RefreshSelection();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using MagnetismHeroes.UnityRuntime.StageAssets;
|
using MagnetismHeroes.UnityRuntime.StageAssets;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
@@ -6,12 +7,16 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
|
|||||||
public sealed class BoardEnvironment : MonoBehaviour
|
public sealed class BoardEnvironment : MonoBehaviour
|
||||||
{
|
{
|
||||||
private int _stageId;
|
private int _stageId;
|
||||||
|
[SerializeField] private float _collisionTolerance = .001f;
|
||||||
|
private const float BallRadius = .5f;
|
||||||
|
private SphereCollider _safetySphere;
|
||||||
public string Theme { get; private set; }
|
public string Theme { get; private set; }
|
||||||
public Bounds PlaygroundBounds { get; private set; }
|
public Bounds PlaygroundBounds { get; private set; }
|
||||||
public void Apply(StageAsset stage)
|
public void Apply(StageAsset stage)
|
||||||
{
|
{
|
||||||
if (_stageId == stage.StageId) return;
|
if (_stageId == stage.StageId) return;
|
||||||
foreach (Transform child in transform) { child.gameObject.SetActive(false); Destroy(child.gameObject); }
|
foreach (Transform child in transform) { child.gameObject.SetActive(false); Destroy(child.gameObject); }
|
||||||
|
_safetySphere = null;
|
||||||
foreach (var placement in stage.Decorations)
|
foreach (var placement in stage.Decorations)
|
||||||
{
|
{
|
||||||
if (placement.prefab.name == "Island" && transform.parent.Find("PlaygroundIsland") != null) continue;
|
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;
|
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 Dictionary<string, BallView> _balls = new Dictionary<string, BallView>();
|
||||||
private readonly List<GroupBadge> _badges = new List<GroupBadge>();
|
private readonly List<GroupBadge> _badges = new List<GroupBadge>();
|
||||||
private BoardRules _board;
|
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 Camera Camera => _camera;
|
||||||
public IReadOnlyDictionary<string, BallView> Balls => _balls;
|
public IReadOnlyDictionary<string, BallView> Balls => _balls;
|
||||||
public string MergeCandidate { get; private set; }
|
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 view in _balls.Values) { view.gameObject.SetActive(false); Destroy(view.gameObject); }
|
||||||
foreach (var badge in _badges) { badge.gameObject.SetActive(false); Destroy(badge.gameObject); }
|
foreach (var badge in _badges) { badge.gameObject.SetActive(false); Destroy(badge.gameObject); }
|
||||||
_balls.Clear(); _badges.Clear();
|
_balls.Clear(); _badges.Clear();
|
||||||
MergeCandidate = null; DropDelta = Vector3.zero;
|
MergeCandidate = null; DropDelta = Vector3.zero; _previewGroup = null; _previewValid = true; _splitPreview = false;
|
||||||
if (board == null) return;
|
if (board == null) return;
|
||||||
foreach (var group in board.Groups)
|
foreach (var group in board.Groups)
|
||||||
{
|
{
|
||||||
@@ -74,48 +82,81 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
|
|||||||
|
|
||||||
public void PreviewGroup(string groupId, Vector3 delta)
|
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)
|
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;
|
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;
|
float closest = 1.35f; BallView source = null, target = null;
|
||||||
foreach (var moving in _balls.Values.Where(ball => ball.GroupId == groupId))
|
foreach (var ball in moving)
|
||||||
foreach (var other in _balls.Values.Where(ball => ball.GroupId != groupId))
|
foreach (var other in stationary)
|
||||||
{
|
{
|
||||||
float distance = Vector3.Distance(moving.transform.position, other.transform.position);
|
float distance = Vector3.Distance(ball.transform.position, other.transform.position);
|
||||||
if (distance < closest) { closest = distance; source = moving; target = other; }
|
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;
|
var direction = source.transform.position - target.transform.position;
|
||||||
if (direction.sqrMagnitude < 0.0001f) direction = -_camera.transform.right;
|
if (direction.sqrMagnitude < .0001f) direction = -_camera.transform.right;
|
||||||
DropDelta += direction.normalized * (1 - closest);
|
direction.Normalize();
|
||||||
foreach (var ball in _balls.Values.Where(ball => ball.GroupId == groupId)) ball.transform.position += DropDelta - delta;
|
var correction = direction * (1 - closest);
|
||||||
var moving = _balls.Values.Where(ball => ball.GroupId == groupId).ToArray();
|
var adjusted = moving.Select(ball => ball.transform.position + correction).ToArray();
|
||||||
var fixedBalls = _balls.Values.Where(ball => ball.GroupId != groupId).ToArray();
|
correction += direction * ContactPlacement.Clearance(adjusted, stationary.Select(ball => ball.transform.position).ToArray(), direction);
|
||||||
var correction = direction.normalized * ContactPlacement.Clearance(moving.Select(ball => ball.transform.position).ToArray(), fixedBalls.Select(ball => ball.transform.position).ToArray(), direction.normalized);
|
var safeCorrection = _environment.ConstrainTranslation(moving.Select(ball => ball.transform.position).ToArray(), correction, transform);
|
||||||
DropDelta += correction;
|
if ((safeCorrection - correction).sqrMagnitude < .00000001f)
|
||||||
foreach (var ball in moving) ball.transform.position += correction;
|
{
|
||||||
closest = float.PositiveInfinity;
|
// Only offer an actual unit-sphere contact after the entire correction path is safe.
|
||||||
foreach (var ball in moving)
|
closest = float.PositiveInfinity;
|
||||||
foreach (var other in fixedBalls)
|
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);
|
DropDelta += correction;
|
||||||
if (distance < closest) { closest = distance; source = ball; target = other; }
|
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();
|
PositionBadges();
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool PreviewSplit(string groupId, IReadOnlyList<string> selected, Vector3 delta)
|
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));
|
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);
|
foreach (var ball in positions) _balls[ball.id].transform.position = ToVector(ball.position);
|
||||||
|
DropDelta = delta; _previewValid = true;
|
||||||
PositionBadges(); return true;
|
PositionBadges(); return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +168,7 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
|
|||||||
public void CancelPreview()
|
public void CancelPreview()
|
||||||
{
|
{
|
||||||
if (_board == null) return;
|
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 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();
|
foreach (var badge in _badges) badge.ClearReaction();
|
||||||
PositionBadges();
|
PositionBadges();
|
||||||
@@ -136,21 +177,9 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
|
|||||||
public bool CanDropGroup(string groupId)
|
public bool CanDropGroup(string groupId)
|
||||||
{
|
{
|
||||||
Physics.SyncTransforms();
|
Physics.SyncTransforms();
|
||||||
foreach (var ball in _balls.Values.Where(ball => ball.GroupId == groupId))
|
return _previewValid && _environment != null && _balls.Values
|
||||||
{
|
.Where(ball => _splitPreview || ball.GroupId == groupId)
|
||||||
var sphere = ball.GetComponent<SphereCollider>();
|
.All(ball => _environment.IsSafeSphere(ball.transform.position, transform));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryFindSpawnPosition(out Position3 position)
|
public bool TryFindSpawnPosition(out Position3 position)
|
||||||
@@ -164,7 +193,7 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
|
|||||||
var viewport = _camera.WorldToViewportPoint(point);
|
var viewport = _camera.WorldToViewportPoint(point);
|
||||||
if (viewport.z <= 0 || viewport.x < .08f || viewport.x > .92f || viewport.y < .3f || viewport.y > .78f) continue;
|
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 (_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 = ToPosition(point); return true;
|
||||||
}
|
}
|
||||||
position = default; return false;
|
position = default; return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user