Integrate preserved resume repair with drag camera improvements
This commit is contained in:
@@ -72,6 +72,35 @@ namespace MagnetismHeroes.Core
|
|||||||
|
|
||||||
public static BoardRules CreateFreePlay(FreePlayState state) => new BoardRules(state);
|
public static BoardRules CreateFreePlay(FreePlayState state) => new BoardRules(state);
|
||||||
|
|
||||||
|
// Restore preparation changes positions without treating the correction as collection or play.
|
||||||
|
public bool TryTranslateForRestore(IReadOnlyDictionary<string, Position3> translations, out BoardRules prepared)
|
||||||
|
{
|
||||||
|
prepared = null;
|
||||||
|
if (translations == null || translations.Any(pair => !_groups.ContainsKey(pair.Key) || !pair.Value.IsValid)) return false;
|
||||||
|
var balls = Balls;
|
||||||
|
foreach (var group in _groups)
|
||||||
|
{
|
||||||
|
if (!translations.TryGetValue(group.Key, out var delta)) continue;
|
||||||
|
foreach (var ball in balls.Where(ball => group.Value.Contains(ball.id)))
|
||||||
|
{
|
||||||
|
var point = ball.position;
|
||||||
|
ball.position = new Position3(point.x + delta.x, point.y + delta.y, point.z + delta.z);
|
||||||
|
if (!ball.position.IsValid) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Mode == BoardMode.FreePlay)
|
||||||
|
{
|
||||||
|
var state = ToFreePlayState(GameSession.DefaultCamera()); state.balls = balls;
|
||||||
|
prepared = CreateFreePlay(state);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var state = ToPuzzleState(GameSession.DefaultCamera()); state.balls = balls;
|
||||||
|
prepared = new BoardRules(_definition, state);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public RuleResult TryMoveGroup(string groupId, Position3 delta)
|
public RuleResult TryMoveGroup(string groupId, Position3 delta)
|
||||||
{
|
{
|
||||||
if (!ValidId(groupId) || !_groups.TryGetValue(groupId, out var members)) return RuleResult.Reject(RejectionReason.UnknownId);
|
if (!ValidId(groupId) || !_groups.TryGetValue(groupId, out var members)) return RuleResult.Reject(RejectionReason.UnknownId);
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ namespace MagnetismHeroes.Core
|
|||||||
{
|
{
|
||||||
public enum SessionScreen { Home, StageSelection, Puzzle, FreePlay }
|
public enum SessionScreen { Home, StageSelection, Puzzle, FreePlay }
|
||||||
|
|
||||||
|
public sealed class BoardRestore
|
||||||
|
{
|
||||||
|
public BoardRules Board { get; }
|
||||||
|
public CameraState Camera { get; }
|
||||||
|
public bool ResetToInitial { get; }
|
||||||
|
public BoardRestore(BoardRules board, CameraState camera, bool resetToInitial = false)
|
||||||
|
{ Board = board; Camera = camera; ResetToInitial = resetToInitial; }
|
||||||
|
}
|
||||||
|
|
||||||
// Owns committed game state. A gesture preview never reaches this class.
|
// Owns committed game state. A gesture preview never reaches this class.
|
||||||
public sealed class GameSession
|
public sealed class GameSession
|
||||||
{
|
{
|
||||||
@@ -50,29 +59,106 @@ namespace MagnetismHeroes.Core
|
|||||||
public void ShowSelection() { SaveCurrent(); Screen = SessionScreen.StageSelection; Board = null; }
|
public void ShowSelection() { SaveCurrent(); Screen = SessionScreen.StageSelection; Board = null; }
|
||||||
public void ShowHome() { SaveCurrent(); Screen = SessionScreen.Home; Board = null; }
|
public void ShowHome() { SaveCurrent(); Screen = SessionScreen.Home; Board = null; }
|
||||||
|
|
||||||
public bool StartStage(int stageId)
|
public bool StartStage(int stageId, Func<BoardRules, CameraState, BoardRestore> prepare = null)
|
||||||
{
|
{
|
||||||
var definition = _definitions.FirstOrDefault(value => value.Id == stageId);
|
var definition = _definitions.FirstOrDefault(value => value.Id == stageId);
|
||||||
if (definition == null || !IsUnlocked(stageId)) return false;
|
if (definition == null || !IsUnlocked(stageId)) return false;
|
||||||
SaveCurrent();
|
var next = CopyProgress(); SnapshotCurrent(next);
|
||||||
var restored = _progress.puzzleStates.FirstOrDefault(state => state.stageId == stageId);
|
var restored = next.puzzleStates.FirstOrDefault(state => state.stageId == stageId);
|
||||||
Board = new BoardRules(definition, restored);
|
return Enter(next, new BoardRules(definition, restored), restored == null ? DefaultCamera() : CopyCamera(restored.camera),
|
||||||
Camera = restored == null ? DefaultCamera() : CopyCamera(restored.camera);
|
SessionScreen.Puzzle, prepare);
|
||||||
Screen = SessionScreen.Puzzle;
|
}
|
||||||
SaveCurrent();
|
|
||||||
|
public bool ContinueFreePlay(Func<BoardRules, CameraState, BoardRestore> prepare = null)
|
||||||
|
{
|
||||||
|
if (_progress.latestFreePlay == null) return false;
|
||||||
|
var next = CopyProgress(); SnapshotCurrent(next);
|
||||||
|
return Enter(next, BoardRules.CreateFreePlay(next.latestFreePlay), CopyCamera(next.latestFreePlay.camera), SessionScreen.FreePlay, prepare);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool Enter(ProgressSave next, BoardRules restored, CameraState camera, SessionScreen screen,
|
||||||
|
Func<BoardRules, CameraState, BoardRestore> prepare)
|
||||||
|
{
|
||||||
|
var prepared = prepare == null ? new BoardRestore(restored, camera) : prepare(restored, camera);
|
||||||
|
if (!ValidPreparation(restored, prepared)) return false;
|
||||||
|
if (screen == SessionScreen.Puzzle)
|
||||||
|
{
|
||||||
|
var existing = next.puzzleStates.FirstOrDefault(state => state.stageId == restored.StageId);
|
||||||
|
if (existing != null && !prepared.ResetToInitial)
|
||||||
|
{ ApplyPreparedPositions(existing.balls, prepared.Board); existing.camera = CopyCamera(prepared.Camera); }
|
||||||
|
else if (existing != null)
|
||||||
|
next.puzzleStates = next.puzzleStates.Select(state => state.stageId == restored.StageId ? prepared.Board.ToPuzzleState(prepared.Camera) : state).ToArray();
|
||||||
|
else next.puzzleStates = next.puzzleStates.Concat(new[] { prepared.Board.ToPuzzleState(prepared.Camera) }).OrderBy(state => state.stageId).ToArray();
|
||||||
|
}
|
||||||
|
else if (!prepared.ResetToInitial)
|
||||||
|
{ ApplyPreparedPositions(next.latestFreePlay.balls, prepared.Board); next.latestFreePlay.camera = CopyCamera(prepared.Camera); }
|
||||||
|
else next.latestFreePlay = prepared.Board.ToFreePlayState(prepared.Camera);
|
||||||
|
if (!SaveValidator.TryValidateUpdate(_progress, next, _definitions, out _)) return false;
|
||||||
|
var saved = _store.TrySave(next, out var error);
|
||||||
|
LastSaveError = error; SaveFinished?.Invoke(error);
|
||||||
|
if (!saved) return false;
|
||||||
|
_progress = next; Board = prepared.Board; Camera = CopyCamera(prepared.Camera); Screen = screen;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ContinueFreePlay()
|
private static void ApplyPreparedPositions(BallState[] balls, BoardRules prepared)
|
||||||
{
|
{
|
||||||
if (_progress.latestFreePlay == null) return false;
|
var positions = prepared.Balls.ToDictionary(ball => ball.id, ball => ball.position);
|
||||||
SaveCurrent();
|
foreach (var ball in balls) ball.position = positions[ball.id];
|
||||||
Board = BoardRules.CreateFreePlay(_progress.latestFreePlay);
|
}
|
||||||
Camera = CopyCamera(_progress.latestFreePlay.camera);
|
|
||||||
Screen = SessionScreen.FreePlay;
|
private bool ValidPreparation(BoardRules restored, BoardRestore prepared)
|
||||||
|
{
|
||||||
|
if (prepared?.Board == null || !SaveValidator.ValidCamera(prepared.Camera)
|
||||||
|
|| prepared.Board.StageId != restored.StageId || prepared.Board.Mode != restored.Mode) return false;
|
||||||
|
var expected = restored;
|
||||||
|
if (prepared.ResetToInitial)
|
||||||
|
{
|
||||||
|
expected = new BoardRules(_definitions.First(stage => stage.Id == restored.StageId));
|
||||||
|
if (restored.Mode == BoardMode.FreePlay) expected = BoardRules.CreateFreePlay(expected.ToFreePlayState(DefaultCamera()));
|
||||||
|
}
|
||||||
|
if (!expected.CollectedBallIds.SequenceEqual(prepared.Board.CollectedBallIds)
|
||||||
|
|| !expected.CountedBallIds.SequenceEqual(prepared.Board.CountedBallIds)) return false;
|
||||||
|
var sourceGroups = expected.Groups; var groups = prepared.Board.Groups;
|
||||||
|
if (sourceGroups.Length != groups.Length) return false;
|
||||||
|
for (int index = 0; index < groups.Length; index++)
|
||||||
|
if (sourceGroups[index].id != groups[index].id || !sourceGroups[index].ballIds.SequenceEqual(groups[index].ballIds)) return false;
|
||||||
|
var source = expected.Balls.ToDictionary(ball => ball.id, ball => ball.position);
|
||||||
|
var target = prepared.Board.Balls.ToDictionary(ball => ball.id, ball => ball.position);
|
||||||
|
if (source.Count != target.Count || source.Keys.Except(target.Keys).Any()) return false;
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var first = group.ballIds[0]; var before = source[first]; var after = target[first];
|
||||||
|
var delta = new Position3(after.x - before.x, after.y - before.y, after.z - before.z);
|
||||||
|
if (prepared.ResetToInitial && (delta.x != 0 || delta.y != 0 || delta.z != 0)) return false;
|
||||||
|
foreach (var id in group.ballIds)
|
||||||
|
if (Math.Abs(target[id].x - source[id].x - delta.x) > .0001f
|
||||||
|
|| Math.Abs(target[id].y - source[id].y - delta.y) > .0001f
|
||||||
|
|| Math.Abs(target[id].z - source[id].z - delta.z) > .0001f) return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ProgressSave CopyProgress() => new ProgressSave {
|
||||||
|
schemaVersion = _progress.schemaVersion, completedStageIds = (int[])_progress.completedStageIds.Clone(),
|
||||||
|
seenTutorialIds = (string[])_progress.seenTutorialIds.Clone(),
|
||||||
|
puzzleStates = _progress.puzzleStates.Select(state => new PuzzleState { stageId = state.stageId,
|
||||||
|
balls = CopyBalls(state.balls), groups = CopyGroups(state.groups), camera = CopyCamera(state.camera),
|
||||||
|
collectedBallIds = (string[])state.collectedBallIds.Clone(), countedBallIds = (string[])state.countedBallIds.Clone() }).ToArray(),
|
||||||
|
latestFreePlay = _progress.latestFreePlay == null ? null : new FreePlayState { sourceStageId = _progress.latestFreePlay.sourceStageId,
|
||||||
|
balls = CopyBalls(_progress.latestFreePlay.balls), groups = CopyGroups(_progress.latestFreePlay.groups), camera = CopyCamera(_progress.latestFreePlay.camera) } };
|
||||||
|
private static BallState[] CopyBalls(BallState[] balls) => balls.Select(ball => new BallState { id = ball.id, position = ball.position }).ToArray();
|
||||||
|
private static GroupState[] CopyGroups(GroupState[] groups) => groups.Select(group => new GroupState { id = group.id, ballIds = (string[])group.ballIds.Clone() }).ToArray();
|
||||||
|
|
||||||
|
private void SnapshotCurrent(ProgressSave progress)
|
||||||
|
{
|
||||||
|
if (Board == null) return;
|
||||||
|
if (Screen == SessionScreen.Puzzle)
|
||||||
|
progress.puzzleStates = progress.puzzleStates.Where(state => state.stageId != Board.StageId)
|
||||||
|
.Concat(new[] { Board.ToPuzzleState(Camera) }).OrderBy(state => state.stageId).ToArray();
|
||||||
|
else if (Screen == SessionScreen.FreePlay) progress.latestFreePlay = Board.ToFreePlayState(Camera);
|
||||||
|
}
|
||||||
|
|
||||||
public RuleResult MoveGroup(string groupId, Position3 delta) => Commit(() => Board.TryMoveGroup(groupId, delta));
|
public RuleResult MoveGroup(string groupId, Position3 delta) => Commit(() => Board.TryMoveGroup(groupId, delta));
|
||||||
public RuleResult Merge(string first, string second) => Commit(() => Board.TryMerge(first, second));
|
public RuleResult Merge(string first, string second) => Commit(() => Board.TryMerge(first, second));
|
||||||
public RuleResult MoveAndMerge(string groupId, Position3 delta, string targetGroupId) => Commit(() => Board.TryMoveAndMerge(groupId, delta, targetGroupId));
|
public RuleResult MoveAndMerge(string groupId, Position3 delta, string targetGroupId) => Commit(() => Board.TryMoveAndMerge(groupId, delta, targetGroupId));
|
||||||
@@ -115,13 +201,7 @@ namespace MagnetismHeroes.Core
|
|||||||
|
|
||||||
public bool SaveCurrent()
|
public bool SaveCurrent()
|
||||||
{
|
{
|
||||||
if (Board != null)
|
SnapshotCurrent(_progress);
|
||||||
{
|
|
||||||
if (Screen == SessionScreen.Puzzle)
|
|
||||||
_progress.puzzleStates = _progress.puzzleStates.Where(state => state.stageId != Board.StageId)
|
|
||||||
.Concat(new[] { Board.ToPuzzleState(Camera) }).OrderBy(state => state.stageId).ToArray();
|
|
||||||
else if (Screen == SessionScreen.FreePlay) _progress.latestFreePlay = Board.ToFreePlayState(Camera);
|
|
||||||
}
|
|
||||||
var success = _store.TrySave(_progress, out var error);
|
var success = _store.TrySave(_progress, out var error);
|
||||||
LastSaveError = error;
|
LastSaveError = error;
|
||||||
SaveFinished?.Invoke(error);
|
SaveFinished?.Invoke(error);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||||
|
using Unity.Collections;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace MagnetismHeroes.Editor
|
||||||
|
{
|
||||||
|
public static class PlaygroundRestoreGeometryAssets
|
||||||
|
{
|
||||||
|
public const string CachePath = "Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundRestoreGeometry.asset";
|
||||||
|
public static void CreateCache()
|
||||||
|
{
|
||||||
|
if (AssetDatabase.LoadMainAssetAtPath(CachePath) != null) throw new InvalidOperationException("Restore geometry already exists; verify before replacing it.");
|
||||||
|
var meshes = AssetDatabase.FindAssets("t:GameObject", new[] { "Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs" })
|
||||||
|
.Select(AssetDatabase.GUIDToAssetPath).Select(AssetDatabase.LoadAssetAtPath<GameObject>)
|
||||||
|
.SelectMany(prefab => prefab.GetComponentsInChildren<MeshCollider>(true)).Select(collider => collider.sharedMesh)
|
||||||
|
.Where(mesh => mesh != null).Distinct().OrderBy(mesh => AssetDatabase.GetAssetPath(mesh)).ThenBy(mesh => mesh.name).ToArray();
|
||||||
|
var entries = meshes.Select(mesh => {
|
||||||
|
Vector3[] vertices; int[] triangles;
|
||||||
|
using (var readOnly = MeshUtility.AcquireReadOnlyMeshData(mesh))
|
||||||
|
{
|
||||||
|
var data = readOnly[0]; using var buffer = new NativeArray<Vector3>(data.vertexCount, Allocator.Temp);
|
||||||
|
data.GetVertices(buffer); vertices = buffer.ToArray();
|
||||||
|
var indices = data.indexFormat == UnityEngine.Rendering.IndexFormat.UInt16
|
||||||
|
? data.GetIndexData<ushort>().ToArray().Select(index => (int)index).ToArray()
|
||||||
|
: data.GetIndexData<uint>().ToArray().Select(index => (int)index).ToArray();
|
||||||
|
triangles = Enumerable.Range(0, data.subMeshCount).SelectMany(submesh => {
|
||||||
|
var descriptor = data.GetSubMesh(submesh);
|
||||||
|
return Enumerable.Range(descriptor.indexStart, descriptor.indexCount).Select(index => indices[index] + descriptor.baseVertex);
|
||||||
|
}).ToArray();
|
||||||
|
}
|
||||||
|
string path = AssetDatabase.GetAssetPath(mesh);
|
||||||
|
return new PlaygroundRestoreGeometry.Entry { mesh = mesh, vertices = vertices, triangles = triangles, bounds = mesh.bounds,
|
||||||
|
geometrySha256 = PlaygroundRestoreGeometry.Hash(vertices, triangles), sourceSha256 = FileHash(path),
|
||||||
|
sourceMetaSha256 = FileHash(path + ".meta"), sourceGuid = AssetDatabase.AssetPathToGUID(path) };
|
||||||
|
}).ToArray();
|
||||||
|
if (entries.Length == 0) throw new InvalidOperationException("No authored collider meshes found.");
|
||||||
|
var cache = ScriptableObject.CreateInstance<PlaygroundRestoreGeometry>(); cache.SetEntries(entries);
|
||||||
|
AssetDatabase.CreateAsset(cache, CachePath); EditorUtility.SetDirty(cache); AssetDatabase.SaveAssets();
|
||||||
|
Debug.Log("Created read-only restore geometry for " + entries.Length + " unchanged collider meshes.");
|
||||||
|
}
|
||||||
|
private static string FileHash(string path)
|
||||||
|
{ using var sha = SHA256.Create(); return BitConverter.ToString(sha.ComputeHash(File.ReadAllBytes(path))).Replace("-", "").ToLowerInvariant(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 64260de0ba3968c43ba2b2f17991ebae
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using MagnetismHeroes.Core;
|
||||||
|
using MagnetismHeroes.UnityRuntime.Persistence;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace MagnetismHeroes.Tests
|
||||||
|
{
|
||||||
|
public sealed class PlaygroundResumeSessionTests
|
||||||
|
{
|
||||||
|
private static readonly StageDefinition[] Definitions = {
|
||||||
|
new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 }),
|
||||||
|
new StageDefinition(2, StageKind.Counting, new[] { 1, 1 }, new[] { 2 }),
|
||||||
|
new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) };
|
||||||
|
private sealed class Store : IProgressStore
|
||||||
|
{
|
||||||
|
public ProgressSave Save;
|
||||||
|
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 static PuzzleState Legacy()
|
||||||
|
{
|
||||||
|
return new PuzzleState { stageId = 2,
|
||||||
|
balls = new[] { new BallState { id = "legacy-a", position = new Position3(30, 2, 0) }, new BallState { id = "legacy-b", position = new Position3(31, 2, 0) } },
|
||||||
|
groups = new[] { new GroupState { id = "legacy-group", ballIds = new[] { "legacy-a", "legacy-b" } } },
|
||||||
|
collectedBallIds = new[] { "legacy-a" }, countedBallIds = new[] { "legacy-a" }, camera = GameSession.DefaultCamera() };
|
||||||
|
}
|
||||||
|
private static ProgressSave Fixture() => new ProgressSave {
|
||||||
|
completedStageIds = new[] { 1, 2, 3, 4, 5, 6 }, seenTutorialIds = new[] { "merge", "split" },
|
||||||
|
puzzleStates = new[] { new BoardRules(Definitions[0]).ToPuzzleState(GameSession.DefaultCamera()), Legacy(), new BoardRules(Definitions[2]).ToPuzzleState(GameSession.DefaultCamera()) },
|
||||||
|
latestFreePlay = new BoardRules(Definitions[2]).ToFreePlayState(GameSession.DefaultCamera()) };
|
||||||
|
private static BoardRestore Repair(BoardRules board, CameraState camera)
|
||||||
|
{
|
||||||
|
Assert.That(board.TryTranslateForRestore(new Dictionary<string, Position3> { ["legacy-group"] = new Position3(-23, 0, 0) }, out var prepared), Is.True);
|
||||||
|
return new BoardRestore(prepared, camera);
|
||||||
|
}
|
||||||
|
[Test] public void RestoreTranslationRetainsIdentityMembershipPartialCollectionAndCountWithoutSuccess()
|
||||||
|
{
|
||||||
|
var original = new BoardRules(Definitions[1], Legacy());
|
||||||
|
var prepared = Repair(original, GameSession.DefaultCamera()).Board;
|
||||||
|
Assert.That(original.Balls[0].position.x, Is.EqualTo(30));
|
||||||
|
Assert.That(prepared.Balls.Select(ball => ball.id), Is.EqualTo(original.Balls.Select(ball => ball.id)));
|
||||||
|
Assert.That(prepared.Groups.Single().ballIds, Is.EqualTo(original.Groups.Single().ballIds));
|
||||||
|
Assert.That(prepared.CollectedBallIds, Is.EqualTo(original.CollectedBallIds));
|
||||||
|
Assert.That(prepared.CountedBallIds, Is.EqualTo(original.CountedBallIds));
|
||||||
|
Assert.That(prepared.IsSolved, Is.False);
|
||||||
|
Assert.That(prepared.Balls[1].position.x - prepared.Balls[0].position.x, Is.EqualTo(1));
|
||||||
|
var ordinary = new BoardRules(Definitions[1], Legacy()); ordinary.TryMoveGroup("legacy-group", new Position3(-23, 0, 0));
|
||||||
|
Assert.That(ordinary.CollectedBallIds.Length, Is.EqualTo(2), "Negative control: ordinary play movement would corrupt partial collection.");
|
||||||
|
}
|
||||||
|
[Test] public void InvalidTranslationRejectsWholeReplacementAndLeavesSourceUntouched()
|
||||||
|
{
|
||||||
|
var board = new BoardRules(Definitions[1], Legacy());
|
||||||
|
Assert.That(board.TryTranslateForRestore(new Dictionary<string, Position3> { ["legacy-group"] = new Position3(float.NaN, 0, 0) }, out var prepared), Is.False);
|
||||||
|
Assert.That(prepared, Is.Null); Assert.That(board.Balls[0].position.x, Is.EqualTo(30));
|
||||||
|
Assert.That(board.CollectedBallIds, Is.EqualTo(new[] { "legacy-a" }));
|
||||||
|
}
|
||||||
|
[Test] public void OneValidatedWriteReplacesOnlyTargetWhileKeepingOtherPuzzlesFreeProgressAndTutorials()
|
||||||
|
{
|
||||||
|
var fixture = Fixture(); var otherOne = fixture.puzzleStates[0]; var otherSix = fixture.puzzleStates[2]; var free = fixture.latestFreePlay;
|
||||||
|
var store = new Store { Save = fixture }; var session = new GameSession(Definitions, store);
|
||||||
|
Assert.That(session.StartStage(2, Repair), Is.True);
|
||||||
|
Assert.That(store.Writes, Is.EqualTo(1)); Assert.That(session.Screen, Is.EqualTo(SessionScreen.Puzzle));
|
||||||
|
Assert.That(store.Save.completedStageIds, Is.EqualTo(fixture.completedStageIds)); Assert.That(store.Save.seenTutorialIds, Is.EqualTo(fixture.seenTutorialIds));
|
||||||
|
AssertPuzzle(store.Save.puzzleStates[0], otherOne); AssertPuzzle(store.Save.puzzleStates[2], otherSix);
|
||||||
|
Assert.That(store.Save.latestFreePlay.balls.Select(ball => ball.position), Is.EqualTo(free.balls.Select(ball => ball.position)));
|
||||||
|
Assert.That(store.Save.latestFreePlay.sourceStageId, Is.EqualTo(free.sourceStageId));
|
||||||
|
Assert.That(store.Save.puzzleStates[1].balls[0].position.x, Is.EqualTo(7));
|
||||||
|
Assert.That(fixture.puzzleStates[1].balls[0].position.x, Is.EqualTo(30), "Preparing and saving must not mutate a loaded store snapshot.");
|
||||||
|
}
|
||||||
|
[Test] public void InconclusivePreparationHasNoWriteAndKeepsCurrentBoardCameraAndEverySlot()
|
||||||
|
{
|
||||||
|
var fixture = Fixture(); var store = new Store { Save = fixture }; var session = new GameSession(Definitions, store);
|
||||||
|
session.StartStage(6); var current = session.Board; var camera = session.Camera; int before = store.Writes;
|
||||||
|
Assert.That(session.StartStage(2, (board, view) => null), Is.False);
|
||||||
|
Assert.That(store.Writes, Is.EqualTo(before)); Assert.That(session.Board, Is.SameAs(current)); Assert.That(session.Camera, Is.SameAs(camera));
|
||||||
|
Assert.That(store.Save.puzzleStates.Single(state => state.stageId == 2).balls[0].position.x, Is.EqualTo(30));
|
||||||
|
}
|
||||||
|
[TestCase(false)] [TestCase(true)] public void ValidUnsortedDtosKeepTheirRawIdentityMembershipAndMarkerOrder(bool repair)
|
||||||
|
{
|
||||||
|
var fixture = Fixture(); fixture.puzzleStates = fixture.puzzleStates.Reverse().ToArray();
|
||||||
|
var original = fixture.puzzleStates.Single(state => state.stageId == 2);
|
||||||
|
original.balls = original.balls.Reverse().ToArray(); original.groups[0].ballIds = original.groups[0].ballIds.Reverse().ToArray();
|
||||||
|
original.collectedBallIds = new[] { "legacy-b", "legacy-a" };
|
||||||
|
var store = new Store { Save = fixture }; var session = new GameSession(Definitions, store);
|
||||||
|
Assert.That(session.StartStage(2, repair ? Repair : null), Is.True);
|
||||||
|
Assert.That(store.Writes, Is.EqualTo(1)); Assert.That(store.Save.puzzleStates.Select(state => state.stageId), Is.EqualTo(new[] { 6, 2, 1 }));
|
||||||
|
var actual = store.Save.puzzleStates.Single(state => state.stageId == 2);
|
||||||
|
Assert.That(actual.balls.Select(ball => ball.id), Is.EqualTo(original.balls.Select(ball => ball.id)));
|
||||||
|
Assert.That(actual.groups[0].ballIds, Is.EqualTo(original.groups[0].ballIds));
|
||||||
|
Assert.That(actual.collectedBallIds, Is.EqualTo(original.collectedBallIds)); Assert.That(actual.countedBallIds, Is.EqualTo(original.countedBallIds));
|
||||||
|
Assert.That(actual.balls[0].position.x, Is.EqualTo(repair ? 8 : 31)); Assert.That(original.balls[0].position.x, Is.EqualTo(31));
|
||||||
|
AssertPuzzle(store.Save.puzzleStates[0], fixture.puzzleStates[0]); AssertPuzzle(store.Save.puzzleStates[2], fixture.puzzleStates[2]);
|
||||||
|
}
|
||||||
|
[Test] public void ReplacementRejectsChangedIdsMembershipAndNonRigidShapeWithoutAnyWrite()
|
||||||
|
{
|
||||||
|
foreach (string change in new[] { "ids", "shape", "collection" })
|
||||||
|
{
|
||||||
|
var store = new Store { Save = Fixture() }; var session = new GameSession(Definitions, store);
|
||||||
|
Assert.That(session.StartStage(2, (board, camera) => {
|
||||||
|
var state = board.ToPuzzleState(camera);
|
||||||
|
if (change == "ids") { state.balls[1].id = "new-id"; state.groups[0].ballIds[1] = "new-id"; }
|
||||||
|
if (change == "shape") state.balls[1].position.x += 1;
|
||||||
|
if (change == "collection") state.collectedBallIds = new[] { "legacy-a", "legacy-b" };
|
||||||
|
return new BoardRestore(new BoardRules(Definitions[1], state), camera);
|
||||||
|
}), Is.False);
|
||||||
|
Assert.That(store.Writes, Is.Zero); Assert.That(session.Board, Is.Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[Test] public void ExplicitInitialResetOnlyChangesTheAffectedPuzzleAndNeverRemovesPastCompletion()
|
||||||
|
{
|
||||||
|
var fixture = Fixture(); var store = new Store { Save = fixture }; var session = new GameSession(Definitions, store);
|
||||||
|
Assert.That(session.StartStage(2, (board, camera) => new BoardRestore(new BoardRules(Definitions[1]), GameSession.DefaultCamera(), true)), Is.True);
|
||||||
|
Assert.That(store.Writes, Is.EqualTo(1)); Assert.That(session.Board.CollectedBallIds, Is.Empty); Assert.That(session.Board.CountedBallIds, Is.Empty);
|
||||||
|
Assert.That(session.Board.Balls.Select(ball => ball.id), Is.EqualTo(new[] { "s02-b01", "s02-b02" }));
|
||||||
|
Assert.That(session.CompletedStageIds, Is.EqualTo(fixture.completedStageIds));
|
||||||
|
AssertPuzzle(store.Save.puzzleStates[0], fixture.puzzleStates[0]); AssertPuzzle(store.Save.puzzleStates[2], fixture.puzzleStates[2]);
|
||||||
|
Assert.That(store.Save.latestFreePlay.sourceStageId, Is.EqualTo(6));
|
||||||
|
}
|
||||||
|
[Test] public void ActualStoreWriteFailureKeepsPrimaryValidBackupCurrentBoardAndNoIntermediateTargetWrite()
|
||||||
|
{
|
||||||
|
string directory = Path.Combine(Path.GetTempPath(), "MagnetismHeroes-restore-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory);
|
||||||
|
string path = Path.Combine(directory, "progress.json");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var store = new LocalProgressStore(path, Definitions); Assert.That(store.TrySave(Fixture(), out _), Is.True); Assert.That(store.TrySave(Fixture(), out _), Is.True);
|
||||||
|
var session = new GameSession(Definitions, store); session.StartStage(6);
|
||||||
|
byte[] primary = File.ReadAllBytes(path), backup = File.ReadAllBytes(path + ".bak"); var board = session.Board; var camera = session.Camera;
|
||||||
|
using (new FileStream(path + ".tmp", FileMode.Create, FileAccess.ReadWrite, FileShare.None))
|
||||||
|
Assert.That(session.StartStage(2, Repair), Is.False);
|
||||||
|
Assert.That(session.LastSaveError, Is.Not.Null); Assert.That(session.Board, Is.SameAs(board)); Assert.That(session.Camera, Is.SameAs(camera));
|
||||||
|
Assert.That(File.ReadAllBytes(path), Is.EqualTo(primary)); Assert.That(File.ReadAllBytes(path + ".bak"), Is.EqualTo(backup));
|
||||||
|
Assert.That(store.TryLoad(out var loaded, out var error), Is.True, error);
|
||||||
|
Assert.That(loaded.puzzleStates.Single(state => state.stageId == 2).balls[0].position.x, Is.EqualTo(30));
|
||||||
|
}
|
||||||
|
finally { Directory.Delete(directory, true); }
|
||||||
|
}
|
||||||
|
private static void AssertPuzzle(PuzzleState actual, PuzzleState expected)
|
||||||
|
{
|
||||||
|
Assert.That(actual.stageId, Is.EqualTo(expected.stageId)); Assert.That(actual.balls.Select(ball => ball.id), Is.EqualTo(expected.balls.Select(ball => ball.id)));
|
||||||
|
Assert.That(actual.balls.Select(ball => ball.position), Is.EqualTo(expected.balls.Select(ball => ball.position)));
|
||||||
|
Assert.That(actual.groups.Select(group => group.id), Is.EqualTo(expected.groups.Select(group => group.id)));
|
||||||
|
for (int index = 0; index < actual.groups.Length; index++) Assert.That(actual.groups[index].ballIds, Is.EqualTo(expected.groups[index].ballIds));
|
||||||
|
Assert.That(actual.collectedBallIds, Is.EqualTo(expected.collectedBallIds)); Assert.That(actual.countedBallIds, Is.EqualTo(expected.countedBallIds));
|
||||||
|
Assert.That(actual.camera.target, Is.EqualTo(expected.camera.target)); Assert.That(actual.camera.rotation, Is.EqualTo(expected.camera.rotation)); Assert.That(actual.camera.distance, Is.EqualTo(expected.camera.distance));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e1b9640c8d119ad488ff61dadfd6d2ce
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
#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 UnityEngine;
|
||||||
|
using UnityEngine.TestTools;
|
||||||
|
using TMPro;
|
||||||
|
using Unity.Collections;
|
||||||
|
|
||||||
|
namespace MagnetismHeroes.Tests
|
||||||
|
{
|
||||||
|
public sealed class PlaygroundResumePlayTests
|
||||||
|
{
|
||||||
|
private sealed class Store : IProgressStore
|
||||||
|
{
|
||||||
|
public ProgressSave Save;
|
||||||
|
public int Writes;
|
||||||
|
public bool FailWrites;
|
||||||
|
public bool TryLoad(out ProgressSave save, out string error) { save = Clone(Save); error = null; return true; }
|
||||||
|
public bool TrySave(ProgressSave save, out string error) { Writes++; error = FailWrites ? "test storage unavailable" : null; if (FailWrites) return false; Save = Clone(save); return true; }
|
||||||
|
}
|
||||||
|
private GameApp _app;
|
||||||
|
private Store _store;
|
||||||
|
private readonly List<Object> _fixtures = new List<Object>();
|
||||||
|
private static ProgressSave Clone(ProgressSave value)
|
||||||
|
{
|
||||||
|
var copy = JsonUtility.FromJson<ProgressSave>(JsonUtility.ToJson(value));
|
||||||
|
// JsonUtility recreates a null nested serializable class; the store contract keeps null.
|
||||||
|
if (value.latestFreePlay == null) copy.latestFreePlay = null;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
private static BallState Ball(string id, float x) => new BallState { id = id, position = new Position3(x, 5, 0) };
|
||||||
|
private static GroupState Group(string id, params string[] balls) => new GroupState { id = id, ballIds = balls };
|
||||||
|
[UnitySetUp] public IEnumerator Setup()
|
||||||
|
{
|
||||||
|
foreach (var listener in Object.FindObjectsByType<AudioListener>(FindObjectsSortMode.None)) listener.enabled = false;
|
||||||
|
_app = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab")).GetComponent<GameApp>();
|
||||||
|
_store = new Store { Save = new ProgressSave { completedStageIds = Enumerable.Range(1, 30).ToArray(), seenTutorialIds = new[] { "merge", "split" } } };
|
||||||
|
_app.Initialize(_store);
|
||||||
|
_app.Presenter.BindEnvironment(_app.Environment);
|
||||||
|
_app.BindRestoreGeometry(AssetDatabase.LoadAssetAtPath<PlaygroundRestoreGeometry>("Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundRestoreGeometry.asset"));
|
||||||
|
yield return null;
|
||||||
|
}
|
||||||
|
[UnityTearDown] public IEnumerator Teardown() { foreach (var fixture in _fixtures) Object.Destroy(fixture); _fixtures.Clear(); Object.Destroy(_app.gameObject); yield return null; }
|
||||||
|
private void Reload() { _app.Initialize(_store); _app.Presenter.BindEnvironment(_app.Environment); }
|
||||||
|
private TMP_Text Text(string field) => (TMP_Text)new SerializedObject(_app).FindProperty(field).objectReferenceValue;
|
||||||
|
private BoxCollider Box(Vector3 center, Vector3 size)
|
||||||
|
{
|
||||||
|
var obstacle = new GameObject("test-only-restore-obstacle"); _fixtures.Add(obstacle); obstacle.transform.position = center;
|
||||||
|
var box = obstacle.AddComponent<BoxCollider>(); box.size = size; Physics.SyncTransforms(); return box;
|
||||||
|
}
|
||||||
|
private void Free(Vector3 point)
|
||||||
|
{
|
||||||
|
_store.Save.latestFreePlay = new FreePlayState { sourceStageId = 6,
|
||||||
|
balls = new[] { new BallState { id = "legacy-free", position = BoardPresenter.ToPosition(point) } },
|
||||||
|
groups = new[] { Group("legacy-free-group", "legacy-free") }, camera = GameSession.DefaultCamera() };
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
private void MeshDiagnostic(MeshCollider collider, Vector3 source, Vector3 candidate)
|
||||||
|
{
|
||||||
|
var cache = AssetDatabase.LoadAssetAtPath<PlaygroundRestoreGeometry>("Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundRestoreGeometry.asset");
|
||||||
|
Assert.That(cache.TryGet(collider.sharedMesh, out var vertices, out var triangles), Is.True);
|
||||||
|
using (var dataArray = MeshUtility.AcquireReadOnlyMeshData(collider.sharedMesh))
|
||||||
|
{
|
||||||
|
var data = dataArray[0]; using var buffer = new NativeArray<Vector3>(data.vertexCount, Allocator.Temp); data.GetVertices(buffer);
|
||||||
|
Assert.That(vertices.SequenceEqual(buffer.ToArray()), Is.True, "Cache local vertices must equal actual imported collider MeshData.");
|
||||||
|
var indices = data.indexFormat == UnityEngine.Rendering.IndexFormat.UInt16 ? data.GetIndexData<ushort>().ToArray().Select(index => (int)index).ToArray() : data.GetIndexData<uint>().ToArray().Select(index => (int)index).ToArray();
|
||||||
|
var actual = Enumerable.Range(0, data.subMeshCount).SelectMany(submesh => { var descriptor = data.GetSubMesh(submesh); return Enumerable.Range(descriptor.indexStart, descriptor.indexCount).Select(index => indices[index] + descriptor.baseVertex); }).ToArray();
|
||||||
|
Assert.That(triangles.SequenceEqual(actual), Is.True, "Cache triangle indices must equal actual imported collider MeshData.");
|
||||||
|
}
|
||||||
|
foreach (var point in new[] { source, candidate })
|
||||||
|
{
|
||||||
|
float closest = float.PositiveInfinity; Vector3 surface = Vector3.zero; int closestTriangle = -1;
|
||||||
|
for (int index = 0; index < triangles.Length; index += 3)
|
||||||
|
{
|
||||||
|
var a = collider.transform.TransformPoint(vertices[triangles[index]]); var b = collider.transform.TransformPoint(vertices[triangles[index+1]]); var c = collider.transform.TransformPoint(vertices[triangles[index+2]]);
|
||||||
|
var normal = Vector3.Cross(b-a,c-a); if (normal.sqrMagnitude < .000000001f) continue;
|
||||||
|
var projected = point - normal * (Vector3.Dot(point-a,normal)/normal.sqrMagnitude);
|
||||||
|
var candidates = new List<Vector3>();
|
||||||
|
if (Vector3.Dot(Vector3.Cross(b-a,projected-a),normal)>=0 && Vector3.Dot(Vector3.Cross(c-b,projected-b),normal)>=0 && Vector3.Dot(Vector3.Cross(a-c,projected-c),normal)>=0) candidates.Add(projected);
|
||||||
|
foreach (var edge in new[] { (a,b), (b,c), (c,a) }) candidates.Add(edge.Item1 + (edge.Item2-edge.Item1) * Mathf.Clamp01(Vector3.Dot(point-edge.Item1,edge.Item2-edge.Item1)/(edge.Item2-edge.Item1).sqrMagnitude));
|
||||||
|
foreach (var nearest in candidates) if (Vector3.Distance(point,nearest)<closest) { closest=Vector3.Distance(point,nearest);surface=nearest;closestTriangle=index/3; }
|
||||||
|
}
|
||||||
|
bool original = Physics.queriesHitBackfaces;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var crossings = _app.Environment.GetType().GetMethod("SurfaceCrossings", System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic);
|
||||||
|
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=(int)crossings.Invoke(_app.Environment,new object[]{collider,point,direction});
|
||||||
|
Physics.queriesHitBackfaces=true; int total=(int)crossings.Invoke(_app.Environment,new object[]{collider,point,direction});
|
||||||
|
Debug.Log($"[ProgressRestoreMeshDiagnostic] point={point:R}, direction={direction:R}, nativeEntries={entries}, nativeCrossings={total}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {Physics.queriesHitBackfaces=original;}
|
||||||
|
Debug.Log($"[ProgressRestoreMeshDiagnostic] mesh={collider.sharedMesh.name}, point={point:R}, closestSurface={surface:R}, distance={closest:R}, triangle={closestTriangle}, position={collider.transform.position:R}, rotation={collider.transform.rotation:R}, scale={collider.transform.lossyScale:R}, vertices={vertices.Length}, triangles={triangles.Length/3}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator CountingResumeRepairsTheWholeGroupWithoutCollectingOrCompletingAndWritesOnce()
|
||||||
|
{
|
||||||
|
var puzzle = new PuzzleState { stageId = 2, balls = new[] { Ball("old-a", 30), Ball("old-b", 31) },
|
||||||
|
groups = new[] { Group("old-group", "old-a", "old-b") }, collectedBallIds = new[] { "old-a" },
|
||||||
|
countedBallIds = new[] { "old-a" }, camera = GameSession.DefaultCamera() };
|
||||||
|
_store.Save.puzzleStates = new[] { puzzle }; Reload();
|
||||||
|
var progress = (int[])_store.Save.completedStageIds.Clone(); int before = _store.Writes;
|
||||||
|
_app.StartStage(2); yield return null;
|
||||||
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True, "Actual GameApp must preflight legacy positions before displaying/saving them.");
|
||||||
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle));
|
||||||
|
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EqualTo(new[] { "old-a", "old-b" }));
|
||||||
|
Assert.That(_app.Session.Board.Groups.Single().id, Is.EqualTo("old-group"));
|
||||||
|
Assert.That(_app.Session.Board.CountedBallIds, Is.EqualTo(puzzle.countedBallIds));
|
||||||
|
Assert.That(_app.Session.Board.CollectedBallIds, Is.EqualTo(puzzle.collectedBallIds));
|
||||||
|
var positions = _app.Session.Board.Balls.Select(ball => BoardPresenter.ToVector(ball.position)).ToArray();
|
||||||
|
Assert.That(positions[1] - positions[0], Is.EqualTo(Vector3.right));
|
||||||
|
Assert.That(positions[1].x, Is.EqualTo(_app.Environment.PlaygroundBounds.max.x - .5f).Within(.002f));
|
||||||
|
Assert.That(_store.Writes, Is.EqualTo(before + 1));
|
||||||
|
Assert.That(_store.Save.completedStageIds, Is.EqualTo(progress));
|
||||||
|
Assert.That(_app.Instruction, Is.EqualTo("친구를 놀이터로 다시 보냈어요."));
|
||||||
|
foreach (char character in _app.Instruction) Assert.That(Text("_instruction").font.HasCharacter(character), Is.True, "Approved static atlas must contain every restore character.");
|
||||||
|
_app.ShowHome(); _app.StartStage(2); yield return null;
|
||||||
|
Assert.That(_app.Instruction, Is.Not.EqualTo("친구를 놀이터로 다시 보냈어요."));
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator BoxInteriorMovesToTheNearestActualSafeSurfaceRatherThanAFullBoundaryEscape()
|
||||||
|
{
|
||||||
|
_app.StartStage(6); Box(new Vector3(0, 5, 0), new Vector3(2, 2, 2)); Free(new Vector3(0, 5, 0));
|
||||||
|
_app.ContinueFreePlay(); yield return null;
|
||||||
|
var restored = BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position);
|
||||||
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
||||||
|
Assert.That(Vector3.Distance(restored, new Vector3(0, 5, 0)), Is.InRange(1.498f, 1.501f));
|
||||||
|
Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo("legacy-free"));
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator NonReadableAsymmetricClosedMeshUsesCachedTrianglesAndFindsItsNearestSafeFace()
|
||||||
|
{
|
||||||
|
_app.StartStage(6);
|
||||||
|
foreach (var collider in _app.Environment.GetComponentsInChildren<Collider>()) if (!collider.isTrigger && !collider.name.Contains("Clearing")) collider.enabled = false;
|
||||||
|
var obstacle = new GameObject("test-only-asymmetric-closed-mesh"); _fixtures.Add(obstacle);
|
||||||
|
obstacle.transform.SetParent(_app.Environment.transform, true);
|
||||||
|
obstacle.transform.SetPositionAndRotation(new Vector3(0, 3, 0), Quaternion.Euler(0, 31, 0));
|
||||||
|
var vertices = new[] { new Vector3(-1,-2,-3),new Vector3(1,-2,-3),new Vector3(1,2,-3),new Vector3(-1,2,-3),
|
||||||
|
new Vector3(-1,-2,3),new Vector3(1,-2,3),new Vector3(1,2,3),new Vector3(-1,2,3) };
|
||||||
|
var triangles = new[] { 0,2,1,0,3,2,4,5,6,4,6,7,0,1,5,0,5,4,3,7,6,3,6,2,0,4,7,0,7,3,1,2,6,1,6,5 };
|
||||||
|
var mesh = new Mesh { vertices = vertices, triangles = triangles }; mesh.RecalculateBounds(); _fixtures.Add(mesh);
|
||||||
|
var colliderMesh = obstacle.AddComponent<MeshCollider>(); colliderMesh.sharedMesh = mesh;
|
||||||
|
var original = AssetDatabase.LoadAssetAtPath<PlaygroundRestoreGeometry>("Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundRestoreGeometry.asset");
|
||||||
|
var cache = Object.Instantiate(original); _fixtures.Add(cache);
|
||||||
|
var entries = new List<PlaygroundRestoreGeometry.Entry>();
|
||||||
|
foreach (var actual in _app.Environment.GetComponentsInChildren<MeshCollider>())
|
||||||
|
if (original.TryGet(actual.sharedMesh, out var sourceVertices, out var sourceTriangles)) entries.Add(new PlaygroundRestoreGeometry.Entry {
|
||||||
|
mesh = actual.sharedMesh, vertices = sourceVertices, triangles = sourceTriangles, bounds = actual.sharedMesh.bounds,
|
||||||
|
geometrySha256 = PlaygroundRestoreGeometry.Hash(sourceVertices, sourceTriangles) });
|
||||||
|
entries.Add(new PlaygroundRestoreGeometry.Entry { mesh = mesh, vertices = vertices, triangles = triangles, bounds = mesh.bounds,
|
||||||
|
geometrySha256 = PlaygroundRestoreGeometry.Hash(vertices, triangles) }); cache.SetEntries(entries.ToArray());
|
||||||
|
mesh.UploadMeshData(true); Assert.That(mesh.isReadable, Is.False);
|
||||||
|
_app.BindRestoreGeometry(cache); Physics.SyncTransforms(); Free(obstacle.transform.position);
|
||||||
|
_app.ContinueFreePlay(); yield return null;
|
||||||
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
||||||
|
var delta = BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position) - obstacle.transform.position;
|
||||||
|
Assert.That(delta.magnitude, Is.InRange(1.498f, 1.501f));
|
||||||
|
Assert.That(Mathf.Abs(Vector3.Dot(delta.normalized, obstacle.transform.right)), Is.GreaterThan(.999f));
|
||||||
|
Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo("legacy-free"));
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator NearestCorrectionAccountsForAnotherObstacleAcrossAnOtherwiseSafeEscapeInterval()
|
||||||
|
{
|
||||||
|
_app.StartStage(6);
|
||||||
|
Box(new Vector3(0,5,0),new Vector3(2,2,2)); Box(new Vector3(-1.7f,5,0),new Vector3(.2f,2,.4f));
|
||||||
|
var point = new Vector3(-.8f,5,0); Free(point); _app.ContinueFreePlay(); yield return null;
|
||||||
|
Assert.That(_app.Session.Screen,Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board),Is.True);
|
||||||
|
float expected = Mathf.Sqrt(.699f*.699f + Mathf.Pow(.2f+Mathf.Sqrt(.499f*.499f-.101f*.101f),2));
|
||||||
|
float distance = Vector3.Distance(point,BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position));
|
||||||
|
Assert.That(distance,Is.InRange(expected-.003f,expected+.011f));
|
||||||
|
Assert.That(_app.Session.Board.Balls.Single().id,Is.EqualTo("legacy-free"));
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator ExhaustiveTerrainCellExclusionCanResetWithoutMistakingACappedSearchForProof()
|
||||||
|
{
|
||||||
|
var state = new PuzzleState { stageId=2, balls=new[] {new BallState{id="covered-a",position=new Position3(-8,.05f,-6)},new BallState{id="covered-b",position=new Position3(8,6,6)}},
|
||||||
|
groups=new[]{Group("covered-group","covered-a","covered-b")},camera=GameSession.DefaultCamera()};
|
||||||
|
_store.Save.puzzleStates=new[]{state};
|
||||||
|
// Neither solid contains the entire center domain. Their actual sphere-clearance excludes every cell.
|
||||||
|
Box(new Vector3(-8.075f,.05f,-6),new Vector3(.15f,.3f,.3f)); Box(new Vector3(-7.925f,.05f,-6),new Vector3(.15f,.3f,.3f));
|
||||||
|
Reload();int before=_store.Writes;_app.StartStage(2);yield return null;
|
||||||
|
Assert.That(_app.Session.Board.Balls.Select(ball=>ball.id),Is.EqualTo(new[]{"s02-b01","s02-b02"}));
|
||||||
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board),Is.True);Assert.That(_store.Writes,Is.EqualTo(before+1));
|
||||||
|
Assert.That(_app.Instruction,Is.EqualTo("친구가 놀이터에 들어가지 못해서 처음부터 다시 시작해요."));
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator UnsupportedSolidSearchFailureKeepsTheRecordWithoutResettingOrWriting()
|
||||||
|
{
|
||||||
|
_app.StartStage(6);
|
||||||
|
foreach (var collider in _app.Environment.GetComponentsInChildren<Collider>()) if (!collider.isTrigger) collider.enabled = false;
|
||||||
|
var obstacle = new GameObject("test-only-unsupported-restore-solid"); _fixtures.Add(obstacle);
|
||||||
|
obstacle.transform.position = new Vector3(0,3,0); var capsule = obstacle.AddComponent<CapsuleCollider>(); capsule.radius = 50; capsule.height = 100;
|
||||||
|
Free(new Vector3(0,3,0)); string saved = JsonUtility.ToJson(_store.Save); int before = _store.Writes;
|
||||||
|
_app.ContinueFreePlay(); yield return null;
|
||||||
|
Assert.That(_app.Session.Screen,Is.EqualTo(SessionScreen.Home)); Assert.That(_app.Session.Board,Is.Null);
|
||||||
|
Assert.That(_store.Writes,Is.EqualTo(before)); Assert.That(JsonUtility.ToJson(_store.Save),Is.EqualTo(saved));
|
||||||
|
Assert.That(_app.Instruction,Is.Not.EqualTo("친구가 놀이터에 들어가지 못해서 처음부터 다시 시작해요."));
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator CorruptGeometryChecksumKeepsTheExistingRecordInsteadOfTreatingSearchFailureAsImpossible()
|
||||||
|
{
|
||||||
|
_app.StartStage(6); var cache = Object.Instantiate(AssetDatabase.LoadAssetAtPath<PlaygroundRestoreGeometry>("Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundRestoreGeometry.asset")); _fixtures.Add(cache);
|
||||||
|
var serialized = new SerializedObject(cache); var entries = serialized.FindProperty("_entries");
|
||||||
|
for (int index = 0; index < entries.arraySize; index++) entries.GetArrayElementAtIndex(index).FindPropertyRelative("geometrySha256").stringValue = "corrupt";
|
||||||
|
serialized.ApplyModifiedPropertiesWithoutUndo(); _app.BindRestoreGeometry(cache);
|
||||||
|
Free(new Vector3(30, 5, 0)); string saved = JsonUtility.ToJson(_store.Save); int before = _store.Writes;
|
||||||
|
_app.ContinueFreePlay(); yield return null;
|
||||||
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Home)); Assert.That(_app.Session.Board, Is.Null);
|
||||||
|
Assert.That(_store.Writes, Is.EqualTo(before)); Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved));
|
||||||
|
Assert.That(_app.Instruction, Is.Not.EqualTo("친구가 놀이터에 들어가지 못해서 처음부터 다시 시작해요."));
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator ApprovedUnreadableRockAndOverlappingTreeDeepInteriorsResumeThroughTheRuntimeCache()
|
||||||
|
{
|
||||||
|
_app.StartStage(6);
|
||||||
|
foreach (var collider in _app.Environment.GetComponentsInChildren<Collider>()) if (!collider.isTrigger && !collider.name.Contains("Clearing")) collider.enabled = false;
|
||||||
|
foreach (string name in new[] { "SlateBoulder", "RoundCanopyTree" })
|
||||||
|
{
|
||||||
|
var obstacle = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/" + name + ".prefab"), _app.Environment.transform);
|
||||||
|
_fixtures.Add(obstacle); obstacle.transform.localScale = Vector3.one * (name == "SlateBoulder" ? 3 : 2);
|
||||||
|
var collider = obstacle.GetComponentInChildren<MeshCollider>(); Physics.SyncTransforms();
|
||||||
|
obstacle.transform.position += new Vector3(0, 3, 0) - collider.bounds.center; Physics.SyncTransforms();
|
||||||
|
Assert.That(collider.sharedMesh.isReadable, Is.False, "Actual approved imports stay non-readable.");
|
||||||
|
Vector3 point = collider.bounds.center; bool found = !_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point));
|
||||||
|
for (float y = Mathf.Max(.6f, collider.bounds.min.y + .5f); !found && y < Mathf.Min(5.8f, collider.bounds.max.y - .5f); y += .1f)
|
||||||
|
{ point = new Vector3(collider.bounds.center.x, y, collider.bounds.center.z); found = !_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point)); }
|
||||||
|
Assert.That(found, Is.True, "Boundary injection must start inside the actual approved solid.");
|
||||||
|
Free(point); int before = _store.Writes;
|
||||||
|
var elapsed = System.Diagnostics.Stopwatch.StartNew(); _app.ContinueFreePlay(); elapsed.Stop(); yield return null;
|
||||||
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay), name + " must repair rather than merely retain the unsafe legacy save.");
|
||||||
|
Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo("legacy-free")); Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
||||||
|
Assert.That(_store.Writes, Is.EqualTo(before + 1));
|
||||||
|
float distance = Vector3.Distance(point, BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position));
|
||||||
|
float sampledUpperBound = float.PositiveInfinity;
|
||||||
|
Vector3 sampledPoint = point;
|
||||||
|
// Independent actual-physics rays sample every radial interval; no binary assumption about solid gaps.
|
||||||
|
for (int index = 0; index < 96; index++)
|
||||||
|
{
|
||||||
|
float y = 1 - 2 * (index + .5f) / 96; float angle = index * 2.39996323f;
|
||||||
|
var direction = new Vector3(Mathf.Sqrt(1-y*y)*Mathf.Cos(angle), y, Mathf.Sqrt(1-y*y)*Mathf.Sin(angle));
|
||||||
|
for (float radius = .02f; radius <= Mathf.Min(4, sampledUpperBound); radius += .02f)
|
||||||
|
if (_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point + direction * radius))) { sampledUpperBound = radius; sampledPoint = point + direction * radius; break; }
|
||||||
|
}
|
||||||
|
var sphere = _app.Presenter.Balls["legacy-free"].GetComponent<SphereCollider>();
|
||||||
|
bool penetration = Physics.ComputePenetration(sphere, sampledPoint, Quaternion.identity, collider, collider.transform.position, collider.transform.rotation, out _, out float depth);
|
||||||
|
bool overlap = Physics.OverlapSphere(sampledPoint, .5f, ~0, QueryTriggerInteraction.Ignore).Contains(collider);
|
||||||
|
bool ray = collider.Raycast(new Ray(sampledPoint, (point - sampledPoint).normalized), out var hit, 6);
|
||||||
|
Debug.Log($"[ProgressRestoreActual] approved={name}, utc={System.DateTime.UtcNow:O}, ms={elapsed.ElapsedMilliseconds}, source={point:R}, restoredDistance={distance:R}, sampledSafe={sampledPoint:R}, radialUpperBound={sampledUpperBound:R}, overlap={overlap}, penetration={penetration}, depth={depth:R}, inwardRay={ray}, surfaceDistance={hit.distance:R}");
|
||||||
|
MeshDiagnostic(collider,point,sampledPoint);
|
||||||
|
Assert.That(distance, Is.LessThanOrEqualTo(sampledUpperBound + .011f));
|
||||||
|
Debug.Log($"[ProgressRestoreActual] approved={name}, utc={System.DateTime.UtcNow:O}, ms={elapsed.ElapsedMilliseconds}, distance={distance:R}, independentRadialUpperBound={sampledUpperBound:R}");
|
||||||
|
obstacle.SetActive(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator ClosedBoxCoversEveryRigidCandidateButInitialPuzzleFitsAndOnlyAffectedSlotResets()
|
||||||
|
{
|
||||||
|
var state = new PuzzleState { stageId = 2,
|
||||||
|
balls = new[] { new BallState { id = "blocked-a", position = new Position3(-8, .05f, -6) }, new BallState { id = "blocked-b", position = new Position3(8, 6, 6) } },
|
||||||
|
groups = new[] { Group("blocked-group", "blocked-a", "blocked-b") },
|
||||||
|
collectedBallIds = new[] { "blocked-a" }, countedBallIds = new[] { "blocked-a" }, camera = new CameraState { distance = 12 } };
|
||||||
|
_store.Save.puzzleStates = new[] { new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 1)).ToPuzzleState(GameSession.DefaultCamera()), state,
|
||||||
|
new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 6)).ToPuzzleState(GameSession.DefaultCamera()) };
|
||||||
|
_store.Save.latestFreePlay = new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 6)).ToFreePlayState(GameSession.DefaultCamera());
|
||||||
|
string otherOne = JsonUtility.ToJson(_store.Save.puzzleStates[0]), otherSix = JsonUtility.ToJson(_store.Save.puzzleStates[2]), free = JsonUtility.ToJson(_store.Save.latestFreePlay);
|
||||||
|
Box(new Vector3(-8, .05f, -6), Vector3.one * .3f); Reload(); int before = _store.Writes;
|
||||||
|
_app.StartStage(2); yield return null;
|
||||||
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
||||||
|
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EqualTo(new[] { "s02-b01", "s02-b02" }));
|
||||||
|
Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty); Assert.That(_app.Session.Board.CountedBallIds, Is.Empty);
|
||||||
|
Assert.That(_store.Writes, Is.EqualTo(before + 1));
|
||||||
|
Assert.That(JsonUtility.ToJson(_store.Save.puzzleStates[0]), Is.EqualTo(otherOne)); Assert.That(JsonUtility.ToJson(_store.Save.puzzleStates[2]), Is.EqualTo(otherSix));
|
||||||
|
Assert.That(JsonUtility.ToJson(_store.Save.latestFreePlay), Is.EqualTo(free));
|
||||||
|
Assert.That(_app.Session.Camera.distance, Is.EqualTo(8));
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator MissingCacheCannotResetAnImpossibleBoardAndFailedWriteRestoresPreviousStageAndCamera()
|
||||||
|
{
|
||||||
|
_store.Save.puzzleStates = new[] { new PuzzleState { stageId = 2, balls = new[] { Ball("wide-a", -30), Ball("wide-b", 30) },
|
||||||
|
groups = new[] { Group("wide-group", "wide-a", "wide-b") }, camera = GameSession.DefaultCamera() } };
|
||||||
|
Reload(); _app.StartStage(6);
|
||||||
|
var manual = new CameraState { target = new Position3(0, .6f, 0), rotation = new Rotation4(0, 0, 0, 1), distance = 11 };
|
||||||
|
_app.Session.CommitCamera(manual); _app.Orbit.Apply(manual);
|
||||||
|
var current = _app.Session.Board; string theme = _app.Environment.Theme, saved = JsonUtility.ToJson(_store.Save); int before = _store.Writes;
|
||||||
|
_app.BindRestoreGeometry(null); _app.StartStage(2); yield return null;
|
||||||
|
Assert.That(_app.Session.Board, Is.SameAs(current)); Assert.That(_app.Environment.Theme, Is.EqualTo(theme));
|
||||||
|
Assert.That(_store.Writes, Is.EqualTo(before)); Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved));
|
||||||
|
Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(JsonUtility.ToJson(manual)));
|
||||||
|
_app.BindRestoreGeometry(AssetDatabase.LoadAssetAtPath<PlaygroundRestoreGeometry>("Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundRestoreGeometry.asset"));
|
||||||
|
_store.FailWrites = true; _app.StartStage(2); yield return null;
|
||||||
|
Assert.That(_app.Session.Board, Is.SameAs(current)); Assert.That(_app.Environment.Theme, Is.EqualTo(theme));
|
||||||
|
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); Assert.That(_store.Writes, Is.EqualTo(before + 1));
|
||||||
|
Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(JsonUtility.ToJson(manual)));
|
||||||
|
Assert.That(Text("_saveNotice").text, Is.EqualTo("지금은 저장하지 못했어요. 다시 해볼게요."));
|
||||||
|
Assert.That(_app.Instruction, Is.Not.EqualTo("친구가 놀이터에 들어가지 못해서 처음부터 다시 시작해요."));
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator RepairedUnseenTutorialDoesNotChangeTutorialRecordAddAWriteOrOverwriteTheNotice()
|
||||||
|
{
|
||||||
|
_store.Save.completedStageIds = Enumerable.Range(1, 5).ToArray(); _store.Save.seenTutorialIds = new string[0];
|
||||||
|
_store.Save.puzzleStates = new[] { new PuzzleState { stageId = 6, balls = new[] { Ball("demo-a", 30), Ball("demo-b", 33) },
|
||||||
|
groups = new[] { Group("demo-first", "demo-a"), Group("demo-second", "demo-b") }, camera = GameSession.DefaultCamera() } };
|
||||||
|
Reload(); int before = _store.Writes; _app.StartStage(6); yield return null;
|
||||||
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
||||||
|
Assert.That(_store.Writes, Is.EqualTo(before + 1)); Assert.That(_store.Save.seenTutorialIds, Is.Empty);
|
||||||
|
Assert.That(_app.Instruction, Is.EqualTo("친구를 놀이터로 다시 보냈어요."));
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator TargetSceneryReplacesInactivePreviousStageCollidersBeforeRestorePreflight()
|
||||||
|
{
|
||||||
|
_app.StartStage(6); _app.ShowHome();
|
||||||
|
var stale = Box(new Vector3(0, 3, 0), new Vector3(40, 40, 40)); stale.transform.SetParent(_app.Environment.transform, true);
|
||||||
|
_store.Save.puzzleStates = new[] { new PuzzleState { stageId = 2, balls = new[] { Ball("target-a", 30), Ball("target-b", 31) },
|
||||||
|
groups = new[] { Group("target-group", "target-a", "target-b") }, camera = GameSession.DefaultCamera() } };
|
||||||
|
Reload(); _app.StartStage(2); yield return null;
|
||||||
|
Assert.That(_app.Session.Board.StageId, Is.EqualTo(2)); Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
||||||
|
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EqualTo(new[] { "target-a", "target-b" }));
|
||||||
|
Assert.That(_app.Environment.gameObject.activeInHierarchy, Is.True);
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator SafeResumeKeepsExactPuzzleFreeProgressAndManualCamera()
|
||||||
|
{
|
||||||
|
var definition = _app.Session.Definitions.First(stage => stage.Id == 2);
|
||||||
|
var camera = new CameraState { target = new Position3(0, .6f, 0), rotation = new Rotation4(0, 0, 0, 1), distance = 11 };
|
||||||
|
_store.Save.puzzleStates = new[] { new BoardRules(definition).ToPuzzleState(camera) };
|
||||||
|
_store.Save.latestFreePlay = new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 6)).ToFreePlayState(GameSession.DefaultCamera());
|
||||||
|
string expected = JsonUtility.ToJson(_store.Save); Reload();
|
||||||
|
_app.StartStage(2); yield return null;
|
||||||
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
||||||
|
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(expected));
|
||||||
|
Assert.That(JsonUtility.ToJson(_app.Session.Camera), Is.EqualTo(JsonUtility.ToJson(camera)));
|
||||||
|
Assert.That(_app.Instruction, Is.Not.EqualTo("친구를 놀이터로 다시 보냈어요."));
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator RepairableFreeKeepsSourceShapeAndAllOtherSlotsWithNoAutomaticMerge()
|
||||||
|
{
|
||||||
|
_store.Save.puzzleStates = new[] { new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 2)).ToPuzzleState(GameSession.DefaultCamera()),
|
||||||
|
new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 6)).ToPuzzleState(GameSession.DefaultCamera()) };
|
||||||
|
_store.Save.latestFreePlay = new FreePlayState { sourceStageId = 6,
|
||||||
|
balls = new[] { Ball("free-a", 30), Ball("free-b", 31), Ball("free-c", 34) },
|
||||||
|
groups = new[] { Group("free-pair", "free-a", "free-b"), Group("free-single", "free-c") }, camera = GameSession.DefaultCamera() };
|
||||||
|
string firstPuzzle = JsonUtility.ToJson(_store.Save.puzzleStates[0]), secondPuzzle = JsonUtility.ToJson(_store.Save.puzzleStates[1]);
|
||||||
|
Reload(); int before = _store.Writes; _app.ContinueFreePlay(); yield return null;
|
||||||
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
||||||
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay));
|
||||||
|
Assert.That(_app.Session.Board.StageId, Is.EqualTo(6));
|
||||||
|
Assert.That(_app.Session.Board.Groups.Select(group => group.id), Is.EqualTo(new[] { "free-pair", "free-single" }));
|
||||||
|
var balls = _app.Session.Board.Balls.ToDictionary(ball => ball.id, ball => BoardPresenter.ToVector(ball.position));
|
||||||
|
Assert.That(balls["free-b"] - balls["free-a"], Is.EqualTo(Vector3.right));
|
||||||
|
foreach (string pair in new[] { "free-a", "free-b" }) Assert.That(Vector3.Distance(balls[pair], balls["free-c"]), Is.GreaterThanOrEqualTo(.999f));
|
||||||
|
Assert.That(JsonUtility.ToJson(_store.Save.puzzleStates[0]), Is.EqualTo(firstPuzzle));
|
||||||
|
Assert.That(JsonUtility.ToJson(_store.Save.puzzleStates[1]), Is.EqualTo(secondPuzzle));
|
||||||
|
Assert.That(_store.Writes, Is.EqualTo(before + 1));
|
||||||
|
}
|
||||||
|
[UnityTest] public IEnumerator ImpossibleFreeResumeResetsOnlyLatestFreeToSourceInitialAndKeepsPuzzleSlotsAndProgress()
|
||||||
|
{
|
||||||
|
var definition = _app.Session.Definitions.First(stage => stage.Id == 6);
|
||||||
|
var initial = new BoardRules(definition);
|
||||||
|
_store.Save.puzzleStates = new[] { new BoardRules(_app.Session.Definitions.First(stage => stage.Id == 2)).ToPuzzleState(GameSession.DefaultCamera()), initial.ToPuzzleState(GameSession.DefaultCamera()) };
|
||||||
|
_store.Save.latestFreePlay = new FreePlayState { sourceStageId = 6, balls = new[] { Ball("wide-a", -30), Ball("wide-b", 30) },
|
||||||
|
groups = new[] { Group("wide-group", "wide-a", "wide-b") }, camera = new CameraState { distance = 12, target = new Position3(1, 1, 1) } };
|
||||||
|
var puzzles = JsonUtility.ToJson(_store.Save); Reload(); int before = _store.Writes;
|
||||||
|
_app.ContinueFreePlay(); yield return null;
|
||||||
|
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
|
||||||
|
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay));
|
||||||
|
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EqualTo(initial.Balls.Select(ball => ball.id)));
|
||||||
|
Assert.That(_app.Session.Board.Balls.Select(ball => ball.position), Is.EqualTo(initial.Balls.Select(ball => ball.position)));
|
||||||
|
Assert.That(_store.Save.puzzleStates.Length, Is.EqualTo(2));
|
||||||
|
var expected = JsonUtility.FromJson<ProgressSave>(puzzles);
|
||||||
|
Assert.That(JsonUtility.ToJson(_store.Save.puzzleStates[0]), Is.EqualTo(JsonUtility.ToJson(expected.puzzleStates[0])));
|
||||||
|
Assert.That(JsonUtility.ToJson(_store.Save.puzzleStates[1]), Is.EqualTo(JsonUtility.ToJson(expected.puzzleStates[1])));
|
||||||
|
Assert.That(_store.Save.completedStageIds, Is.EqualTo(expected.completedStageIds));
|
||||||
|
Assert.That(_store.Save.seenTutorialIds, Is.EqualTo(expected.seenTutorialIds));
|
||||||
|
Assert.That(_store.Writes, Is.EqualTo(before + 1));
|
||||||
|
Assert.That(_app.Session.Camera.distance, Is.EqualTo(GameSession.DefaultCamera().distance));
|
||||||
|
Assert.That(_app.Instruction, Is.EqualTo("친구가 놀이터에 들어가지 못해서 처음부터 다시 시작해요."));
|
||||||
|
foreach (char character in _app.Instruction) Assert.That(Text("_instruction").font.HasCharacter(character), Is.True);
|
||||||
|
Text("_instruction").ForceMeshUpdate(); Assert.That(Text("_instruction").textBounds.size.x, Is.LessThanOrEqualTo(Text("_instruction").rectTransform.rect.width + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 51217ffa475e6664fbe43e44d14c421f
|
||||||
@@ -43,6 +43,8 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
|
|||||||
[SerializeField] private UnityEngine.UI.Button _nextButton;
|
[SerializeField] private UnityEngine.UI.Button _nextButton;
|
||||||
[SerializeField] private BoardEnvironment _environment;
|
[SerializeField] private BoardEnvironment _environment;
|
||||||
[SerializeField] private GhostDemonstration _demonstration;
|
[SerializeField] private GhostDemonstration _demonstration;
|
||||||
|
[SerializeField] private PlaygroundRestoreGeometry _restoreGeometry;
|
||||||
|
public void BindRestoreGeometry(PlaygroundRestoreGeometry geometry) => _restoreGeometry = geometry;
|
||||||
public GameSession Session { get; private set; }
|
public GameSession Session { get; private set; }
|
||||||
public BoardPresenter Presenter => _presenter;
|
public BoardPresenter Presenter => _presenter;
|
||||||
public BoardInput Input => _input;
|
public BoardInput Input => _input;
|
||||||
@@ -116,8 +118,10 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
|
|||||||
public void StartStage(int id)
|
public void StartStage(int id)
|
||||||
{
|
{
|
||||||
_input.CancelSplit(); _voice.Clear(); _demonstration?.Hide(); bool restored = Session.HasUnfinishedStage(id);
|
_input.CancelSplit(); _voice.Clear(); _demonstration?.Hide(); bool restored = Session.HasUnfinishedStage(id);
|
||||||
if (!Session.StartStage(id)) return;
|
string restoreNotice = null;
|
||||||
|
if (!Session.StartStage(id, (board, camera) => PrepareRestore(board, camera, out restoreNotice))) { Refresh(true); return; }
|
||||||
Refresh(true);
|
Refresh(true);
|
||||||
|
if (restoreNotice != null) { _instruction.text = restoreNotice; return; }
|
||||||
string tutorial = id == 6 ? "merge" : id == 10 ? "split" : null;
|
string tutorial = id == 6 ? "merge" : id == 10 ? "split" : null;
|
||||||
if (tutorial != null && !Session.HasSeenTutorial(tutorial) && _demonstration != null)
|
if (tutorial != null && !Session.HasSeenTutorial(tutorial) && _demonstration != null)
|
||||||
{ Session.MarkTutorialSeen(tutorial); _demonstration.Show(id == 10); _voice.Guide(id == 6 ? "demo_merge6" : "demo_split10"); return; }
|
{ Session.MarkTutorialSeen(tutorial); _demonstration.Show(id == 10); _voice.Guide(id == 6 ? "demo_merge6" : "demo_split10"); return; }
|
||||||
@@ -126,7 +130,38 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
|
|||||||
else if (!restored) _voice.Guide(id == 10 ? "guide_split" : "guide_merge");
|
else if (!restored) _voice.Guide(id == 10 ? "guide_split" : "guide_merge");
|
||||||
}
|
}
|
||||||
public void ContinueFreePlay()
|
public void ContinueFreePlay()
|
||||||
{ _input.CancelSplit(); _voice.Clear(); _demonstration?.Hide(); if (Session.ContinueFreePlay()) Refresh(true); }
|
{
|
||||||
|
_input.CancelSplit(); _voice.Clear(); _demonstration?.Hide(); string restoreNotice = null;
|
||||||
|
bool started = Session.ContinueFreePlay((board, camera) => PrepareRestore(board, camera, out restoreNotice));
|
||||||
|
Refresh(true);
|
||||||
|
if (started && restoreNotice != null) _instruction.text = restoreNotice;
|
||||||
|
}
|
||||||
|
private BoardRestore PrepareRestore(BoardRules board, CameraState camera, out string notice)
|
||||||
|
{
|
||||||
|
notice = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Activate the target scenery before any physics query; a hidden/previous stage cannot validate a restore.
|
||||||
|
_world.SetActive(true);
|
||||||
|
var stage = _stages.First(value => value.StageId == board.StageId);
|
||||||
|
_environment.Apply(stage); Physics.SyncTransforms();
|
||||||
|
var prepared = PlaygroundResume.Prepare(board, Session.Definitions.First(value => value.Id == board.StageId),
|
||||||
|
camera, _presenter, _environment, _restoreGeometry, out notice);
|
||||||
|
if (prepared == null)
|
||||||
|
{
|
||||||
|
_saveNotice.text = "지금은 시작하지 못했어요. 다시 해볼게요.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_orbit.Apply(prepared.Camera);
|
||||||
|
return new BoardRestore(prepared.Board, _orbit.Snapshot(), prepared.ResetToInitial);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[ProgressRestore] " + exception.Message);
|
||||||
|
_saveNotice.text = "지금은 시작하지 못했어요. 다시 해볼게요.";
|
||||||
|
notice = null; return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
public void StartNextStage()
|
public void StartNextStage()
|
||||||
{ if (Session.HasNextStage) StartStage(Session.Board.StageId + 1); }
|
{ if (Session.HasNextStage) StartStage(Session.Board.StageId + 1); }
|
||||||
public void ShowHelp()
|
public void ShowHelp()
|
||||||
@@ -181,6 +216,7 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
|
|||||||
_selectionPanel.SetActive(screen == SessionScreen.StageSelection);
|
_selectionPanel.SetActive(screen == SessionScreen.StageSelection);
|
||||||
bool playing = screen == SessionScreen.Puzzle || screen == SessionScreen.FreePlay;
|
bool playing = screen == SessionScreen.Puzzle || screen == SessionScreen.FreePlay;
|
||||||
_playPanel.SetActive(playing); _world.SetActive(playing);
|
_playPanel.SetActive(playing); _world.SetActive(playing);
|
||||||
|
if (playing) _environment?.Apply(_stages.First(stage => stage.StageId == Session.Board.StageId));
|
||||||
_continueButton.gameObject.SetActive(Session.HasFreePlay);
|
_continueButton.gameObject.SetActive(Session.HasFreePlay);
|
||||||
if (_stageTwoButton != null) _stageTwoButton.interactable = Session.IsUnlocked(2);
|
if (_stageTwoButton != null) _stageTwoButton.interactable = Session.IsUnlocked(2);
|
||||||
for (int index = 0; index < _stageButtons.Length; index++)
|
for (int index = 0; index < _stageButtons.Length; index++)
|
||||||
@@ -198,7 +234,6 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
|
|||||||
if (applyCamera) _orbit.Apply(Session.Camera);
|
if (applyCamera) _orbit.Apply(Session.Camera);
|
||||||
_presenter.Render(Session.Board);
|
_presenter.Render(Session.Board);
|
||||||
if (!playing) { _sound?.Clear(); _sound?.Theme("Menu"); return; }
|
if (!playing) { _sound?.Clear(); _sound?.Theme("Menu"); return; }
|
||||||
_environment?.Apply(_stages.First(stage => stage.StageId == Session.Board.StageId));
|
|
||||||
_sound?.Theme(_stages.First(stage => stage.StageId == Session.Board.StageId).Theme);
|
_sound?.Theme(_stages.First(stage => stage.StageId == Session.Board.StageId).Theme);
|
||||||
_stageTitle.text = screen == SessionScreen.FreePlay ? "자유 놀이터" : $"숫자 {Session.Board.BallCount} 친구 찾기";
|
_stageTitle.text = screen == SessionScreen.FreePlay ? "자유 놀이터" : $"숫자 {Session.Board.BallCount} 친구 찾기";
|
||||||
_counter.text = screen == SessionScreen.FreePlay ? $"친구 {Session.Board.BallCount}" : $"센 친구 {Session.Board.CountedCount} / {Session.Board.BallCount}";
|
_counter.text = screen == SessionScreen.FreePlay ? $"친구 {Session.Board.BallCount}" : $"센 친구 {Session.Board.CountedCount} / {Session.Board.BallCount}";
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bbbd7c6c248fe414da209364fa007ca1
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace MagnetismHeroes.UnityRuntime.Bootstrap
|
||||||
|
{
|
||||||
|
// Read-only derived collision geometry; the approved imported meshes remain non-readable and unchanged.
|
||||||
|
public sealed class PlaygroundRestoreGeometry : ScriptableObject
|
||||||
|
{
|
||||||
|
[Serializable] public sealed class Entry
|
||||||
|
{
|
||||||
|
public Mesh mesh;
|
||||||
|
public Vector3[] vertices;
|
||||||
|
public int[] triangles;
|
||||||
|
public Bounds bounds;
|
||||||
|
public string geometrySha256, sourceSha256, sourceMetaSha256, sourceGuid;
|
||||||
|
}
|
||||||
|
[SerializeField] private Entry[] _entries = Array.Empty<Entry>();
|
||||||
|
public bool TryGet(Mesh mesh, out Vector3[] vertices, out int[] triangles)
|
||||||
|
{
|
||||||
|
vertices = null; triangles = null;
|
||||||
|
var entry = _entries.FirstOrDefault(value => value != null && value.mesh == mesh);
|
||||||
|
if (entry == null || entry.vertices == null || entry.triangles == null || entry.vertices.Length != mesh.vertexCount
|
||||||
|
|| entry.triangles.Length == 0 || entry.triangles.Length % 3 != 0 || entry.bounds != mesh.bounds
|
||||||
|
|| entry.vertices.Any(point => !float.IsFinite(point.x) || !float.IsFinite(point.y) || !float.IsFinite(point.z))
|
||||||
|
|| entry.triangles.Any(index => index < 0 || index >= entry.vertices.Length)
|
||||||
|
|| entry.geometrySha256 != Hash(entry.vertices, entry.triangles)) return false;
|
||||||
|
ulong indices = 0; for (int index = 0; index < mesh.subMeshCount; index++) indices += mesh.GetIndexCount(index);
|
||||||
|
if (indices != (ulong)entry.triangles.Length) return false;
|
||||||
|
vertices = (Vector3[])entry.vertices.Clone(); triangles = (int[])entry.triangles.Clone(); return true;
|
||||||
|
}
|
||||||
|
public static string Hash(Vector3[] vertices, int[] triangles)
|
||||||
|
{
|
||||||
|
using var bytes = new MemoryStream();
|
||||||
|
using (var writer = new BinaryWriter(bytes, System.Text.Encoding.UTF8, true))
|
||||||
|
{
|
||||||
|
writer.Write(vertices.Length);
|
||||||
|
foreach (var point in vertices) { writer.Write(point.x); writer.Write(point.y); writer.Write(point.z); }
|
||||||
|
writer.Write(triangles.Length); foreach (int index in triangles) writer.Write(index);
|
||||||
|
}
|
||||||
|
using var sha = SHA256.Create();
|
||||||
|
return BitConverter.ToString(sha.ComputeHash(bytes.ToArray())).Replace("-", "").ToLowerInvariant();
|
||||||
|
}
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
public void SetEntries(Entry[] entries) => _entries = entries;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 15dba9ff95d33b74db1572556cc79b2b
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using MagnetismHeroes.Core;
|
||||||
|
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace MagnetismHeroes.UnityRuntime.Bootstrap
|
||||||
|
{
|
||||||
|
public static class PlaygroundResume
|
||||||
|
{
|
||||||
|
private const float Radius = .5f;
|
||||||
|
private const float Margin = Radius - .001f;
|
||||||
|
private const int SearchLimit = 8192;
|
||||||
|
private const float NearestTolerance = .01f;
|
||||||
|
|
||||||
|
public static BoardRestore Prepare(BoardRules board, StageDefinition definition, CameraState camera,
|
||||||
|
BoardPresenter presenter, BoardEnvironment environment, PlaygroundRestoreGeometry geometry, out string notice)
|
||||||
|
{
|
||||||
|
notice = null;
|
||||||
|
Physics.SyncTransforms();
|
||||||
|
var balls = board.Balls.ToDictionary(ball => ball.id, ball => BoardPresenter.ToVector(ball.position));
|
||||||
|
var groups = board.Groups;
|
||||||
|
if (balls.Values.All(point => environment.IsSafeSphere(point, presenter.transform)) && SeparateGroups(groups, balls)) return new BoardRestore(board, camera);
|
||||||
|
if (geometry == null) { Debug.LogWarning("[ProgressRestore] Missing restore geometry; existing record retained."); return null; }
|
||||||
|
var bounds = environment.PlaygroundBounds;
|
||||||
|
var obstacles = Physics.OverlapBox(bounds.center, bounds.extents + Vector3.one * Radius, Quaternion.identity, ~0, QueryTriggerInteraction.Ignore)
|
||||||
|
.Where(collider => collider.enabled && collider.gameObject.activeInHierarchy && !collider.isTrigger
|
||||||
|
&& !collider.transform.IsChildOf(presenter.transform)).ToArray();
|
||||||
|
var meshes = new Dictionary<MeshCollider, RestoreMesh>();
|
||||||
|
foreach (var mesh in obstacles.OfType<MeshCollider>())
|
||||||
|
{
|
||||||
|
if (!geometry.TryGet(mesh.sharedMesh, out var vertices, out var triangles))
|
||||||
|
{ Debug.LogWarning("[ProgressRestore] Invalid restore geometry; existing record retained."); return null; }
|
||||||
|
meshes.Add(mesh, new RestoreMesh(mesh, vertices, triangles));
|
||||||
|
}
|
||||||
|
var domains = new Dictionary<string, Bounds>();
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var points = group.ballIds.Select(id => balls[id]).ToArray();
|
||||||
|
var min = points.Aggregate(Vector3.Min); var max = points.Aggregate(Vector3.Max);
|
||||||
|
var lower = bounds.min + Vector3.one * Margin - min;
|
||||||
|
var upper = bounds.max - Vector3.one * Margin - max;
|
||||||
|
if (lower.x > upper.x || lower.y > upper.y || lower.z > upper.z)
|
||||||
|
return Reset(board, definition, presenter, environment, out notice);
|
||||||
|
var domain = new Bounds((lower + upper) * .5f, upper - lower); domains.Add(group.id, domain);
|
||||||
|
// This proves obstruction for every rigid translation, rather than inferring it from failed samples.
|
||||||
|
if (points.Any(point => obstacles.OfType<BoxCollider>().Any(box => CoversCenterDomain(box, point, domain))))
|
||||||
|
return Reset(board, definition, presenter, environment, out notice);
|
||||||
|
}
|
||||||
|
var translations = new Dictionary<string, Position3>();
|
||||||
|
var occupied = new List<Vector3>();
|
||||||
|
var pending = new List<GroupState>();
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var points = group.ballIds.Select(id => balls[id]).ToArray();
|
||||||
|
if (points.All(point => environment.IsSafeSphere(point, presenter.transform)) && Clear(points, occupied)) occupied.AddRange(points);
|
||||||
|
else pending.Add(group);
|
||||||
|
}
|
||||||
|
var probeObject = new GameObject("RestorePlacementProbe");
|
||||||
|
var probe = probeObject.AddComponent<SphereCollider>(); probe.radius = Radius; probe.isTrigger = true; probeObject.layer = 2;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var group in pending.OrderByDescending(group => group.ballIds.Length).ThenBy(group => group.id, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var points = group.ballIds.Select(id => balls[id]).ToArray();
|
||||||
|
if (!TryNearestTranslation(points, domains[group.id], occupied, obstacles, meshes, probe, presenter, environment, out var delta, out bool noPlacement))
|
||||||
|
{
|
||||||
|
if (noPlacement) return Reset(board, definition, presenter, environment, out notice);
|
||||||
|
Debug.LogWarning("[ProgressRestore] Placement search inconclusive; existing record retained.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
translations.Add(group.id, BoardPresenter.ToPosition(delta));
|
||||||
|
occupied.AddRange(points.Select(point => point + delta));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { probeObject.SetActive(false); UnityEngine.Object.Destroy(probeObject); }
|
||||||
|
if (!board.TryTranslateForRestore(translations, out var prepared) || !prepared.Balls.All(ball => environment.IsSafeSphere(BoardPresenter.ToVector(ball.position), presenter.transform))
|
||||||
|
|| !SeparateGroups(prepared.Groups, prepared.Balls.ToDictionary(ball => ball.id, ball => BoardPresenter.ToVector(ball.position)))) return null;
|
||||||
|
notice = "친구를 놀이터로 다시 보냈어요.";
|
||||||
|
return new BoardRestore(prepared, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BoardRestore Reset(BoardRules board, StageDefinition definition, BoardPresenter presenter, BoardEnvironment environment, out string notice)
|
||||||
|
{
|
||||||
|
notice = null;
|
||||||
|
var initial = new BoardRules(definition);
|
||||||
|
if (board.Mode == BoardMode.FreePlay) initial = BoardRules.CreateFreePlay(initial.ToFreePlayState(GameSession.DefaultCamera()));
|
||||||
|
if (!initial.Balls.All(ball => environment.IsSafeSphere(BoardPresenter.ToVector(ball.position), presenter.transform)))
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[ProgressRestore] Authored initial layout is unsafe; existing record retained.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
notice = "친구가 놀이터에 들어가지 못해서 처음부터 다시 시작해요.";
|
||||||
|
return new BoardRestore(initial, GameSession.DefaultCamera(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CoversCenterDomain(BoxCollider box, Vector3 point, Bounds translations)
|
||||||
|
{
|
||||||
|
var center = box.transform.TransformPoint(box.center); var inverse = Quaternion.Inverse(box.transform.rotation);
|
||||||
|
var scale = box.transform.lossyScale; var half = Vector3.Scale(box.size * .5f, new Vector3(Mathf.Abs(scale.x), Mathf.Abs(scale.y), Mathf.Abs(scale.z)));
|
||||||
|
var min = -half; var max = half;
|
||||||
|
for (int corner = 0; corner < 8; corner++)
|
||||||
|
{
|
||||||
|
var world = point + new Vector3((corner & 1) == 0 ? translations.min.x : translations.max.x,
|
||||||
|
(corner & 2) == 0 ? translations.min.y : translations.max.y,
|
||||||
|
(corner & 4) == 0 ? translations.min.z : translations.max.z);
|
||||||
|
var local = inverse * (world - center);
|
||||||
|
if (local.x < min.x || local.y < min.y || local.z < min.z || local.x > max.x || local.y > max.y || local.z > max.z) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryNearestTranslation(Vector3[] points, Bounds domain, List<Vector3> occupied,
|
||||||
|
Collider[] obstacles, Dictionary<MeshCollider, RestoreMesh> meshes, SphereCollider probe, BoardPresenter presenter, BoardEnvironment environment,
|
||||||
|
out Vector3 result, out bool noPlacement)
|
||||||
|
{
|
||||||
|
result = Vector3.zero;
|
||||||
|
noPlacement = false;
|
||||||
|
var elapsed = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var candidates = new List<Vector3>(); var visited = new HashSet<Vector3>();
|
||||||
|
Vector3 Clamp(Vector3 delta) => new Vector3(Mathf.Clamp(delta.x, domain.min.x, domain.max.x),
|
||||||
|
Mathf.Clamp(delta.y, domain.min.y, domain.max.y), Mathf.Clamp(delta.z, domain.min.z, domain.max.z));
|
||||||
|
void Add(Vector3 delta)
|
||||||
|
{
|
||||||
|
delta = Clamp(delta);
|
||||||
|
if (visited.Add(delta)) candidates.Add(delta);
|
||||||
|
}
|
||||||
|
var closestBound = Clamp(Vector3.zero); Add(closestBound);
|
||||||
|
bool Safe(Vector3 translation) => points.All(point => environment.IsSafeSphere(point + translation, presenter.transform))
|
||||||
|
&& Clear(points.Select(point => point + translation), occupied);
|
||||||
|
if (Safe(closestBound)) { result = closestBound; Debug.Log("[ProgressRestore] Exact bounds projection, ms=" + elapsed.ElapsedMilliseconds); return true; }
|
||||||
|
// Boundary faces/corners provide escape candidates even from deep inside a closed triangle mesh.
|
||||||
|
for (int x = -1; x <= 1; x++) for (int y = -1; y <= 1; y++) for (int z = -1; z <= 1; z++)
|
||||||
|
Add(new Vector3(x < 0 ? domain.min.x : x > 0 ? domain.max.x : closestBound.x,
|
||||||
|
y < 0 ? domain.min.y : y > 0 ? domain.max.y : closestBound.y,
|
||||||
|
z < 0 ? domain.min.z : z > 0 ? domain.max.z : closestBound.z));
|
||||||
|
var best = Vector3.zero; bool found = false;
|
||||||
|
// Escape candidates seed an upper bound. Their lengths never prune another direction or imply impossibility.
|
||||||
|
for (int examined = 0; candidates.Count > 0 && examined < 128; examined++)
|
||||||
|
{
|
||||||
|
int index = 0;
|
||||||
|
for (int i = 1; i < candidates.Count; i++) if (candidates[i].sqrMagnitude < candidates[index].sqrMagnitude) index = i;
|
||||||
|
var delta = candidates[index]; candidates.RemoveAt(index);
|
||||||
|
var translated = points.Select(point => point + delta).ToArray();
|
||||||
|
if (Safe(delta))
|
||||||
|
{
|
||||||
|
if (!found || delta.sqrMagnitude < best.sqrMagnitude) { found = true; best = delta; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (var point in translated)
|
||||||
|
{
|
||||||
|
foreach (var other in occupied)
|
||||||
|
{
|
||||||
|
var away = point - other; float distance = away.magnitude;
|
||||||
|
if (distance >= .999f) continue;
|
||||||
|
if (distance > .000001f) Add(delta + away / distance * (1 - distance));
|
||||||
|
else foreach (var axis in new[] { Vector3.right, Vector3.up, Vector3.forward }) { Add(delta + axis); Add(delta - axis); }
|
||||||
|
}
|
||||||
|
if (environment.IsSafeSphere(point, presenter.transform)) continue;
|
||||||
|
foreach (var obstacle in obstacles)
|
||||||
|
{
|
||||||
|
if (obstacle.bounds.SqrDistance(point) > Radius * Radius) continue;
|
||||||
|
if (Clearance(obstacle, point, meshes, out _, out var surfaceEscape)) Add(delta + surfaceEscape);
|
||||||
|
if (Physics.ComputePenetration(probe, point, Quaternion.identity, obstacle, obstacle.transform.position,
|
||||||
|
obstacle.transform.rotation, out var direction, out float depth)) Add(delta + direction * Mathf.Max(0, depth - .0009f));
|
||||||
|
var min = obstacle.bounds.min - Vector3.one * Radius;
|
||||||
|
var max = obstacle.bounds.max + Vector3.one * Radius;
|
||||||
|
for (int axis = 0; axis < 3; axis++)
|
||||||
|
{
|
||||||
|
var escape = delta; escape[axis] += min[axis] - point[axis]; Add(escape);
|
||||||
|
escape = delta; escape[axis] += max[axis] - point[axis]; Add(escape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A cell's closest point to zero is an exact lower bound on every translation it contains.
|
||||||
|
// Signed distance is 1-Lipschitz: penetration deeper than its circumradius excludes the entire cell.
|
||||||
|
var cells = new List<(Bounds bounds, float lower)>();
|
||||||
|
var penetrationByTranslation = new Dictionary<Vector3, float>();
|
||||||
|
float PenetrationAt(Vector3 translation)
|
||||||
|
{
|
||||||
|
if (!penetrationByTranslation.TryGetValue(translation, out float value))
|
||||||
|
{ value = Penetration(points, translation, occupied, obstacles, meshes); penetrationByTranslation.Add(translation, value); }
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
void AddCell(Bounds cell)
|
||||||
|
{
|
||||||
|
var centerPenetration = PenetrationAt(cell.center);
|
||||||
|
if (centerPenetration > cell.extents.magnitude + .000001f) return;
|
||||||
|
var closest = cell.ClosestPoint(Vector3.zero);
|
||||||
|
float penetration = PenetrationAt(closest);
|
||||||
|
float lower = Mathf.Sqrt(closest.sqrMagnitude + penetration * penetration);
|
||||||
|
if (found && lower >= best.magnitude - NearestTolerance) return;
|
||||||
|
cells.Add((cell, lower));
|
||||||
|
}
|
||||||
|
AddCell(domain);
|
||||||
|
int examinedCells = 0;
|
||||||
|
for (; cells.Count > 0 && examinedCells < SearchLimit; examinedCells++)
|
||||||
|
{
|
||||||
|
int index = 0;
|
||||||
|
for (int i = 1; i < cells.Count; i++) if (cells[i].lower < cells[index].lower) index = i;
|
||||||
|
var cell = cells[index]; cells.RemoveAt(index);
|
||||||
|
if (found && cell.lower >= best.magnitude - NearestTolerance) continue;
|
||||||
|
var closest = cell.bounds.ClosestPoint(Vector3.zero);
|
||||||
|
if (Safe(closest))
|
||||||
|
{
|
||||||
|
if (!found || closest.sqrMagnitude < best.sqrMagnitude) { found = true; best = closest; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var size = cell.bounds.size; int axis = size.x >= size.y && size.x >= size.z ? 0 : size.y >= size.z ? 1 : 2;
|
||||||
|
// Float exhaustion is an inconclusive query, never evidence of missing space.
|
||||||
|
if (size[axis] < .0000001f) return false;
|
||||||
|
size[axis] *= .5f; var offset = Vector3.zero; offset[axis] = size[axis] * .5f;
|
||||||
|
AddCell(new Bounds(cell.bounds.center - offset, size)); AddCell(new Bounds(cell.bounds.center + offset, size));
|
||||||
|
}
|
||||||
|
result = best;
|
||||||
|
bool certified = found && !cells.Any(cell => cell.lower < best.magnitude - NearestTolerance);
|
||||||
|
// With no fixed friends, exhausting certified unsafe cells proves terrain-only impossibility.
|
||||||
|
// A limit/error or exclusions caused by other friends cannot establish it.
|
||||||
|
noPlacement = !found && cells.Count == 0 && occupied.Count == 0;
|
||||||
|
Debug.Log($"[ProgressRestore] Nearest certificate={certified}, tolerance={NearestTolerance:R}, cells={examinedCells}, remaining={cells.Count}, distance={best.magnitude:R}, ms={elapsed.ElapsedMilliseconds}");
|
||||||
|
return certified;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float Penetration(Vector3[] points, Vector3 translation, List<Vector3> occupied,
|
||||||
|
Collider[] obstacles, Dictionary<MeshCollider, RestoreMesh> meshes)
|
||||||
|
{
|
||||||
|
float penetration = 0;
|
||||||
|
foreach (var source in points)
|
||||||
|
{
|
||||||
|
var point = source + translation;
|
||||||
|
foreach (var other in occupied) penetration = Mathf.Max(penetration, .999f - Vector3.Distance(point, other));
|
||||||
|
foreach (var obstacle in obstacles)
|
||||||
|
if (obstacle.bounds.SqrDistance(point) <= Radius * Radius && Clearance(obstacle, point, meshes, out float clearance, out _))
|
||||||
|
penetration = Mathf.Max(penetration, -clearance);
|
||||||
|
}
|
||||||
|
return penetration;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Clearance(Collider collider, Vector3 point, Dictionary<MeshCollider, RestoreMesh> meshes,
|
||||||
|
out float clearance, out Vector3 escape)
|
||||||
|
{
|
||||||
|
clearance = 0; escape = Vector3.zero;
|
||||||
|
if (collider is BoxCollider box)
|
||||||
|
{
|
||||||
|
var center = box.transform.TransformPoint(box.center); var rotation = box.transform.rotation;
|
||||||
|
var local = Quaternion.Inverse(rotation) * (point - center); var scale = box.transform.lossyScale;
|
||||||
|
var half = Vector3.Scale(box.size * .5f, new Vector3(Mathf.Abs(scale.x), Mathf.Abs(scale.y), Mathf.Abs(scale.z)));
|
||||||
|
var min = -half; var max = half;
|
||||||
|
var nearest = new Vector3(Mathf.Clamp(local.x, min.x, max.x), Mathf.Clamp(local.y, min.y, max.y), Mathf.Clamp(local.z, min.z, max.z));
|
||||||
|
var surface = center + rotation * nearest; var away = point - surface;
|
||||||
|
if (away.sqrMagnitude > .000000001f) { clearance = away.magnitude - Margin; escape = away.normalized * Mathf.Max(0, -clearance); return true; }
|
||||||
|
float distance = float.PositiveInfinity; Vector3 direction = Vector3.zero;
|
||||||
|
for (int axis = 0; axis < 3; axis++) foreach (float edge in new[] { min[axis], max[axis] })
|
||||||
|
{
|
||||||
|
nearest = local; nearest[axis] = edge; away = center + rotation * nearest - point;
|
||||||
|
if (away.magnitude < distance) { distance = away.magnitude; direction = away.normalized; }
|
||||||
|
}
|
||||||
|
clearance = -distance - Margin; escape = direction * -clearance;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (collider is SphereCollider sphere)
|
||||||
|
{
|
||||||
|
var scale = sphere.transform.lossyScale; var away = point - sphere.transform.TransformPoint(sphere.center);
|
||||||
|
float radius = sphere.radius * Mathf.Max(Mathf.Abs(scale.x), Mathf.Abs(scale.y), Mathf.Abs(scale.z));
|
||||||
|
clearance = away.magnitude - radius - Margin;
|
||||||
|
escape = (away.sqrMagnitude > .000000001f ? away.normalized : Vector3.right) * Mathf.Max(0, -clearance);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (collider is MeshCollider mesh && mesh.sharedMesh != null)
|
||||||
|
{
|
||||||
|
if (!meshes.TryGetValue(mesh, out var geometry)) return false;
|
||||||
|
if (!geometry.IsClosed) return false;
|
||||||
|
geometry.Distance(point, out var nearest, out float distance, out bool inside);
|
||||||
|
clearance = (inside ? -distance : distance) - Margin;
|
||||||
|
var direction = inside ? nearest - point : point - nearest;
|
||||||
|
escape = direction.normalized * Mathf.Max(0, -clearance);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Unsupported geometry supplies no exclusion certificate; actual safety still evaluates it.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RestoreMesh
|
||||||
|
{
|
||||||
|
private readonly Vector3[] _vertices;
|
||||||
|
private readonly int[] _triangles;
|
||||||
|
private readonly bool _closed;
|
||||||
|
public bool IsClosed => _closed;
|
||||||
|
public RestoreMesh(MeshCollider collider, Vector3[] vertices, int[] triangles)
|
||||||
|
{
|
||||||
|
_triangles = triangles;
|
||||||
|
_vertices = vertices.Select(collider.transform.TransformPoint).ToArray();
|
||||||
|
var edges = new Dictionary<(Vector3 first, Vector3 second), (int count, int orientation)>();
|
||||||
|
for (int index = 0; index < _triangles.Length; index += 3)
|
||||||
|
for (int edge = 0; edge < 3; edge++)
|
||||||
|
{
|
||||||
|
var first = vertices[_triangles[index + edge]]; var second = vertices[_triangles[index + (edge + 1) % 3]];
|
||||||
|
bool forward = Compare(first, second) < 0; var key = forward ? (first, second) : (second, first);
|
||||||
|
edges.TryGetValue(key, out var value); edges[key] = (value.count + 1, value.orientation + (forward ? 1 : -1));
|
||||||
|
}
|
||||||
|
_closed = edges.Count > 0 && edges.Values.All(value => value.count == 2 && value.orientation == 0);
|
||||||
|
}
|
||||||
|
private static int Compare(Vector3 first, Vector3 second)
|
||||||
|
{ int value = first.x.CompareTo(second.x); if (value != 0) return value; value = first.y.CompareTo(second.y); return value != 0 ? value : first.z.CompareTo(second.z); }
|
||||||
|
public void Distance(Vector3 point, out Vector3 nearest, out float distance, out bool inside)
|
||||||
|
{
|
||||||
|
nearest = Vector3.zero; float squared = float.PositiveInfinity; double winding = 0;
|
||||||
|
for (int index = 0; index < _triangles.Length; index += 3)
|
||||||
|
{
|
||||||
|
var a = _vertices[_triangles[index]]; var b = _vertices[_triangles[index + 1]]; var c = _vertices[_triangles[index + 2]];
|
||||||
|
var candidate = ClosestTriangle(point, a, b, c); float candidateSquared = (candidate - point).sqrMagnitude;
|
||||||
|
if (candidateSquared < squared) { squared = candidateSquared; nearest = candidate; }
|
||||||
|
if (!_closed) continue;
|
||||||
|
a -= point; b -= point; c -= point;
|
||||||
|
double numerator = Vector3.Dot(a, Vector3.Cross(b, c));
|
||||||
|
double denominator = (double)a.magnitude * b.magnitude * c.magnitude + (double)Vector3.Dot(a, b) * c.magnitude
|
||||||
|
+ (double)Vector3.Dot(b, c) * a.magnitude + (double)Vector3.Dot(c, a) * b.magnitude;
|
||||||
|
winding += 2 * Math.Atan2(numerator, denominator);
|
||||||
|
}
|
||||||
|
distance = Mathf.Sqrt(squared); inside = _closed && Math.Abs(winding) > Math.PI * 2;
|
||||||
|
}
|
||||||
|
private static Vector3 ClosestTriangle(Vector3 point, Vector3 a, Vector3 b, Vector3 c)
|
||||||
|
{
|
||||||
|
var ab = b - a; var ac = c - a; var ap = point - a; float d1 = Vector3.Dot(ab, ap), d2 = Vector3.Dot(ac, ap);
|
||||||
|
if (d1 <= 0 && d2 <= 0) return a;
|
||||||
|
var bp = point - b; float d3 = Vector3.Dot(ab, bp), d4 = Vector3.Dot(ac, bp);
|
||||||
|
if (d3 >= 0 && d4 <= d3) return b;
|
||||||
|
float vc = d1 * d4 - d3 * d2; if (vc <= 0 && d1 >= 0 && d3 <= 0) return a + ab * (d1 / (d1 - d3));
|
||||||
|
var cp = point - c; float d5 = Vector3.Dot(ab, cp), d6 = Vector3.Dot(ac, cp);
|
||||||
|
if (d6 >= 0 && d5 <= d6) return c;
|
||||||
|
float vb = d5 * d2 - d1 * d6; if (vb <= 0 && d2 >= 0 && d6 <= 0) return a + ac * (d2 / (d2 - d6));
|
||||||
|
float va = d3 * d6 - d5 * d4; if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) return b + (c - b) * ((d4 - d3) / (d4 - d3 + d5 - d6));
|
||||||
|
float inverse = 1 / (va + vb + vc); return a + ab * (vb * inverse) + ac * (vc * inverse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Clear(IEnumerable<Vector3> positions, IEnumerable<Vector3> occupied)
|
||||||
|
=> positions.All(point => occupied.All(other => (point - other).sqrMagnitude >= .999f * .999f));
|
||||||
|
private static bool SeparateGroups(GroupState[] groups, Dictionary<string, Vector3> balls)
|
||||||
|
{
|
||||||
|
var occupied = new List<Vector3>();
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var points = group.ballIds.Select(id => balls[id]).ToArray();
|
||||||
|
if (!Clear(points, occupied)) return false;
|
||||||
|
occupied.AddRange(points);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 32953c8bdbfc3f542967d944ce912740
|
||||||
Reference in New Issue
Block a user