Complete persistence boundaries and free-play supply with device verification

This commit is contained in:
NINI
2026-10-05 11:30:12 +09:00
parent a651e5ca7f
commit e84eb1a35e
21 changed files with 1786 additions and 95 deletions
@@ -16,10 +16,19 @@ namespace MagnetismHeroes.Core
public BoardRules Board { get; private set; }
public CameraState Camera { get; private set; } = DefaultCamera();
public string LastSaveError { get; private set; }
public event Action<string> SaveFinished;
public bool HasFreePlay => _progress.latestFreePlay != null;
public int[] CompletedStageIds => (int[])_progress.completedStageIds.Clone();
public IReadOnlyList<StageDefinition> Definitions => Array.AsReadOnly(_definitions);
public bool HasUnfinishedStage(int stageId) => _progress.puzzleStates.Any(state => state.stageId == stageId);
public bool HasSeenTutorial(string id) => _progress.seenTutorialIds.Contains(id);
public bool MarkTutorialSeen(string id)
{
if ((id != "merge" && id != "split") || HasSeenTutorial(id)) return false;
_progress.seenTutorialIds = _progress.seenTutorialIds.Concat(new[] { id }).ToArray();
SaveCurrent(); return true;
}
public GameSession(IEnumerable<StageDefinition> definitions, IProgressStore store)
{
@@ -67,6 +76,14 @@ namespace MagnetismHeroes.Core
public RuleResult SplitAndMove(string groupId, IReadOnlyList<string> selectedIds, string newGroupId, Position3 delta) =>
Commit(() => Board.TrySplitAndMove(groupId, selectedIds, newGroupId, delta));
public RuleResult Count(string ballId) => Commit(() => Board.TryCount(ballId));
public RuleResult ReturnGroup(string groupId) => Commit(() => Board.TryReturn(groupId));
public RuleResult Spawn(Position3 position)
{
if (Board == null) return RuleResult.Reject(RejectionReason.InvalidGroup);
int index = 1;
while (Board.Balls.Any(ball => ball.id == $"free-b{index:D2}") || Board.Groups.Any(group => group.id == $"free-g{index:D2}")) index++;
return Commit(() => Board.TrySpawn($"free-b{index:D2}", $"free-g{index:D2}", position));
}
public void CommitCamera(CameraState camera)
{
@@ -104,6 +121,7 @@ namespace MagnetismHeroes.Core
}
var success = _store.TrySave(_progress, out var error);
LastSaveError = error;
SaveFinished?.Invoke(error);
return success;
}
@@ -0,0 +1,74 @@
using System;
using System.Linq;
using System.Reflection;
using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Bootstrap;
using MagnetismHeroes.UnityRuntime.StageAssets;
using TMPro;
using UnityEditor;
using UnityEngine;
namespace MagnetismHeroes.Editor
{
public static class PersistenceAssets
{
private const string Presentation = "Assets/MagnetismHeroes/UnityRuntime/Presentation/";
[MenuItem("Magnetism Heroes/Build Persistence Assets")]
public static void Build()
{
var font = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(Presentation + "Fonts/NotoSansCJKkr-FirstSlice.asset");
const string glyphs = "2 하나 꺼내기 친구 반납 지금은 저장하지 못했어요. 다시 해볼게요. 빈 곳이 보이도록 화면을 움직여 볼까?";
if (glyphs.Any(character => !font.HasCharacter(character)))
{
typeof(TMP_FontAsset).GetProperty("SourceFont_EditorRef", BindingFlags.Instance | BindingFlags.NonPublic)
.SetValue(font, AssetDatabase.LoadAssetAtPath<Font>(Presentation + "Fonts/NotoSansCJKkr-Regular.otf"));
font.atlasPopulationMode = AtlasPopulationMode.Dynamic;
try { if (!font.TryAddCharacters(glyphs, out var missing)) throw new InvalidOperationException("Missing persistence glyphs: " + missing); }
finally { font.atlasPopulationMode = AtlasPopulationMode.Static; }
EditorUtility.SetDirty(font);
}
const string stagePath = "Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage02.asset";
var stage = AssetDatabase.LoadAssetAtPath<StageAsset>(stagePath);
if (stage == null) { stage = ScriptableObject.CreateInstance<StageAsset>(); AssetDatabase.CreateAsset(stage, stagePath); }
var stageFields = new SerializedObject(stage); stageFields.FindProperty("_stageId").intValue = 2;
stageFields.FindProperty("_kind").enumValueIndex = (int)StageKind.Counting;
var initial = stageFields.FindProperty("_initialGroups"); initial.arraySize = 2;
initial.GetArrayElementAtIndex(0).intValue = initial.GetArrayElementAtIndex(1).intValue = 1;
var target = stageFields.FindProperty("_targetGroups"); target.arraySize = 1; target.GetArrayElementAtIndex(0).intValue = 2;
var positions = stageFields.FindProperty("_ballPositions"); positions.arraySize = 2;
positions.GetArrayElementAtIndex(0).vector3Value = new Vector3(-1.2f, .8f, 0);
positions.GetArrayElementAtIndex(1).vector3Value = new Vector3(1.2f, .8f, 0); stageFields.ApplyModifiedPropertiesWithoutUndo();
var root = PrefabUtility.LoadPrefabContents(FirstSliceAssets.AppPrefabPath);
try
{
var app = new SerializedObject(root.GetComponent<GameApp>());
var stages = app.FindProperty("_stages"); var catalog = Enumerable.Range(0, stages.arraySize)
.Select(index => (StageAsset)stages.GetArrayElementAtIndex(index).objectReferenceValue).Where(value => value.StageId != 2)
.Concat(new[] { stage }).OrderBy(value => value.StageId).ToArray(); stages.arraySize = catalog.Length;
for (int index = 0; index < catalog.Length; index++) stages.GetArrayElementAtIndex(index).objectReferenceValue = catalog[index];
var safe = root.transform.Find("Interface/SafeArea"); var selection = safe.Find("StageSelection");
var template = selection.Find("Stage01Button").gameObject;
var stageButton = Button(selection, template, "Stage02Button", "2 친구 찾기", new Vector2(.5f, .36f), new Vector2(280, 72));
app.FindProperty("_stageTwoButton").objectReferenceValue = stageButton;
var play = safe.Find("Play"); var playTemplate = play.Find("HomeButton").gameObject;
app.FindProperty("_spawnButton").objectReferenceValue = Button(play, playTemplate, "SpawnButton", "하나 꺼내기", new Vector2(.86f, .3f), new Vector2(240, 62));
app.FindProperty("_returnButton").objectReferenceValue = Button(play, playTemplate, "ReturnButton", "친구 반납", new Vector2(.86f, .2f), new Vector2(240, 62));
var notice = (TMP_Text)app.FindProperty("_saveNotice").objectReferenceValue;
notice.transform.SetParent(safe, false); notice.transform.SetAsLastSibling();
notice.rectTransform.anchorMin = notice.rectTransform.anchorMax = new Vector2(.5f, .07f);
notice.rectTransform.anchoredPosition = Vector2.zero;
app.ApplyModifiedPropertiesWithoutUndo(); PrefabUtility.SaveAsPrefabAsset(root, FirstSliceAssets.AppPrefabPath);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
AssetDatabase.SaveAssets(); Debug.Log("[Persistence] Stage02, locked selection, free supply/whole-friend return and shared save notice wired. Main unchanged.");
}
private static UnityEngine.UI.Button Button(Transform parent, GameObject template, string name, string label, Vector2 anchor, Vector2 size)
{
var existing = parent.Find(name); var button = existing == null ? UnityEngine.Object.Instantiate(template, parent) : existing.gameObject;
button.name = name; var rect = (RectTransform)button.transform; rect.anchorMin = rect.anchorMax = anchor;
rect.anchoredPosition = Vector2.zero; rect.sizeDelta = size;
var text = button.GetComponentInChildren<TMP_Text>(); text.text = label; text.rectTransform.sizeDelta = size;
return button.GetComponent<UnityEngine.UI.Button>();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 60c8eab85ee35ad40884c25ab21276c9
@@ -26,9 +26,9 @@ namespace MagnetismHeroes.Tests
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
var serialized = new SerializedObject(prefab.GetComponent<GameApp>()); var stages = serialized.FindProperty("_stages");
Assert.That(stages.arraySize, Is.EqualTo(4)); var definitions = Enumerable.Range(0, stages.arraySize)
Assert.That(stages.arraySize, Is.EqualTo(5)); var definitions = Enumerable.Range(0, stages.arraySize)
.Select(index => ((StageAsset)stages.GetArrayElementAtIndex(index).objectReferenceValue).ToDefinition()).ToArray();
Assert.That(definitions.Select(stage => stage.Id), Is.EqualTo(new[] { 1, 6, 10, 14 }));
Assert.That(definitions.Select(stage => stage.Id), Is.EqualTo(new[] { 1, 2, 6, 10, 14 }));
var session = new GameSession(definitions, new EmptyStore()); Assert.That(session.IsUnlocked(6), Is.False);
Assert.That(session.StartStage(6), Is.False); Assert.That(session.IsUnlocked(10), Is.False); Assert.That(session.IsUnlocked(14), Is.False);
Assert.That(serialized.FindProperty("_compositionButtons").arraySize, Is.EqualTo(3));
@@ -73,6 +73,7 @@ namespace MagnetismHeroes.Tests
Assert.That(File.ReadAllText(_path), Is.EqualTo("broken-primary"));
Assert.That(store.TrySave(new ProgressSave(), out var error), Is.True, error);
Assert.That(Directory.GetFiles(_directory, "*.damaged-*").Select(File.ReadAllText), Does.Contain("broken-primary"));
Assert.That(File.ReadAllText(_path + ".bak"), Is.EqualTo("broken-backup"));
}
[Test] public void InvalidGroupMembershipDoesNotReplaceAValidSave()
{
@@ -99,5 +100,23 @@ namespace MagnetismHeroes.Tests
Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
Assert.That(File.ReadAllText(_path), Does.Not.Contain("IsValid"));
}
[Test] public void LockedTemporaryFileReportsFailureAndKeepsTheLastValidSave()
{
var store = Store(); Assert.That(store.TrySave(Puzzle(), out _), Is.True);
Assert.That(store.TrySave(Puzzle(), out _), Is.True);
var before = File.ReadAllBytes(_path);
var backupBefore = File.ReadAllBytes(_path + ".bak");
using (var locked = new FileStream(_path + ".tmp", FileMode.Create, FileAccess.ReadWrite, FileShare.None))
{
string error = null; bool saved = true;
Assert.DoesNotThrow(() => saved = store.TrySave(Puzzle(), out error));
Assert.That(saved, Is.False); Assert.That(error, Is.Not.Empty);
Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before));
Assert.That(File.ReadAllBytes(_path + ".bak"), Is.EqualTo(backupBefore));
}
Assert.That(store.TryLoad(out var restored, out _), Is.True);
Assert.That(restored.puzzleStates.Length, Is.EqualTo(1));
Assert.That(store.TrySave(Puzzle(), out var recoveredError), Is.True, recoveredError);
}
}
}
@@ -0,0 +1,95 @@
using System;
using System.IO;
using System.Linq;
using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Persistence;
using NUnit.Framework;
namespace MagnetismHeroes.Tests
{
public sealed class PersistenceSessionTests
{
private string _directory, _path;
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 RecordedStore : IProgressStore
{
private readonly LocalProgressStore _store;
public int Writes;
public RecordedStore(string path) { _store = new LocalProgressStore(path, Definitions); }
public bool TryLoad(out ProgressSave save, out string error) => _store.TryLoad(out save, out error);
public bool TrySave(ProgressSave save, out string error) { Writes++; return _store.TrySave(save, out error); }
}
[SetUp] public void SetUp()
{ _directory = Path.Combine(Path.GetTempPath(), "MagnetismHeroes-persistence-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_directory); _path = Path.Combine(_directory, "progress.json"); }
[TearDown] public void TearDown() => Directory.Delete(_directory, true);
private ProgressSave Load()
{ Assert.That(new LocalProgressStore(_path, Definitions).TryLoad(out var save, out var error), Is.True, error); return save; }
private static CameraState Camera(float distance) => new CameraState {
target = new Position3(1, .8f, -2), rotation = new Rotation4(0, .6f, 0, .8f), distance = distance };
private static void CompleteOne(GameSession session)
{ session.StartStage(1); session.MoveGroup(session.Board.Groups.Single().id, new Position3(.5f, .8f, 0)); session.Count(session.Board.Balls.Single().id); }
[Test] public void ActualFileAlternatesTwoUnfinishedBoardsAndLatestFreeWithoutLosingAnyField()
{
var store = new RecordedStore(_path); var session = new GameSession(Definitions, store); CompleteOne(session);
session.Spawn(new Position3(-2, .8f, 0)); session.CommitCamera(Camera(9)); session.ShowHome();
session.StartStage(2); var groups = session.Board.Groups;
session.MoveAndMerge(groups[0].id, new Position3(.6f, 1.1f, -2), groups[1].id);
session.Count(session.Board.Balls[0].id); session.CommitCamera(Camera(11)); session.ShowHome();
var fixture = Load(); fixture.completedStageIds = new[] { 1, 2, 3, 4, 5 };
Assert.That(new LocalProgressStore(_path, Definitions).TrySave(fixture, out var error), Is.True, error);
session = new GameSession(Definitions, new RecordedStore(_path)); session.StartStage(6);
session.MoveGroup(session.Board.Groups[0].id, new Position3(-1, .8f, 2)); session.CommitCamera(Camera(7)); session.ShowHome();
var before = File.ReadAllBytes(_path); var saved = Load();
Assert.That(saved.puzzleStates.Length, Is.EqualTo(2));
Assert.That(saved.puzzleStates.Single(state => state.stageId == 2).countedBallIds.Length, Is.EqualTo(1));
foreach (int id in new[] { 2, 6, 2, 6 })
{
session = new GameSession(Definitions, new RecordedStore(_path)); Assert.That(session.Screen, Is.EqualTo(SessionScreen.Home));
Assert.That(session.StartStage(id), Is.True); var expected = saved.puzzleStates.Single(state => state.stageId == id);
AssertBoard(session.Board, expected.balls, expected.groups); AssertCamera(session.Camera, expected.camera);
Assert.That(session.Board.CollectedBallIds, Is.EqualTo(expected.collectedBallIds));
Assert.That(session.Board.CountedBallIds, Is.EqualTo(expected.countedBallIds)); session.ShowHome();
Assert.That(session.ContinueFreePlay(), Is.True); AssertBoard(session.Board, saved.latestFreePlay.balls, saved.latestFreePlay.groups);
AssertCamera(session.Camera, saved.latestFreePlay.camera); session.ShowHome();
Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before), "Exact deterministic JSON bytes retain both unfinished plus global Free.");
}
}
[Test] public void CompletionIsOneWriteReplacesOnlyLatestFreeAndReplayKeepsProgress()
{
var store = new RecordedStore(_path); var session = new GameSession(Definitions, store); CompleteOne(session);
session.StartStage(2); session.MoveAndMerge(session.Board.Groups[0].id, new Position3(.5f, 0, 0), session.Board.Groups[1].id);
session.Count(session.Board.Balls[0].id); int before = store.Writes;
session.Count(session.Board.Balls[1].id); Assert.That(store.Writes, Is.EqualTo(before + 1));
var completed = Load(); Assert.That(completed.completedStageIds, Is.EqualTo(new[] { 1, 2 }));
Assert.That(completed.puzzleStates, Is.Empty); Assert.That(completed.latestFreePlay.sourceStageId, Is.EqualTo(2));
session.StartStage(1); session.MoveGroup(session.Board.Groups[0].id, new Position3(.2f, 0, 0));
var replay = Load(); Assert.That(replay.completedStageIds, Is.EqualTo(new[] { 1, 2 }));
Assert.That(replay.puzzleStates.Single().stageId, Is.EqualTo(1)); Assert.That(replay.latestFreePlay.sourceStageId, Is.EqualTo(2));
session.Count(session.Board.Balls.Single().id); Assert.That(Load().latestFreePlay.sourceStageId, Is.EqualTo(1));
Assert.That(Load().completedStageIds, Is.EqualTo(new[] { 1, 2 }));
}
[Test] public void TutorialRecordsAreUniqueValidatedAndPersistAcrossFreeAndPuzzleTransitions()
{
var store = new RecordedStore(_path); var session = new GameSession(Definitions, store);
Assert.That(session.MarkTutorialSeen("merge"), Is.True); int before = store.Writes;
Assert.That(session.MarkTutorialSeen("merge"), Is.False); Assert.That(session.MarkTutorialSeen("unknown"), Is.False);
Assert.That(store.Writes, Is.EqualTo(before)); CompleteOne(session); Assert.That(session.MarkTutorialSeen("split"), Is.True);
session.StartStage(1); session.ShowHome(); session = new GameSession(Definitions, new RecordedStore(_path));
Assert.That(session.HasSeenTutorial("merge"), Is.True); Assert.That(session.HasSeenTutorial("split"), Is.True);
Assert.That(Load().seenTutorialIds, Is.EqualTo(new[] { "merge", "split" }));
}
private static void AssertBoard(BoardRules board, BallState[] balls, GroupState[] groups)
{
Assert.That(board.Balls.Select(ball => ball.id), Is.EqualTo(balls.Select(ball => ball.id)));
Assert.That(board.Balls.Select(ball => ball.position), Is.EqualTo(balls.Select(ball => ball.position)));
Assert.That(board.Groups.Select(group => group.id), Is.EqualTo(groups.Select(group => group.id)));
for (int index = 0; index < groups.Length; index++) Assert.That(board.Groups[index].ballIds, Is.EqualTo(groups[index].ballIds));
}
private static void AssertCamera(CameraState actual, CameraState expected)
{ Assert.That(actual.target, Is.EqualTo(expected.target)); Assert.That(actual.rotation, Is.EqualTo(expected.rotation)); Assert.That(actual.distance, Is.EqualTo(expected.distance)); }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8666ab89be2e7ab4a96f90ee79122b2f
@@ -20,8 +20,16 @@ namespace MagnetismHeroes.Tests
private sealed class MemoryStore : IProgressStore
{
public ProgressSave Saved = new ProgressSave();
public bool TryLoad(out ProgressSave save, out string error) { save = Saved; error = null; return true; }
public bool TrySave(ProgressSave save, out string error) { Saved = save; error = null; return true; }
public bool FailWrites;
private static ProgressSave Copy(ProgressSave save)
{ var copy = JsonUtility.FromJson<ProgressSave>(JsonUtility.ToJson(save)); if (save.latestFreePlay == null) copy.latestFreePlay = null; return copy; }
public bool TryLoad(out ProgressSave save, out string error) { save = Copy(Saved); error = null; return true; }
public bool TrySave(ProgressSave save, out string error)
{
error = FailWrites ? "Disk full during committed save" : null;
if (FailWrites) return false;
Saved = Copy(save); return true;
}
}
private GameApp _app; private MemoryStore _store; private Touchscreen _touchscreen;
private InputSettings _originalInputSettings, _testInputSettings;
@@ -63,6 +71,75 @@ namespace MagnetismHeroes.Tests
private void QueueTouch(int id, UnityEngine.InputSystem.TouchPhase phase, Vector2 position) =>
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = id, phase = phase, position = position });
[UnityTest] public IEnumerator FailedMoveCameraAndPauseSavesShowAVisibleNoticeAndRecoveryClearsIt()
{
_app.StartStage(1); yield return null;
var before = JsonUtility.ToJson(_store.Saved); _store.FailWrites = true;
_app.CommitMove(_app.Session.Board.Groups.Single().id, new Position3(.2f, 0, 0));
var notice = _app.GetComponentsInChildren<TMP_Text>(true).Single(text => text.name == "SaveNotice");
Assert.That(notice.text, Does.Contain("저장")); Assert.That(notice.gameObject.activeInHierarchy, Is.True);
Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(before));
_store.FailWrites = false; _app.Session.SaveCurrent(); Assert.That(notice.text, Is.Empty);
_store.FailWrites = true; _app.Input.Begin(new Vector2(Screen.width - 30, Screen.height / 2), false);
_app.Input.Move(new Vector2(Screen.width - 80, Screen.height / 2)); _app.Input.End(new Vector2(Screen.width - 80, Screen.height / 2));
Assert.That(notice.text, Does.Contain("저장"));
_store.FailWrites = false; _app.Session.SaveCurrent(); Assert.That(notice.text, Is.Empty);
_store.FailWrites = true; _app.gameObject.SendMessage("OnApplicationPause", true);
Assert.That(notice.text, Does.Contain("저장"));
_store.FailWrites = false; _app.gameObject.SendMessage("OnApplicationPause", true);
Assert.That(notice.text, Is.Empty);
}
[UnityTest] public IEnumerator FreeSupplyButtonsCanReachTenRejectMoreReturnAWholeFriendAndRespawnEmptyBoard()
{
_app.StartStage(1); yield return null; Physics.SyncTransforms();
var point = BallPoint(); Drag(point, point + new Vector2(60, 0)); yield return null; Physics.SyncTransforms(); Tap(BallPoint()); yield return null;
var spawnObject = _app.transform.Find("Interface/SafeArea/Play/SpawnButton");
Assert.That(spawnObject, Is.Not.Null, "Free play needs its real supply button.");
var spawn = spawnObject.GetComponent<UnityEngine.UI.Button>();
var giveBack = _app.transform.Find("Interface/SafeArea/Play/ReturnButton").GetComponent<UnityEngine.UI.Button>();
for (int index = 0; index < 9; index++) { spawn.onClick.Invoke(); yield return null; }
Assert.That(_app.Session.Board.BallCount, Is.EqualTo(10)); var full = JsonUtility.ToJson(_store.Saved);
Assert.That(spawn.interactable, Is.False); spawn.onClick.Invoke(); Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(full));
while (_app.Session.Board.BallCount > 0)
{
var ball = _app.Presenter.Balls.Values.First(); Vector2 location = _app.Presenter.Camera.WorldToScreenPoint(ball.transform.position);
int before = _app.Session.Board.BallCount; Tap(location); giveBack.onClick.Invoke(); yield return null; Physics.SyncTransforms();
Assert.That(_app.Session.Board.BallCount, Is.LessThan(before));
}
Assert.That(_app.Session.Board.Groups, Is.Empty); Assert.That(_store.Saved.completedStageIds, Is.EqualTo(new[] { 1 }));
spawn.onClick.Invoke(); yield return null; Assert.That(_app.Session.Board.BallCount, Is.EqualTo(1));
}
[UnityTest] public IEnumerator SuccessfulTwoCountKeepsItsFinalNumbersDuringFreeSplitCancelAndCommit()
{
_store.Saved.completedStageIds = new[] { 1 }; _app.Initialize(_store);
foreach (bool cancel in new[] { true, false })
{
_app.StartStage(2); yield return null; Physics.SyncTransforms();
var groups = _app.Session.Board.Groups;
_app.CommitMove(groups[0].id, new Position3(.6f, 0, 0), groups[1].id);
var ids = _app.Session.Board.Balls.Select(ball => ball.id).ToArray();
foreach (string id in ids)
{
Vector2 point = _app.Presenter.Camera.WorldToScreenPoint(_app.Presenter.Balls[id].transform.position);
Tap(point);
}
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_app.Voice.PendingCount, Is.EqualTo(2));
Assert.That(_app.GetComponent<AudioSource>().clip.name, Is.EqualTo("count_01"));
Vector2 firstPoint = _app.Presenter.Camera.WorldToScreenPoint(_app.Presenter.Balls[ids[0]].transform.position); Tap(firstPoint);
_app.transform.Find("Interface/SafeArea/Play/SplitButton").GetComponent<UnityEngine.UI.Button>().onClick.Invoke();
if (cancel) _app.transform.Find("Interface/SafeArea/Play/CancelSplitButton").GetComponent<UnityEngine.UI.Button>().onClick.Invoke();
else { Tap(firstPoint); Drag(firstPoint, firstPoint - new Vector2(120, 0)); }
Assert.That(_app.Voice.PendingCount, Is.EqualTo(2), cancel ? "Cancel split must keep count FIFO." : "Committed split must keep count FIFO.");
float deadline = Time.realtimeSinceStartup + 5;
while (_app.Voice.CurrentClipId == "count_01" && Time.realtimeSinceStartup < deadline) yield return null;
Assert.That(_app.Voice.CurrentClipId, Is.EqualTo("count_02")); Assert.That(_app.GetComponent<AudioSource>().isPlaying, Is.True);
while (_app.Voice.PendingCount > 0 && Time.realtimeSinceStartup < deadline) yield return null;
Assert.That(_app.Voice.PendingCount, Is.Zero);
}
}
[UnityTest] public IEnumerator SameUpdateShortTapRespondsBeforeCollectionAndCountsOnceAfterCollection()
{
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
@@ -25,6 +25,9 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
[SerializeField] private UnityEngine.UI.Button _playButton;
[SerializeField] private UnityEngine.UI.Button _continueButton;
[SerializeField] private UnityEngine.UI.Button _stageOneButton;
[SerializeField] private UnityEngine.UI.Button _stageTwoButton;
[SerializeField] private UnityEngine.UI.Button _spawnButton;
[SerializeField] private UnityEngine.UI.Button _returnButton;
[SerializeField] private UnityEngine.UI.Button _homeButton;
[SerializeField] private UnityEngine.UI.Button _selectionHomeButton;
[SerializeField] private UnityEngine.UI.Button[] _compositionButtons = Array.Empty<UnityEngine.UI.Button>();
@@ -47,12 +50,15 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
_playButton.onClick.AddListener(ShowSelection);
_continueButton.onClick.AddListener(ContinueFreePlay);
_stageOneButton.onClick.AddListener(() => StartStage(1));
if (_stageTwoButton != null) _stageTwoButton.onClick.AddListener(() => StartStage(2));
if (_spawnButton != null) _spawnButton.onClick.AddListener(SpawnFriend);
if (_returnButton != null) _returnButton.onClick.AddListener(ReturnSelectedFriend);
_homeButton.onClick.AddListener(ShowHome);
_selectionHomeButton.onClick.AddListener(ShowHome);
for (int index = 0; index < _compositionButtons.Length; index++)
{ int stageId = _stages[index + 1].StageId; _compositionButtons[index].onClick.AddListener(() => StartStage(stageId)); }
{ int stageId = _stages.Where(stage => stage.ToDefinition().Kind == StageKind.Composition).ElementAt(index).StageId; _compositionButtons[index].onClick.AddListener(() => StartStage(stageId)); }
if (_splitButton != null) _splitButton.onClick.AddListener(() => { _input.BeginSplit(); if (_input.IsSplitting) _voice.Guide("guide_split"); });
if (_cancelSplitButton != null) _cancelSplitButton.onClick.AddListener(() => { _input.CancelSplit(); _voice.Clear(); });
if (_cancelSplitButton != null) _cancelSplitButton.onClick.AddListener(() => { _input.CancelSplit(); _voice.CancelGuide(); });
}
private void Start()
{
@@ -62,11 +68,30 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
public void Initialize(IProgressStore store)
{
Session = new GameSession(_stages.Select(stage => stage.ToDefinition()), store);
Session.SaveFinished += ShowSaveResult;
_saveNotice.text = Session.LastSaveError == null ? "" : Session.LastSaveError.StartsWith("Recovered", StringComparison.Ordinal)
? "이전 기록으로 이어서 시작할게요." : "기록을 읽지 못해 처음부터 시작할게요.";
if (Session.LastSaveError != null) Debug.LogWarning("[ProgressLoad] " + Session.LastSaveError);
Refresh(true);
}
private void ShowSaveResult(string error)
{
_saveNotice.text = error == null ? "" : "지금은 저장하지 못했어요. 다시 해볼게요.";
if (error != null) Debug.LogWarning("[ProgressSave] " + error);
}
public void SpawnFriend()
{
if (Session.Screen != SessionScreen.FreePlay || Session.Board.BallCount >= 10) return;
_input.CancelSplit();
if (!_presenter.TryFindSpawnPosition(out var position)) { _instruction.text = "빈 곳이 보이도록 화면을 움직여 볼까?"; return; }
if (Session.Spawn(position).Accepted) Refresh(false);
}
public void ReturnSelectedFriend()
{
if (Session.Screen != SessionScreen.FreePlay || _input.SelectedGroup == null) return;
string groupId = _input.SelectedGroup; _input.CancelSplit();
if (Session.ReturnGroup(groupId).Accepted) Refresh(false);
}
public void ShowSelection()
{ _input.CancelSplit(); _voice.Clear(); Session.ShowSelection(); Refresh(true); }
public void ShowHome()
@@ -93,7 +118,7 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
string newId = groupId + "-split";
while (Session.Board.Groups.Any(group => group.id == newId)) newId += "-split";
var result = Session.SplitAndMove(groupId, selected, newId, delta);
if (result.Accepted) { _voice.Clear(); Refresh(false); }
if (result.Accepted) { _voice.CancelGuide(); Refresh(false); }
return result.Accepted;
}
public void CountBall(string ballId)
@@ -116,7 +141,10 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
bool playing = screen == SessionScreen.Puzzle || screen == SessionScreen.FreePlay;
_playPanel.SetActive(playing); _world.SetActive(playing);
_continueButton.gameObject.SetActive(Session.HasFreePlay);
for (int index = 0; index < _compositionButtons.Length; index++) _compositionButtons[index].interactable = Session.IsUnlocked(_stages[index + 1].StageId);
if (_stageTwoButton != null) _stageTwoButton.interactable = Session.IsUnlocked(2);
for (int index = 0; index < _compositionButtons.Length; index++) _compositionButtons[index].interactable = Session.IsUnlocked(_stages.Where(stage => stage.ToDefinition().Kind == StageKind.Composition).ElementAt(index).StageId);
if (_spawnButton != null) { _spawnButton.gameObject.SetActive(screen == SessionScreen.FreePlay); _spawnButton.interactable = Session.Board != null && Session.Board.BallCount < 10; }
if (_returnButton != null) _returnButton.gameObject.SetActive(screen == SessionScreen.FreePlay);
if (applyCamera) _orbit.Apply(Session.Camera);
_presenter.Render(Session.Board);
if (!playing) return;
@@ -137,6 +165,7 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
{
if (Session == null || _splitButton == null) return;
var group = Session.Board?.Groups.FirstOrDefault(value => value.id == _input.SelectedGroup);
if (_returnButton != null) _returnButton.interactable = Session.Screen == SessionScreen.FreePlay && group != null && !_input.IsSplitting;
_splitButton.gameObject.SetActive(!_input.IsSplitting);
_splitButton.interactable = group != null && group.ballIds.Length > 1;
_cancelSplitButton.gameObject.SetActive(_input.IsSplitting);
@@ -55,7 +55,7 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
_splitting = true; _selected.Clear(); _app.RefreshSelection();
}
public void CancelSplit()
{ Cancel(); if (_splitting) _app.Voice.Clear(); _splitting = false; _selected.Clear(); _selectedGroup = null; _presenter.ShowSelection(_selected); _app.RefreshSelection(); }
{ Cancel(); if (_splitting) _app.Voice.CancelGuide(); _splitting = false; _selected.Clear(); _selectedGroup = null; _presenter.ShowSelection(_selected); _app.RefreshSelection(); }
public void Begin(Vector2 point, bool uiBlocked)
{
@@ -96,6 +96,7 @@ namespace MagnetismHeroes.UnityRuntime.Persistence
{
if (!SaveValidator.TryValidate(save, _definitions, out error)) return false;
string temporary = _path + ".tmp";
bool createdTemporary = false;
try
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(_path)));
@@ -106,7 +107,7 @@ namespace MagnetismHeroes.UnityRuntime.Persistence
}
using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
using (var writer = new StreamWriter(stream))
{ writer.Write(JsonConvert.SerializeObject(save, Formatting.Indented, SerializerSettings)); writer.Flush(); stream.Flush(true); }
{ createdTemporary = true; writer.Write(JsonConvert.SerializeObject(save, Formatting.Indented, SerializerSettings)); writer.Flush(); stream.Flush(true); }
if (File.Exists(_path))
{
// Never replace a valid recovered backup with the invalid primary.
@@ -118,7 +119,7 @@ namespace MagnetismHeroes.UnityRuntime.Persistence
}
catch (Exception exception) when (exception is IOException || exception is UnauthorizedAccessException)
{ error = exception.Message; return false; }
finally { if (File.Exists(temporary)) File.Delete(temporary); }
finally { if (createdTemporary && File.Exists(temporary)) File.Delete(temporary); }
}
}
}
@@ -117,6 +117,23 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
return true;
}
public bool TryFindSpawnPosition(out Position3 position)
{
Physics.SyncTransforms();
// Search visible space at the same unit size, away from friends and background props.
for (int row = 0; row < 5; row++)
for (int column = 0; column < 7; column++)
{
var point = new Vector3((column - 3) * 1.2f, .8f + row * 1.2f, 0);
var viewport = _camera.WorldToViewportPoint(point);
if (viewport.z <= 0 || viewport.x < .08f || viewport.x > .92f || viewport.y < .3f || viewport.y > .78f) continue;
if (_board.Balls.Any(ball => Vector3.Distance(ToVector(ball.position), point) < 1.1f)) continue;
if (Physics.OverlapSphere(point, .55f, ~0, QueryTriggerInteraction.Ignore).Any(collider => !collider.transform.IsChildOf(transform))) continue;
position = ToPosition(point); return true;
}
position = default; return false;
}
private void PositionBadges()
{
if (_board == null) return;
File diff suppressed because one or more lines are too long
@@ -35,6 +35,8 @@ RectTransform:
- {fileID: 947662399340529430}
- {fileID: 8104365176756033704}
- {fileID: 8364979175126307048}
- {fileID: 2329883891830714780}
- {fileID: 5090946158136759876}
m_Father: {fileID: 3999613752604275916}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@@ -289,6 +291,7 @@ RectTransform:
- {fileID: 6801798374945474417}
- {fileID: 4774970796337304695}
- {fileID: 7868283166943063582}
- {fileID: 2650272815419196348}
m_Father: {fileID: 3999613752604275916}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@@ -803,6 +806,401 @@ MonoBehaviour:
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &1328309431493275319
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5049519322727858860}
- component: {fileID: 7493142574714255270}
- component: {fileID: 8396089032746231139}
m_Layer: 0
m_Name: Label
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5049519322727858860
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1328309431493275319}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 2329883891830714780}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 240, y: 62}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &7493142574714255270
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1328309431493275319}
m_CullTransparentMesh: 1
--- !u!114 &8396089032746231139
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1328309431493275319}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: "\uD558\uB098 \uAEBC\uB0B4\uAE30"
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: a5bc77ef7ce7c7e4d85ac578cfcd6646, type: 2}
m_sharedMaterial: {fileID: 8207275579423446409, guid: a5bc77ef7ce7c7e4d85ac578cfcd6646, type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 0.12, g: 0.2, b: 0.31, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 28
m_fontSizeBase: 28
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656bu
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &1458915873150721834
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 2650272815419196348}
- component: {fileID: 1467687443961635546}
- component: {fileID: 6572129107923074595}
- component: {fileID: 4131456494785851528}
m_Layer: 0
m_Name: Stage02Button
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &2650272815419196348
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1458915873150721834}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 5651594774981243508}
m_Father: {fileID: 2361705476825752074}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.36}
m_AnchorMax: {x: 0.5, y: 0.36}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 280, y: 72}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1467687443961635546
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1458915873150721834}
m_CullTransparentMesh: 1
--- !u!114 &6572129107923074595
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1458915873150721834}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 0.8, b: 0.3, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &4131456494785851528
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1458915873150721834}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 6572129107923074595}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!1 &1467268788090281726
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5651594774981243508}
- component: {fileID: 6040077154455259841}
- component: {fileID: 3093407653909830820}
m_Layer: 0
m_Name: Label
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5651594774981243508
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1467268788090281726}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 2650272815419196348}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 280, y: 72}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &6040077154455259841
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1467268788090281726}
m_CullTransparentMesh: 1
--- !u!114 &3093407653909830820
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1467268788090281726}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: "2 \uCE5C\uAD6C \uCC3E\uAE30"
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: a5bc77ef7ce7c7e4d85ac578cfcd6646, type: 2}
m_sharedMaterial: {fileID: 8207275579423446409, guid: a5bc77ef7ce7c7e4d85ac578cfcd6646, type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 0.12, g: 0.2, b: 0.31, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 34
m_fontSizeBase: 34
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656bu
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &1734378736986617752
GameObject:
m_ObjectHideFlags: 0
@@ -972,7 +1370,6 @@ RectTransform:
- {fileID: 3194082258746179425}
- {fileID: 4532829656532669174}
- {fileID: 5317872834923726274}
- {fileID: 983150840242648897}
m_Father: {fileID: 3999613752604275916}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@@ -1136,6 +1533,127 @@ Transform:
- {fileID: 4847436933123184085}
m_Father: {fileID: 6696913924934804788}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &3130945442339227461
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5090946158136759876}
- component: {fileID: 9219190355672231004}
- component: {fileID: 665966907793119626}
- component: {fileID: 4435257573578294946}
m_Layer: 0
m_Name: ReturnButton
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5090946158136759876
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3130945442339227461}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 4450401086215199481}
m_Father: {fileID: 2530971626885568150}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.86, y: 0.2}
m_AnchorMax: {x: 0.86, y: 0.2}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 240, y: 62}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &9219190355672231004
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3130945442339227461}
m_CullTransparentMesh: 1
--- !u!114 &665966907793119626
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3130945442339227461}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 0.8, b: 0.3, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &4435257573578294946
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3130945442339227461}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 665966907793119626}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!1 &3169550458985797308
GameObject:
m_ObjectHideFlags: 0
@@ -2716,6 +3234,143 @@ MonoBehaviour:
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &5066269170581119357
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 4450401086215199481}
- component: {fileID: 6172297422653075404}
- component: {fileID: 1778027915619506758}
m_Layer: 0
m_Name: Label
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &4450401086215199481
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5066269170581119357}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5090946158136759876}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 240, y: 62}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &6172297422653075404
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5066269170581119357}
m_CullTransparentMesh: 1
--- !u!114 &1778027915619506758
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5066269170581119357}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: "\uCE5C\uAD6C \uBC18\uB0A9"
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: a5bc77ef7ce7c7e4d85ac578cfcd6646, type: 2}
m_sharedMaterial: {fileID: 8207275579423446409, guid: a5bc77ef7ce7c7e4d85ac578cfcd6646, type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 0.12, g: 0.2, b: 0.31, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 28
m_fontSizeBase: 28
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656bu
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &5310382666504661114
GameObject:
m_ObjectHideFlags: 0
@@ -3256,6 +3911,7 @@ RectTransform:
- {fileID: 3237793279449092937}
- {fileID: 2361705476825752074}
- {fileID: 2530971626885568150}
- {fileID: 983150840242648897}
m_Father: {fileID: 4274269384354727046}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@@ -3958,7 +4614,7 @@ RectTransform:
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3237793279449092937}
m_Father: {fileID: 3999613752604275916}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.07}
m_AnchorMax: {x: 0.5, y: 0.07}
@@ -4065,6 +4721,127 @@ MonoBehaviour:
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &7061760651837010790
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 2329883891830714780}
- component: {fileID: 5573057991149697080}
- component: {fileID: 4086799282263640400}
- component: {fileID: 3013192724825968427}
m_Layer: 0
m_Name: SpawnButton
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &2329883891830714780
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7061760651837010790}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 5049519322727858860}
m_Father: {fileID: 2530971626885568150}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.86, y: 0.3}
m_AnchorMax: {x: 0.86, y: 0.3}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 240, y: 62}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &5573057991149697080
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7061760651837010790}
m_CullTransparentMesh: 1
--- !u!114 &4086799282263640400
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7061760651837010790}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 0.8, b: 0.3, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &3013192724825968427
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7061760651837010790}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 4086799282263640400}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!1 &7158186905691569963
GameObject:
m_ObjectHideFlags: 0
@@ -4120,6 +4897,7 @@ MonoBehaviour:
m_EditorClassIdentifier: MagnetismHeroes.UnityRuntime::MagnetismHeroes.UnityRuntime.Bootstrap.GameApp
_stages:
- {fileID: 11400000, guid: 1bd91d4fcb826e54e861dc2275fcbdee, type: 2}
- {fileID: 11400000, guid: 74d68861704a8b541bb8ac1d37613620, type: 2}
- {fileID: 11400000, guid: 6e1df3aeda3cea54b8441c528e7f503a, type: 2}
- {fileID: 11400000, guid: 65683957e8f990649a840e8648fd470a, type: 2}
- {fileID: 11400000, guid: a8fae6d8bb4a24246b5cfc1e89d7075f, type: 2}
@@ -4134,6 +4912,9 @@ MonoBehaviour:
_playButton: {fileID: 8869707931174415660}
_continueButton: {fileID: 428650618540010171}
_stageOneButton: {fileID: 227025040056778259}
_stageTwoButton: {fileID: 4131456494785851528}
_spawnButton: {fileID: 3013192724825968427}
_returnButton: {fileID: 4435257573578294946}
_homeButton: {fileID: 1767103786293493516}
_selectionHomeButton: {fileID: 5673944335554824576}
_compositionButtons:
@@ -29,6 +29,8 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
{ _guide = Find(id); if (!_playingCount) { _source.Stop(); Advance(); } }
public void Clear()
{ _counts.Clear(); _guide = null; _playingCount = false; _source.Stop(); _source.clip = null; }
public void CancelGuide()
{ _guide = null; if (!_playingCount) { _source.Stop(); _source.clip = null; } }
private AudioClip Find(string id) { foreach (var voice in _clips) if (voice.id == id) return voice.clip; return null; }
private void Advance()
{
@@ -0,0 +1,21 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 25d91ab5f7c987840910f7a3683c8d3f, type: 3}
m_Name: Stage02
m_EditorClassIdentifier: MagnetismHeroes.UnityRuntime::MagnetismHeroes.UnityRuntime.StageAssets.StageAsset
_stageId: 2
_kind: 0
_initialGroups: 0100000001000000i
_targetGroups: 02000000i
_ballPositions:
- {x: -1.2, y: 0.8, z: 0}
- {x: 1.2, y: 0.8, z: 0}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 74d68861704a8b541bb8ac1d37613620
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+1 -1
View File
@@ -1,6 +1,6 @@
# 합체·분리 대표 단계
실제 `Stage06/10/14.asset`은 각각 `[1,1] → [2]`, `[2] → [1,1]`, `[3,1] → [2,2]`다. GameApp 프리팹의 선택 버튼은 저장된 진행에 따른 기존 순차 해금을 사용한다. 새 저장에서는 6·10·14가 잠겨 있다. 중간 카탈로그에는 1·6·10·14만 있으며 전체 30단계는 후속 제작 범위다.
실제 `Stage06/10/14.asset`은 각각 `[1,1] → [2]`, `[2] → [1,1]`, `[3,1] → [2,2]`다. GameApp 프리팹의 선택 버튼은 저장된 진행에 따른 기존 순차 해금을 사용한다. 새 저장에서는 6·10·14가 잠겨 있다. 현재 중간 카탈로그는 1·2·6·10·14이며 전체 30단계는 후속 제작 범위다.
친구를 끌어 다른 묶음에 가까이 놓으면 가장 가까운 두 구의 접촉 위치에 붙는다. 대상 구의 초록색 미리보기는 저장하지 않는다. 각 구의 크기와 ID는 유지하고, 각 묶음에 숫자·대표 얼굴·얇은 리본 망토 하나씩을 표시한다. 숫자는 카메라 위쪽으로 묶음 전체 구의 투영 경계보다 높게 놓는다.
+24
View File
@@ -0,0 +1,24 @@
# 저장·진행·자유놀이
JSON v1은 앱 전용 `progress.json`에 확정 상태만 기록한다. 각 단계의 미완료 상태는 독립적으로 하나씩 보관하고, 최근 자유놀이는 전역 하나다. 완료 기록은 누적되며, 재도전 미완료와 같은 단계의 완료 기록이 함께 존재할 수 있다. 성공 시 완료 추가·해당 미완료 제거·최근 자유놀이 교체가 한 번의 저장 호출로 일어난다. 자동으로 다음 단계로 이동하지 않는다.
카탈로그는 현재 1·2·6·10·14다. 2번은 실제 `[1,1] → [2]` 계수 퍼즐이며 1번 완료 뒤 해금된다. 6·10·14는 정상 순차 잠금 규칙을 유지한다. 전체 30단계와 첫 시범은 후속 콘텐츠 범위다. `HasSeenTutorial` / `MarkTutorialSeen`는 `merge`·`split`만 한 번씩 저장하며, 이 단계에서 시범 자체를 추가하지 않는다.
성공한 구의 ID·위치·소속·카메라를 유지한 자유놀이에서 합체·분리·이동한다. **하나 꺼내기**는 현재 ID와 충돌하지 않는 구 하나를 기존과 같은 크기로 만든다. 화면 안에서 친구·배경과 겹치지 않는 공간을 검사하며 구 합계 10에서 추가를 거부한다. **친구 반납**은 탭한 친구의 모든 구를 반납한다. 일부만 반납하려면 먼저 분리한다. 빈 보드도 저장하고 다시 꺼낼 수 있다. 자유놀이 조작은 완료 기록을 취소하지 않는다.
확정 이동·합체·분리·계수·공급·반납, 카메라 조작 종료, 화면 이동, 일시정지에서 저장한다. 미확정 드래그와 선택·접촉 표시를 저장하지 않는다. 저장 실패는 현재 화면에 짧은 한국어 안내를 보여주고 실제 파일 오류를 개발 로그에 남긴다. 다음 저장 성공 시 안내를 비운다. 복원은 Home에서 시작하며 이전 계수나 성공 음성을 반복하지 않는다. 같은 보드의 자유놀이 조작 중에는 마지막 숫자 대기열을 보존하고 화면 이동·일시정지는 음성을 정리한다.
임시 파일을 디스크에 flush한 뒤 primary를 교체한다. 유효 primary를 backup으로 보존하며 불완전 `.tmp`는 읽지 않는다. 손상 primary는 유효 backup 전체로 복구한다. 다음 저장 전에 손상 원본을 `.damaged-*`로 복사하며, 손상 primary를 유효 backup 위에 덮어쓰지 않는다. 둘 다 손상되면 정상 초기 상태와 안내를 표시하고 원본 증거를 보존한다.
검증 명령:
```powershell
pwsh -NoProfile -File tools/verify.ps1 -Phase EditMode -Milestone Persistence
pwsh -NoProfile -File tools/verify.ps1 -Phase PlayMode -Milestone Persistence
pwsh -NoProfile -File tools/verify.ps1 -Phase AndroidBuild -Milestone Persistence
pwsh -NoProfile -File tools/verify.ps1 -Phase AndroidSmoke -Milestone Persistence
```
`artifacts/verification/Persistence/`의 XML, 실제 명령과 종료 코드, APK SHA256, AVD serial, PNG, JSON 전후 쌍과 런타임 로그가 근거다. `red/`는 잠긴 임시 파일과 저장 안내·공급 UI 부재의 실제 실패 증거다. Android 검사는 실제 터치로 1/2 양수 부분 계수를 만들고 일시정지·force-stop·재시작한다. test-only 완료 진행 fixture는 대표 6 접근에만 사용하며 부분 계수 ID·위치를 주입하지 않는다. 실제 이동한 6번 미완료, 실제 완료한 1번 자유놀이와 번갈아 전체 필드를 비교한다. 공급 10개 제한·친구 전체 반납·빈 보드 재공급·쓰기 실패·backup 전체 복구·두 파일 손상을 검사한다. 에뮬레이터 검사는 실제 모바일 하드웨어 성능 판정이 아니다.
Stage02의 `count_02`는 이미 연결된 초안 음성을 사용하며 사람 청취 승인은 별도다. 승인된 `count_01`·`guide_collect`·`guide_count` WAV를 변경하지 않는다. SFX/BGM은 [요청표](audio-requests.md)를 유지하며 사용자 요청대로 나중에 추가한다.
+182
View File
@@ -0,0 +1,182 @@
param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$EvidenceDir,[string]$Serial='emulator-5554')
$ErrorActionPreference='Stop'
$EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path
$adb=Join-Path $env:LOCALAPPDATA 'Android/Sdk/platform-tools/adb.exe'
$package='com.magnetismheroes.game'; $activity="$package/com.unity3d.player.UnityPlayerGameActivity"
$remote="/sdcard/Android/data/$package/files/progress.json"
$checks=[Collections.Generic.List[object]]::new(); $commands=[Collections.Generic.List[object]]::new()
$result=[ordered]@{milestone='Persistence';sourceCommit=(& git rev-parse HEAD);sourceDirty=[bool](& git status --porcelain);serial=$Serial;apkSha256=(Get-FileHash -LiteralPath $ApkPath -Algorithm SHA256).Hash;status='failed'}
$physicalInput=$false
function Adb([string[]]$Arguments) {
$output=& $adb -s $Serial @Arguments 2>&1; $code=$LASTEXITCODE
$commands.Add(@{command=('adb -s '+$Serial+' '+($Arguments -join ' '));exitCode=$code;output=($output -join "`n")})
if ($code -ne 0) { throw "ADB exit $code : $($Arguments -join ' ')" }; return ($output -join "`n").Trim()
}
function Check([string]$Id,[bool]$Passed,[string]$Actual) {
$checks.Add(@{id=$Id;passed=$Passed;actual=$Actual}); if (!$Passed) { throw "Persistence check failed: $Id ($Actual)" }
}
function Step([string]$Name,[string[]]$Arguments=@(),[int]$Delay=500) {
& (Join-Path $PSScriptRoot 'observe.ps1') -Step $Name -AdbArguments $Arguments -Serial $Serial -EvidenceDir $EvidenceDir -DelayMilliseconds $Delay
if ($Arguments.Count -gt 1 -and $Arguments[1] -eq 'input') { $script:physicalInput=$false }
}
function Save([string]$Name) { Adb @('pull',$remote,(Join-Path $EvidenceDir "$Name.json")) | Out-Null; return Get-Content -LiteralPath (Join-Path $EvidenceDir "$Name.json") -Raw | ConvertFrom-Json }
function Same($First,$Second) { return ($First | ConvertTo-Json -Depth 12 -Compress) -eq ($Second | ConvertTo-Json -Depth 12 -Compress) }
function Restart([string]$Name) {
Adb @('shell','input','keyevent','3') | Out-Null; Start-Sleep -Milliseconds 700
Adb @('shell','am','force-stop',$package) | Out-Null
Step "$Name-home" @('shell','am','start','-W','-n',$activity) 6500
}
function Touch([int[]]$Contacts) {
if (!$script:physicalInput) {
& (Join-Path $PSScriptRoot 'send-composition-touch.ps1') -Contacts @(30,400,999,100)
Start-Sleep -Milliseconds 100
& (Join-Path $PSScriptRoot 'send-composition-touch.ps1') -Contacts @(30,400,999,0)
Start-Sleep -Milliseconds 100; $script:physicalInput=$true
$commands.Add(@{command='EmulatorController.sendTouch warmup 30,400,999 down/up';exitCode=0})
}
& (Join-Path $PSScriptRoot 'send-composition-touch.ps1') -Contacts $Contacts
$commands.Add(@{command=('EmulatorController.sendTouch contacts='+($Contacts -join ','));exitCode=0})
}
function BallPoint($Board,[string]$Id) {
$ball=@($Board.balls | Where-Object id -eq $Id)[0]; $camera=$Board.camera
$rotation=[Numerics.Quaternion]::new($camera.rotation.x,$camera.rotation.y,$camera.rotation.z,$camera.rotation.w)
$origin=[Numerics.Vector3]::new($camera.target.x,$camera.target.y,$camera.target.z)+[Numerics.Vector3]::Transform([Numerics.Vector3]::new(0,0,-$camera.distance),$rotation)
$local=[Numerics.Vector3]::Transform([Numerics.Vector3]::new($ball.position.x,$ball.position.y,$ball.position.z)-$origin,[Numerics.Quaternion]::Inverse($rotation))
$scale=540/([Math]::Tan(23*[Math]::PI/180)*$local.Z)
return @([int][Math]::Round(1200+$local.X*$scale),[int][Math]::Round(540-$local.Y*$scale))
}
function TapBall([string]$Name,$Board,[string]$Id) { $point=BallPoint $Board $Id; Step $Name @('shell','input','tap',"$($point[0])","$($point[1])") }
function SelectStage([string]$Name,[int]$Id) {
Step "$Name-selection" @('shell','input','tap','1200','616')
$point=switch ($Id) { 1 {@(1200,562)} 2 {@(1200,691)} 6 {@(480,562)} 14 {@(1200,389)} default {throw 'Unsupported test stage'} }
Step "$Name-stage" @('shell','input','tap',"$($point[0])","$($point[1])")
}
function RecordAudio([string]$Name,[int]$Seconds) {
$arguments=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-audio.ps1'),'-OutputPath',(Join-Path $EvidenceDir "$Name.wav"),'-Seconds',"$Seconds")
$process=Start-Process pwsh -ArgumentList ($arguments | ForEach-Object {'"'+$_+'"'}) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $EvidenceDir "$Name.stdout") -RedirectStandardError (Join-Path $EvidenceDir "$Name.stderr") -PassThru
Start-Sleep -Seconds 2; return $process
}
try {
Check 'clean-source' (!$result.sourceDirty) $result.sourceCommit
$build=Get-Content -LiteralPath (Join-Path $EvidenceDir 'androidbuild-evidence.json') -Raw | ConvertFrom-Json
Check 'build-provenance' ($build.status -eq 'passed' -and $build.exitCode -eq 0 -and !$build.sourceDirty -and !$build.sourceDirtyEnd -and $build.sourceCommit -eq $result.sourceCommit -and $build.sourceCommitEnd -eq $result.sourceCommit -and $build.apkSha256 -eq $result.apkSha256) $result.apkSha256
Adb @('install','-r',$ApkPath) | Out-Null; Adb @('logcat','-c') | Out-Null; Adb @('shell','pm','clear',$package) | Out-Null
$result.device=@{model=(Adb @('shell','getprop','ro.product.model'));api=(Adb @('shell','getprop','ro.build.version.sdk'));abi=(Adb @('shell','getprop','ro.product.cpu.abilist'));bridge=(Adb @('shell','getprop','ro.dalvik.vm.native.bridge'))}
Step 'initial-home' @('shell','am','start','-W','-n',$activity) 6500
Step 'initial-selection' @('shell','input','tap','1200','616'); Step 'initial-locked-two' @('shell','input','tap','1200','691')
$locked=Save 'locked-two'; Check 'new-save-only-one-unlocked' ($locked.puzzleStates.Count -eq 0 -and $locked.completedStageIds.Count -eq 0) 'Stage02 tap creates no puzzle before Stage01 completion'
Step 'one-start' @('shell','input','tap','1200','562'); Step 'one-collect' @('shell','input','swipe','1200','510','1280','510','650')
$one=Save 'one-collected'; TapBall 'one-count-success' $one.puzzleStates[0] $one.puzzleStates[0].balls[0].id
$free=Save 'one-free'; Check 'one-completion-atomic' ($free.completedStageIds.Count -eq 1 -and $free.completedStageIds[0] -eq 1 -and $free.puzzleStates.Count -eq 0 -and $free.latestFreePlay.sourceStageId -eq 1) 'Completed1, removed puzzle1, latestFree1 in one file'
Step 'one-home' @('shell','input','tap','312','108'); SelectStage 'two' 2
Step 'two-merge-collect' @('shell','input','swipe','1009','510','1291','510','650')
$two=Save 'two-collected'; $board=@($two.puzzleStates | Where-Object stageId -eq 2)[0]
Check 'two-real-collection' ($board.groups.Count -eq 1 -and $board.collectedBallIds.Count -eq 2 -and $board.countedBallIds.Count -eq 0) 'Actual confirmed drag merged both balls and collected both'
TapBall 'two-count-one-of-two' $board $board.balls[0].id
Step 'two-orbit' @('shell','input','swipe','2100','440','2180','480','650')
Touch @(1700,400,501,100,1900,400,502,100); Start-Sleep -Milliseconds 150
Touch @(1700,400,501,100,1920,400,502,100); Start-Sleep -Milliseconds 150
Touch @(1700,400,501,0,1920,400,502,0); Step 'two-camera-confirmed'
$partial=Save 'two-partial-before-pause'; $partialBoard=@($partial.puzzleStates | Where-Object stageId -eq 2)[0]
Check 'positive-partial-count-real-tap' ($partialBoard.countedBallIds.Count -eq 1 -and $partialBoard.balls.Count -eq 2 -and $partialBoard.collectedBallIds.Count -eq 2 -and $partial.completedStageIds -notcontains 2) 'Actual tap saved exactly1/2 unfinished'
Check 'nonidentity-camera-and-zoom' ([Math]::Abs($partialBoard.camera.rotation.w-1) -gt .001 -and [Math]::Abs($partialBoard.camera.distance-8) -gt .01) ($partialBoard.camera | ConvertTo-Json -Compress)
Restart 'two-pause-force-stop'; $restoreAudio=RecordAudio 'audio-partial-restore' 8; SelectStage 'two-restored' 2
$restored=Save 'two-partial-after-restart'; Check 'positive-partial-count-camera-restored' (Same $partialBoard @($restored.puzzleStates | Where-Object stageId -eq 2)[0]) 'IDs positions membership collected counted target rotation distance all match'
Check 'restore-audio-recorder-finished' ($restoreAudio.WaitForExit(30000)) 'Actual emulator PCM capture finished'
$restoreAudio.Refresh(); Check 'restore-audio-recorder-exit' ($restoreAudio.ExitCode -eq 0) "$($restoreAudio.ExitCode)"
$wave=[IO.File]::ReadAllBytes((Join-Path $EvidenceDir 'audio-partial-restore.wav')); $sum=0.0; $samples=0
for ($index=44; $index+1 -lt $wave.Length; $index+=2) { $sample=[BitConverter]::ToInt16($wave,$index); $sum+=[double]$sample*$sample; $samples++ }
$rms=if ($samples -gt 0) {[Math]::Sqrt($sum/$samples)} else {[double]::PositiveInfinity}
Check 'restore-no-historical-count-or-guide-audio' ($samples -gt 0 -and $rms -lt 32) "Actual restore PCM rms=$rms samples=$samples"
Step 'two-home' @('shell','input','tap','312','108'); Adb @('shell','am','force-stop',$package) | Out-Null
# Test-only completion fixture opens representative6; it retains the real counted puzzle and real Free1.
$fixture=Save 'before-unlock-fixture'; $fixture.completedStageIds=@(1..5)
$fixturePath=Join-Path $EvidenceDir 'fixture-completion-access-six.json'; $fixture | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $fixturePath -Encoding utf8
Adb @('push',$fixturePath,$remote) | Out-Null; Step 'six-access-home' @('shell','am','start','-W','-n',$activity) 6500; SelectStage 'six' 6
Step 'six-real-move-unfinished' @('shell','input','swipe','1009','510','930','560','650')
$six=Save 'two-unfinished-before'; Check 'two-independent-unfinished' ($six.puzzleStates.Count -eq 2 -and (Same $partialBoard @($six.puzzleStates | Where-Object stageId -eq 2)[0]) -and @($six.puzzleStates | Where-Object stageId -eq 6)[0].groups.Count -eq 2) 'Stage02 partially counted and Stage06 actually moved coexist'
Step 'six-home' @('shell','input','tap','312','108'); Step 'free-continue' @('shell','input','tap','1200','810')
$freeBefore=Save 'free-before-move'; $point=BallPoint $freeBefore.latestFreePlay $freeBefore.latestFreePlay.balls[0].id
Step 'free-actual-move' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]+45)","$($point[1]+20)",'650')
$freeMoved=Save 'free-moved-before'; Check 'free-independent-of-two-puzzles' ($freeMoved.puzzleStates.Count -eq 2 -and (Same $six.puzzleStates $freeMoved.puzzleStates) -and !(Same $freeBefore.latestFreePlay.balls $freeMoved.latestFreePlay.balls)) 'Only latestFree board positions changed'
Restart 'three-boards-restart'; Step 'free-restored' @('shell','input','tap','1200','810')
Check 'latest-free-whole-restore' (Same $freeMoved (Save 'free-restored-after')) 'Two unfinished plus latestFree all restore'
Step 'free-home' @('shell','input','tap','312','108'); SelectStage 'two-second-restore' 2
Check 'two-second-independent-restore' (Same $partialBoard @((Save 'two-second-after').puzzleStates | Where-Object stageId -eq 2)[0]) 'Partial counting board remains unchanged through Free/six'
Step 'two-second-home' @('shell','input','tap','312','108'); SelectStage 'six-second-restore' 6
Check 'six-second-independent-restore' (Same @($six.puzzleStates | Where-Object stageId -eq 6)[0] @((Save 'six-second-after').puzzleStates | Where-Object stageId -eq 6)[0]) 'Moved composition board restores independently'
Step 'six-second-home' @('shell','input','tap','312','108'); Step 'free-supply-continue' @('shell','input','tap','1200','810')
for ($index=1; $index -le 9; $index++) { Step "free-spawn-$index" @('shell','input','tap','2064','756') }
$full=Save 'free-ten'; Check 'actual-free-cap-ten' ($full.latestFreePlay.balls.Count -eq 10 -and @($full.latestFreePlay.balls.id | Sort-Object -Unique).Count -eq 10) 'Ten unique actual spawned balls'
Step 'free-eleventh-refused' @('shell','input','tap','2064','756'); Check 'ten-add-refused-no-duplicate' (Same $full (Save 'free-cap-after')) 'Extra tap leaves exact saved file state'
# Merge two actual singleton friends, then return the whole selected friend.
$first=$full.latestFreePlay.balls[0].id; $second=$full.latestFreePlay.balls[1].id
$start=BallPoint $full.latestFreePlay $first; $end=BallPoint $full.latestFreePlay $second
Step 'free-merge-two' @('shell','input','swipe',"$($start[0])","$($start[1])","$($end[0]-70)","$($end[1])",'650')
$merged=Save 'free-merged'; $whole=@($merged.latestFreePlay.groups | Where-Object {$_.ballIds.Count -gt 1})[0]
Check 'actual-free-merge-for-whole-return' ($null -ne $whole) 'Real merge created a multi-ball friend'
TapBall 'free-select-whole-friend' $merged.latestFreePlay $whole.ballIds[0]; Step 'free-whole-return' @('shell','input','tap','2064','864')
$returned=Save 'free-whole-return-after'; Check 'whole-friend-return' ($returned.latestFreePlay.balls.Count -eq 10-$whole.ballIds.Count -and @($returned.latestFreePlay.balls | Where-Object {$whole.ballIds -contains $_.id}).Count -eq 0) 'Every selected friend member returned together'
Step 'free-resupply-after-return' @('shell','input','tap','2064','756'); $supply=Save 'free-resupplied'
Check 'resupply-after-return' ($supply.latestFreePlay.balls.Count -eq $returned.latestFreePlay.balls.Count+1) 'Capacity available again after return'
while ($supply.latestFreePlay.balls.Count -gt 0) {
TapBall "empty-select-$($supply.latestFreePlay.balls.Count)" $supply.latestFreePlay $supply.latestFreePlay.balls[0].id
$beforeCount=$supply.latestFreePlay.balls.Count
Step "empty-return-$beforeCount" @('shell','input','tap','2064','864'); $supply=Save "empty-after-$beforeCount"
Check "empty-return-progress-$beforeCount" ($supply.latestFreePlay.balls.Count -lt $beforeCount) 'Selected friend actually returned'
}
Check 'empty-free-board-valid' ($supply.latestFreePlay.groups.Count -eq 0 -and $supply.completedStageIds -contains 1 -and $supply.puzzleStates.Count -eq 2) 'Empty board keeps completion and both unfinished'
Step 'empty-respawn' @('shell','input','tap','2064','756'); $respawn=Save 'empty-respawn-after'; Check 'empty-respawn-one' ($respawn.latestFreePlay.balls.Count -eq 1) 'Supply works from empty board'
# A directory at the temporary-file path causes a real FileStream failure, without touching valid primary/backup.
$validBefore=Adb @('shell','sha256sum',$remote); Adb @('shell','mkdir',($remote+'.tmp')) | Out-Null
$point=BallPoint $respawn.latestFreePlay $respawn.latestFreePlay.balls[0].id
Step 'failed-write-visible-play-notice' @('shell','input','swipe',"$($point[0])","$($point[1])","$($point[0]+35)","$($point[1])",'650')
Check 'failed-write-primary-bytes-preserved' ((Adb @('shell','sha256sum',$remote)) -eq $validBefore) 'Real write failure preserves primary SHA256'
Step 'failed-camera-visible-notice' @('shell','input','swipe','2100','440','2170','475','650')
Adb @('shell','input','keyevent','3') | Out-Null; Start-Sleep -Milliseconds 700
Step 'failed-pause-resume-notice' @('shell','am','start','-W','-n',$activity) 1500
Check 'failed-pause-primary-bytes-preserved' ((Adb @('shell','sha256sum',$remote)) -eq $validBefore) 'Camera and pause failures preserve same SHA256'
Adb @('shell','rmdir',($remote+'.tmp')) | Out-Null
Step 'save-recovered-notice-cleared' @('shell','input','tap','312','108'); $recovered=Save 'write-recovered'
Check 'write-recovery-saves-current-whole-state' (!(Same $respawn.latestFreePlay $recovered.latestFreePlay) -and (Same $respawn.puzzleStates $recovered.puzzleStates)) 'Recovered write includes committed free move/camera, preserves both puzzles'
Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Encoding utf8
$runtime=Get-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Raw
Check 'actual-write-errors-logged' ($runtime -match '\[ProgressSave\].*(denied|directory|UnauthorizedAccess|Access)') 'Actual filesystem error in developer log'
SelectStage 'two-final-count' 2; $secondCount=Save 'two-before-final-count'; $secondBoard=@($secondCount.puzzleStates | Where-Object stageId -eq 2)[0]
$uncounted=@($secondBoard.balls | Where-Object {$secondBoard.countedBallIds -notcontains $_.id})[0]
$countAudio=RecordAudio 'audio-stage-two-final-count' 8
TapBall 'two-final-count-success' $secondBoard $uncounted.id
$twoComplete=Save 'two-final-count-completed'; Check 'two-latest-free-replaces-one-preserves-six' ($twoComplete.latestFreePlay.sourceStageId -eq 2 -and $twoComplete.latestFreePlay.balls.Count -eq 2 -and $twoComplete.puzzleStates.Count -eq 1 -and $twoComplete.puzzleStates[0].stageId -eq 6 -and $twoComplete.completedStageIds -contains 1) 'Second actual count transitions to Free2 while retaining unfinished6 and completion1'
TapBall 'two-free-select-during-final-number' $twoComplete.latestFreePlay $twoComplete.latestFreePlay.balls[0].id
Step 'two-free-split-during-final-number' @('shell','input','tap','312','832'); Step 'two-free-cancel-during-final-number' @('shell','input','tap','312','832')
Check 'count-two-audio-recorder-finished' ($countAudio.WaitForExit(30000)) 'Actual final count PCM capture finished'; $countAudio.Refresh()
Check 'count-two-audio-recorder-exit' ($countAudio.ExitCode -eq 0) "$($countAudio.ExitCode)"
$comparison=Join-Path $EvidenceDir 'audio-stage-two-count02-match.json'
& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath 'Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/count_02.wav' -CapturedPath (Join-Path $EvidenceDir 'audio-stage-two-final-count.wav') -OutputPath $comparison
$match=Get-Content -LiteralPath $comparison -Raw | ConvertFrom-Json; Check 'actual-count-two-plays-through-free-split-cancel' ($LASTEXITCODE -eq 0 -and $match.passed -and $match.normalizedCorrelation -ge .95) ($match | ConvertTo-Json -Compress)
# Corrupt primary, restore complete valid backup, and preserve forensic bytes on next confirmed save.
Adb @('shell','am','force-stop',$package) | Out-Null; Adb @('pull',($remote+'.bak'),(Join-Path $EvidenceDir 'valid-backup-before-damage.json')) | Out-Null
$backup=Get-Content -LiteralPath (Join-Path $EvidenceDir 'valid-backup-before-damage.json') -Raw | ConvertFrom-Json
$damagePath=Join-Path $EvidenceDir 'damaged-primary.txt'; [IO.File]::WriteAllText($damagePath,'damaged-primary-persistence')
Adb @('push',$damagePath,$remote) | Out-Null; Step 'backup-recovery-home-notice' @('shell','am','start','-W','-n',$activity) 6500
Check 'load-does-not-overwrite-damaged-primary' ((Adb @('shell','cat',$remote)) -eq 'damaged-primary-persistence') 'Damaged original remains before any new save'
Step 'backup-restored-free' @('shell','input','tap','1200','810'); Check 'backup-whole-state-restored' (Same $backup (Save 'backup-restored-all')) 'Entire valid backup restored without partial primary load'
$forensic=Adb @('shell','ls',($remote+'.damaged-*')); $result.forensicFiles=$forensic
Check 'damaged-primary-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-primary-persistence') $forensic
Adb @('shell','am','force-stop',$package) | Out-Null
[IO.File]::WriteAllText($damagePath,'damaged-both-primary'); Adb @('push',$damagePath,$remote) | Out-Null
$badBackup=Join-Path $EvidenceDir 'damaged-backup.txt'; [IO.File]::WriteAllText($badBackup,'damaged-both-backup'); Adb @('push',$badBackup,($remote+'.bak')) | Out-Null
Step 'both-damaged-initial-home-notice' @('shell','am','start','-W','-n',$activity) 6500
Check 'both-damaged-originals-preserved-on-load' ((Adb @('shell','cat',$remote)) -eq 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-both-backup') 'Both original byte strings survive failed load'
Step 'both-damaged-new-selection' @('shell','input','tap','1200','616'); $initial=Save 'both-damaged-new-initial'
Check 'both-damage-normal-initial' ($initial.completedStageIds.Count -eq 0 -and $initial.puzzleStates.Count -eq 0 -and $null -eq $initial.latestFreePlay) 'No valid save starts clean initial state'
Check 'both-damage-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-both-backup') 'Primary forensic and damaged backup retained'
$result.sourceCommitEnd=(& git rev-parse HEAD); $result.sourceDirtyEnd=[bool](& git status --porcelain)
Check 'clean-source-end' (!$result.sourceDirtyEnd -and $result.sourceCommitEnd -eq $result.sourceCommit) $result.sourceCommitEnd
$result.status='passed'
} catch { $result.error=$_.Exception.Message; Write-Host $result.error -ForegroundColor Red }
finally {
$result.checks=$checks.ToArray(); $result.commands=$commands.ToArray(); $result.finishedUtc=[DateTime]::UtcNow.ToString('o')
$result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-persistence-evidence.json') -Encoding utf8
}
if ($result.status -ne 'passed') { exit 1 }; Write-Host "Persistence actual Android checks passed: $($checks.Count). PNG visual review remains explicit."; exit 0
+4
View File
@@ -1,4 +1,8 @@
param([Parameter(Mandatory)][string]$ApkPath, [string]$Milestone='FirstSlice', [Parameter(Mandatory)][string]$EvidenceDir, [string]$Serial='emulator-5554')
if ($Milestone -eq 'Persistence') {
& (Join-Path $PSScriptRoot 'exercise-persistence.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir -Serial $Serial
exit $LASTEXITCODE
}
if ($Milestone -eq 'Composition') {
& (Join-Path $PSScriptRoot 'exercise-composition.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir -Serial $Serial
exit $LASTEXITCODE