diff --git a/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundResumePlayTests.cs b/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundResumePlayTests.cs index 168ec7b..c7eab48 100644 --- a/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundResumePlayTests.cs +++ b/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundResumePlayTests.cs @@ -99,6 +99,60 @@ namespace MagnetismHeroes.Tests 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 ActualGardenSplitFiveResumeRepairsWithoutChangingThreeAndTwoMembershipOrOtherSlots() + { + _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())}; + Reload();_app.StartStage(9); + var camera = new CameraState {target=new Position3(0,.6f,0),distance=11,rotation=new Rotation4(-.022557568f,.96225017f,.08418597f,.2578342f)}; + var saved = new FreePlayState {sourceStageId=9,camera=camera, + balls=new[] { + new BallState {id="s09-b01",position=new Position3(-1.0351909f,.8f,.60371524f)}, + new BallState {id="s09-b02",position=new Position3(-.03519098f,.8f,.60371524f)}, + new BallState {id="s09-b03",position=new Position3(-3.9800465f,.8f,1.649168f)}, + new BallState {id="s09-b04",position=new Position3(.964809f,.8f,.60371524f)}, + new BallState {id="s09-b05",position=new Position3(-2.9800465f,.8f,1.649168f)}}, + groups=new[] {new GroupState {id="s09-g01",ballIds=new[] {"s09-b01","s09-b02","s09-b04"}},new GroupState {id="s09-g01-split",ballIds=new[] {"s09-b03","s09-b05"}}}}; + var rawRotation=new Quaternion(camera.rotation.x,camera.rotation.y,camera.rotation.z,camera.rotation.w); + var rawEye=BoardPresenter.ToVector(camera.target)+Vector3.up*.65f-rawRotation*Vector3.forward*camera.distance; + Assert.That(rawEye.y,Is.LessThan(_app.Environment.PlaygroundBounds.min.y),"The exact old Content camera is underground and must be corrected separately from the friends."); + var source=saved.balls.ToDictionary(ball=>ball.id,ball=>BoardPresenter.ToVector(ball.position)); + var fixedPoints=saved.groups[0].ballIds.Select(id=>source[id]).ToArray(); + Assert.That(fixedPoints.All(point=>_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point))),Is.True); + var movedPoints=saved.groups[1].ballIds.Select(id=>source[id]).ToArray(); + Assert.That(movedPoints.All(point=>_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point))),Is.False); + Assert.That(movedPoints.All(point=>_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point+Vector3.forward))&&fixedPoints.All(other=>Vector3.Distance(point+Vector3.forward,other)>=.999f)),Is.True,"A valid one-unit escape proves this board is repairable."); + float sampledUpperBound=1; + for(int index=0;index<64;index++) + { + float y=1-2*(index+.5f)/64,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=.01f;radius_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point+direction*radius))&&fixedPoints.All(other=>Vector3.Distance(point+direction*radius,other)>=.999f))) {sampledUpperBound=radius;break;} + } + _store.Save.latestFreePlay=saved; string puzzles=string.Join("|",_store.Save.puzzleStates.Select(state=>JsonUtility.ToJson(state))); var completed=(int[])_store.Save.completedStageIds.Clone(); var tutorials=(string[])_store.Save.seenTutorialIds.Clone(); + Reload();int before=_store.Writes;_app.ContinueFreePlay();yield return null; + Assert.That(_app.Session.Board,Is.Not.Null,"The exact existing Content split-five record must enter after certified repair."); + Assert.That(_app.Session.Screen,Is.EqualTo(SessionScreen.FreePlay));Assert.That(_app.Session.Board.StageId,Is.EqualTo(9)); + Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board),Is.True);Assert.That(_store.Writes,Is.EqualTo(before+1)); + foreach(var group in saved.groups) + { + var result=_app.Session.Board.Groups.Single(value=>value.id==group.id);Assert.That(result.ballIds,Is.EquivalentTo(group.ballIds)); + var target=_app.Session.Board.Balls.ToDictionary(ball=>ball.id,ball=>BoardPresenter.ToVector(ball.position)); + foreach(string id in group.ballIds) Assert.That(Vector3.Distance(target[id]-target[group.ballIds[0]],source[id]-source[group.ballIds[0]]),Is.LessThan(.0001f)); + } + var restored=_app.Session.Board.Balls.ToDictionary(ball=>ball.id,ball=>BoardPresenter.ToVector(ball.position)); + Assert.That(restored.Keys,Is.EquivalentTo(source.Keys));Assert.That(_app.Session.Board.Groups.Length,Is.EqualTo(2)); + foreach(string id in saved.groups[0].ballIds) Assert.That(restored[id],Is.EqualTo(source[id]),"Already safe friends stay exactly placed."); + float distance=Vector3.Distance(restored["s09-b03"],source["s09-b03"]); + Assert.That(distance,Is.LessThanOrEqualTo(sampledUpperBound+.011f)); + foreach(string id in saved.groups[1].ballIds) foreach(string other in saved.groups[0].ballIds) Assert.That(Vector3.Distance(restored[id],restored[other]),Is.GreaterThanOrEqualTo(.999f)); + Assert.That(string.Join("|",_store.Save.puzzleStates.Select(state=>JsonUtility.ToJson(state))),Is.EqualTo(puzzles));Assert.That(_store.Save.completedStageIds,Is.EqualTo(completed));Assert.That(_store.Save.seenTutorialIds,Is.EqualTo(tutorials)); + Assert.That(_app.Orbit.Camera.transform.position.y,Is.GreaterThanOrEqualTo(_app.Environment.PlaygroundBounds.min.y+.35f-.001f)); + Assert.That(JsonUtility.ToJson(_store.Save.latestFreePlay.camera),Is.EqualTo(JsonUtility.ToJson(_app.Orbit.Snapshot()))); + Debug.Log($"[GardenRestoreActual] utc={System.DateTime.UtcNow:O}, distance={distance:R}, independentRadialUpperBound={sampledUpperBound:R}"); + _app.ShowHome();string corrected=JsonUtility.ToJson(_store.Save.latestFreePlay);_app.ContinueFreePlay();yield return null; + Assert.That(JsonUtility.ToJson(_store.Save.latestFreePlay),Is.EqualTo(corrected));Assert.That(_app.Instruction,Is.Not.EqualTo("친구를 놀이터로 다시 보냈어요.")); + } [UnityTest] public IEnumerator CountingResumeRepairsTheWholeGroupWithoutCollectingOrCompletingAndWritesOnce() { var puzzle = new PuzzleState { stageId = 2, balls = new[] { Ball("old-a", 30), Ball("old-b", 31) }, diff --git a/Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundResume.cs b/Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundResume.cs index 6280831..ad33cff 100644 --- a/Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundResume.cs +++ b/Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundResume.cs @@ -135,7 +135,12 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap 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)); + Add(domain.center); var best = Vector3.zero; bool found = false; + // Establish a safe upper bound before short, unsafe surface candidates can consume the escape budget. + // The domain center also avoids relying only on float-sensitive exact boundary faces. + foreach (var candidate in candidates) + if (Safe(candidate) && (!found || candidate.sqrMagnitude < best.sqrMagnitude)) { found = true; best = candidate; } // 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++) { @@ -202,6 +207,10 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap 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; + // A cell projection may remain on an unsafe surface while its interior already contains safe placements. + // Interior samples only improve the safe upper bound; all exclusion/lower-bound certificates remain unchanged. + var center = cell.bounds.center; + if ((!found || center.sqrMagnitude < best.sqrMagnitude) && Safe(center)) { found = true; best = center; } var closest = cell.bounds.ClosestPoint(Vector3.zero); if (Safe(closest)) { diff --git a/Assets/MagnetismHeroes/UnityRuntime/Interaction/OrbitCamera.cs b/Assets/MagnetismHeroes/UnityRuntime/Interaction/OrbitCamera.cs index 18a5f42..3f36f7e 100644 --- a/Assets/MagnetismHeroes/UnityRuntime/Interaction/OrbitCamera.cs +++ b/Assets/MagnetismHeroes/UnityRuntime/Interaction/OrbitCamera.cs @@ -104,6 +104,8 @@ namespace MagnetismHeroes.UnityRuntime.Interaction private Quaternion SafeRotation(Quaternion rotation, float distance) { float elevation = Mathf.Asin(Mathf.Clamp((rotation * Vector3.back).y, -1, 1)) * Mathf.Rad2Deg; + // Reapplying an already valid stored pose must preserve its exact quaternion, including at the ground limit. + if (elevation >= _minElevation && elevation <= _maxElevation && IsSafeCamera(Position(rotation, distance))) return rotation; float groundElevation = Mathf.Asin(Mathf.Clamp((Ground + _collisionMargin - Pivot.y) / distance, -1, 1)) * Mathf.Rad2Deg; float corrected = Mathf.Clamp(elevation, Mathf.Max(_minElevation, groundElevation), _maxElevation); var pitchAxis = Vector3.Cross(rotation * Vector3.back, Vector3.up).normalized;