387 lines
29 KiB
C#
387 lines
29 KiB
C#
#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
|
|
|