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