Integrate preserved resume repair with drag camera improvements

This commit is contained in:
NINI
2026-10-10 04:23:44 +09:00
15 changed files with 42456 additions and 23 deletions
+29
View File
@@ -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);
+100 -20
View File
@@ -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