Add atomic composition controls and representative stages

This commit is contained in:
NINI
2026-10-05 05:03:40 +09:00
parent 1e7c258631
commit 4c411e446d
56 changed files with 5911 additions and 1601 deletions
+57
View File
@@ -92,6 +92,63 @@ namespace MagnetismHeroes.Core
} }
// Both groups participated in a committed drop, so all their balls are collected. // Both groups participated in a committed drop, so all their balls are collected.
public RuleResult TryMoveAndMerge(string groupId, Position3 delta, string targetGroupId)
{
if (!ValidId(groupId) || !_groups.ContainsKey(groupId) || !ValidId(targetGroupId) || !_groups.ContainsKey(targetGroupId))
return RuleResult.Reject(RejectionReason.UnknownId);
if (groupId == targetGroupId) return RuleResult.Reject(RejectionReason.InvalidGroup);
var move = TryMoveGroup(groupId, delta);
if (!move.Accepted) return move;
return TryMerge(groupId, targetGroupId);
}
public BallState[] PreviewSplit(string groupId, IReadOnlyList<string> selectedIds, Position3 delta)
{
if (!ValidId(groupId) || !_groups.TryGetValue(groupId, out var members) || selectedIds == null ||
selectedIds.Count == 0 || selectedIds.Count >= members.Count || !delta.IsValid ||
(delta.x == 0 && delta.y == 0 && delta.z == 0)) return null;
var selected = new HashSet<string>(selectedIds, StringComparer.Ordinal);
if (selected.Count != selectedIds.Count || selected.Any(id => !members.Contains(id))) return null;
var chosen = members.Where(selected.Contains).ToArray(); var rest = members.Where(id => !selected.Contains(id)).ToArray();
var origin = Center(rest); var destination = Center(chosen);
destination = new Position3(destination.x + delta.x, destination.y + delta.y, destination.z + delta.z);
double dx = destination.x - origin.x, dy = destination.y - origin.y, dz = destination.z - origin.z;
double distance = Math.Sqrt(dx * dx + dy * dy + dz * dz);
// Compact both subsets into connected rows; disconnected taps still yield exactly two friends.
double clearance = (chosen.Length + rest.Length) * 0.5 + 1.1;
if (distance < clearance)
{
if (distance < 0.0001) { dx = delta.x; dy = delta.y; dz = delta.z; distance = Math.Sqrt(dx * dx + dy * dy + dz * dz); }
destination = new Position3(origin.x + (float)(dx / distance * clearance), origin.y + (float)(dy / distance * clearance), origin.z + (float)(dz / distance * clearance));
}
var result = new List<BallState>();
Arrange(rest, origin, result); Arrange(chosen, destination, result);
return result.All(ball => ball.position.IsValid) ? result.ToArray() : null;
}
public RuleResult TrySplitAndMove(string groupId, IReadOnlyList<string> selectedIds, string newGroupId, Position3 delta)
{
if (!ValidId(newGroupId) || _groups.ContainsKey(newGroupId)) return RuleResult.Reject(RejectionReason.DuplicateId);
var positions = PreviewSplit(groupId, selectedIds, delta);
if (positions == null) return RuleResult.Reject(RejectionReason.InvalidSelection);
var split = TrySplit(groupId, selectedIds, newGroupId);
if (!split.Accepted) return split;
foreach (var ball in positions) _balls[ball.id] = ball.position;
return split;
}
private Position3 Center(string[] ids)
{
double x = 0, y = 0, z = 0;
foreach (var id in ids) { var point = _balls[id]; x += point.x; y += point.y; z += point.z; }
return new Position3((float)(x / ids.Length), (float)(y / ids.Length), (float)(z / ids.Length));
}
private static void Arrange(string[] ids, Position3 center, List<BallState> result)
{
for (int index = 0; index < ids.Length; index++) result.Add(new BallState { id = ids[index],
position = new Position3(center.x + index - (ids.Length - 1) * 0.5f, center.y, center.z) });
}
public RuleResult TryMerge(string firstGroupId, string secondGroupId) public RuleResult TryMerge(string firstGroupId, string secondGroupId)
{ {
if (!ValidId(firstGroupId) || !ValidId(secondGroupId) || !_groups.TryGetValue(firstGroupId, out var first) if (!ValidId(firstGroupId) || !ValidId(secondGroupId) || !_groups.TryGetValue(firstGroupId, out var first)
@@ -19,6 +19,7 @@ namespace MagnetismHeroes.Core
public bool HasFreePlay => _progress.latestFreePlay != null; public bool HasFreePlay => _progress.latestFreePlay != null;
public int[] CompletedStageIds => (int[])_progress.completedStageIds.Clone(); public int[] CompletedStageIds => (int[])_progress.completedStageIds.Clone();
public IReadOnlyList<StageDefinition> Definitions => Array.AsReadOnly(_definitions); public IReadOnlyList<StageDefinition> Definitions => Array.AsReadOnly(_definitions);
public bool HasUnfinishedStage(int stageId) => _progress.puzzleStates.Any(state => state.stageId == stageId);
public GameSession(IEnumerable<StageDefinition> definitions, IProgressStore store) public GameSession(IEnumerable<StageDefinition> definitions, IProgressStore store)
{ {
@@ -62,6 +63,9 @@ namespace MagnetismHeroes.Core
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 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 Count(string ballId) => Commit(() => Board.TryCount(ballId));
public void CommitCamera(CameraState camera) public void CommitCamera(CameraState camera)
@@ -0,0 +1,117 @@
using System;
using System.Linq;
using System.Reflection;
using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Bootstrap;
using MagnetismHeroes.UnityRuntime.Presentation;
using MagnetismHeroes.UnityRuntime.StageAssets;
using TMPro;
using UnityEditor;
using UnityEngine;
namespace MagnetismHeroes.Editor
{
public static class CompositionAssets
{
private const string Presentation = "Assets/MagnetismHeroes/UnityRuntime/Presentation/";
private const string Glyphs = "0123456789 자석 영웅 숫자 친구와 입체 놀이터 친구 찾기 자유 놀이 이어하기 숫자 친구를 만나 볼까? 처음으로 숫자 1 친구 찾기 친구를 찾아 끌어와 볼까? 센 친구 0 / 1 빈 곳을 쓸면 회전 · 두 손가락으로 확대 이전 기록으로 이어서 시작할게요. 기록을 읽지 못해 처음부터 시작할게요. 친구를 먼저 끌어와 볼까? 자유 놀이터 해냈어! 친구와 자유롭게 놀아 보자. 모두 모았네! 하나씩 눌러 세어 보자. 붙이거나 나누어 목표 친구를 만들어 보자. 선택 구를 골라 끌어내 보자. 나누기 취소 친구 만들기 합체 분리 둘과 둘";
[MenuItem("Magnetism Heroes/Build Composition Assets")]
public static void Build()
{
var font = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(Presentation + "Fonts/NotoSansCJKkr-FirstSlice.asset");
string needed = new string(Glyphs.Where(character => !font.HasCharacter(character)).Distinct().ToArray());
if (needed.Length > 0)
{
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 composition glyphs: " + missing); }
finally { font.atlasPopulationMode = AtlasPopulationMode.Static; }
}
font.atlasPopulationMode = AtlasPopulationMode.Static; EditorUtility.SetDirty(font);
var stages = new[] {
AssetDatabase.LoadAssetAtPath<StageAsset>(FirstSliceAssets.StageOnePath),
Stage(6, new[] { 1, 1 }, new[] { 2 }, new[] { new Vector3(-1.2f, .8f, 0), new Vector3(1.2f, .8f, 0) }),
Stage(10, new[] { 2 }, new[] { 1, 1 }, new[] { new Vector3(-.5f, .8f, 0), new Vector3(.5f, .8f, 0) }),
Stage(14, new[] { 3, 1 }, new[] { 2, 2 }, new[] { new Vector3(-2, .8f, 0), new Vector3(-1, .8f, 0), new Vector3(0, .8f, 0), new Vector3(2, .8f, 0) }) };
var root = PrefabUtility.LoadPrefabContents(FirstSliceAssets.AppPrefabPath);
try
{
var app = root.GetComponent<GameApp>(); var serialized = new SerializedObject(app);
var stageField = serialized.FindProperty("_stages"); stageField.arraySize = stages.Length;
for (int index = 0; index < stages.Length; index++) stageField.GetArrayElementAtIndex(index).objectReferenceValue = stages[index];
var selection = root.transform.Find("Interface/SafeArea/StageSelection");
var template = selection.Find("Stage01Button").gameObject;
((RectTransform)template.transform).sizeDelta = new Vector2(280, 90);
var buttons = serialized.FindProperty("_compositionButtons"); buttons.arraySize = 3;
var anchors = new[] { new Vector2(.2f, .48f), new Vector2(.8f, .48f), new Vector2(.5f, .64f) };
for (int index = 0; index < 3; index++)
{
int id = stages[index + 1].StageId; string name = $"Stage{id:D2}Button";
var existing = selection.Find(name);
var button = existing == null ? UnityEngine.Object.Instantiate(template, selection) : existing.gameObject;
button.name = name; var rect = (RectTransform)button.transform;
rect.anchorMin = rect.anchorMax = anchors[index]; rect.anchoredPosition = Vector2.zero;
button.GetComponentInChildren<TMP_Text>().text = $"{id} 친구 만들기";
buttons.GetArrayElementAtIndex(index).objectReferenceValue = button.GetComponent<UnityEngine.UI.Button>();
}
var play = root.transform.Find("Interface/SafeArea/Play");
var buttonTemplate = play.Find("HomeButton").gameObject;
foreach (var spec in new[] { ("SplitButton", "나누기", "_splitButton"), ("CancelSplitButton", "취소", "_cancelSplitButton") })
{
var existing = play.Find(spec.Item1);
var button = existing == null ? UnityEngine.Object.Instantiate(buttonTemplate, play) : existing.gameObject;
button.name = spec.Item1; var rect = (RectTransform)button.transform;
rect.anchorMin = rect.anchorMax = new Vector2(.13f, .23f); rect.anchoredPosition = Vector2.zero; rect.sizeDelta = new Vector2(200, 62);
var text = button.GetComponentInChildren<TMP_Text>(); text.text = spec.Item2; text.rectTransform.sizeDelta = rect.sizeDelta;
serialized.FindProperty(spec.Item3).objectReferenceValue = button.GetComponent<UnityEngine.UI.Button>();
button.SetActive(spec.Item1 == "SplitButton");
}
var voice = root.GetComponent<VoicePlayer>(); var voiceObject = new SerializedObject(voice); var clips = voiceObject.FindProperty("_clips");
foreach (string id in new[] { "guide_merge", "guide_split", "demo_composition" })
{
var clip = AssetDatabase.LoadAssetAtPath<AudioClip>(Presentation + "Audio/Voice/" + id + ".wav");
if (clip == null) throw new InvalidOperationException("New draft voice absent: " + id);
var importer = (AudioImporter)AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(clip));
var settings = importer.defaultSampleSettings; settings.loadType = AudioClipLoadType.DecompressOnLoad; settings.compressionFormat = AudioCompressionFormat.PCM;
settings.preloadAudioData = true; importer.defaultSampleSettings = settings; importer.SaveAndReimport();
int index = Enumerable.Range(0, clips.arraySize).Where(i => clips.GetArrayElementAtIndex(i).FindPropertyRelative("id").stringValue == id).DefaultIfEmpty(-1).First();
if (index == -1) { index = clips.arraySize; clips.arraySize++; }
var entry = clips.GetArrayElementAtIndex(index); entry.FindPropertyRelative("id").stringValue = id; entry.FindPropertyRelative("clip").objectReferenceValue = clip;
}
voiceObject.ApplyModifiedPropertiesWithoutUndo(); serialized.ApplyModifiedPropertiesWithoutUndo();
PrefabUtility.SaveAsPrefabAsset(root, FirstSliceAssets.AppPrefabPath);
}
finally { PrefabUtility.UnloadPrefabContents(root); }
var badgeRoot = PrefabUtility.LoadPrefabContents(Presentation + "Prefabs/GroupBadge.prefab");
try
{
var cape = badgeRoot.transform.Find("RibbonCape");
if (cape == null)
{
cape = new GameObject("RibbonCape", typeof(LineRenderer)).transform; cape.SetParent(badgeRoot.transform, false);
var line = cape.GetComponent<LineRenderer>(); line.sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(Presentation + "Materials/CountGold.mat");
line.useWorldSpace = false; line.widthMultiplier = .09f; line.positionCount = 4;
line.SetPositions(new[] { new Vector3(-.4f, -.1f, .55f), new Vector3(-.55f, -.48f, .6f), new Vector3(-.28f, -.7f, .6f), new Vector3(.1f, -.45f, .6f) });
}
var badge = new SerializedObject(badgeRoot.GetComponent<GroupBadge>()); badge.FindProperty("_cape").objectReferenceValue = cape;
badge.ApplyModifiedPropertiesWithoutUndo(); PrefabUtility.SaveAsPrefabAsset(badgeRoot, Presentation + "Prefabs/GroupBadge.prefab");
}
finally { PrefabUtility.UnloadPrefabContents(badgeRoot); }
AssetDatabase.SaveAssets(); Debug.Log("[Composition] Stage06/10/14, selection/split controls, contact/selection presentation and draft guides wired. Main remains root-owned.");
}
private static StageAsset Stage(int id, int[] initial, int[] target, Vector3[] positions)
{
string path = $"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset";
var asset = AssetDatabase.LoadAssetAtPath<StageAsset>(path);
if (asset == null) { asset = ScriptableObject.CreateInstance<StageAsset>(); AssetDatabase.CreateAsset(asset, path); }
var serialized = new SerializedObject(asset); serialized.FindProperty("_stageId").intValue = id;
serialized.FindProperty("_kind").enumValueIndex = (int)StageKind.Composition;
foreach (var spec in new[] { ("_initialGroups", initial), ("_targetGroups", target) })
{ var field = serialized.FindProperty(spec.Item1); field.arraySize = spec.Item2.Length; for (int index = 0; index < spec.Item2.Length; index++) field.GetArrayElementAtIndex(index).intValue = spec.Item2[index]; }
var layout = serialized.FindProperty("_ballPositions"); layout.arraySize = positions.Length;
for (int index = 0; index < positions.Length; index++) layout.GetArrayElementAtIndex(index).vector3Value = positions[index];
serialized.ApplyModifiedPropertiesWithoutUndo(); return asset;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3716099c561b9a84a952934d7120cdec
@@ -0,0 +1,52 @@
using System.Linq;
using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Bootstrap;
using MagnetismHeroes.UnityRuntime.StageAssets;
using MagnetismHeroes.UnityRuntime.Presentation;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace MagnetismHeroes.Tests
{
public sealed class CompositionAssetTests
{
[TestCase(6, new[] { 1, 1 }, new[] { 2 })]
[TestCase(10, new[] { 2 }, new[] { 1, 1 })]
[TestCase(14, new[] { 3, 1 }, new[] { 2, 2 })]
public void InspectorStageMatchesRepresentativeRule(int id, int[] initial, int[] target)
{
var asset = AssetDatabase.LoadAssetAtPath<StageAsset>($"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset");
Assert.That(asset, Is.Not.Null); var definition = asset.ToDefinition();
Assert.That(definition.Id, Is.EqualTo(id)); Assert.That(definition.Kind, Is.EqualTo(StageKind.Composition));
Assert.That(definition.InitialGroups, Is.EqualTo(initial)); Assert.That(definition.TargetGroups, Is.EqualTo(target));
Assert.That(definition.BallPositions.Count, Is.EqualTo(initial.Sum()));
}
[Test] public void AppCatalogControlsAndDraftGuidesAreConnectedWithoutUnlockBypass()
{
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)
.Select(index => ((StageAsset)stages.GetArrayElementAtIndex(index).objectReferenceValue).ToDefinition()).ToArray();
Assert.That(definitions.Select(stage => stage.Id), Is.EqualTo(new[] { 1, 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));
foreach (string field in new[] { "_splitButton", "_cancelSplitButton" }) Assert.That(serialized.FindProperty(field).objectReferenceValue, Is.Not.Null);
}
private sealed class EmptyStore : IProgressStore
{
public bool TryLoad(out ProgressSave save, out string error) { save = new ProgressSave(); error = null; return true; }
public bool TrySave(ProgressSave save, out string error) { error = null; return true; }
}
[Test] public void IdenticalRowsCannotHideEachOthersUnitSpheresAfterContactPlacement()
{
var row = new[] { new Vector3(-.5f, 0, 0), new Vector3(.5f, 0, 0) };
var shifted = new[] { row[0] - Vector3.right, row[1] - Vector3.right };
float correction = ContactPlacement.Clearance(shifted, row, Vector3.left);
Assert.That(correction, Is.EqualTo(1).Within(.0001f));
foreach (var moving in shifted) foreach (var target in row)
Assert.That(Vector3.Distance(moving + Vector3.left * correction, target), Is.GreaterThanOrEqualTo(.9999f));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 82f243b65ae87844cbc4a8fcd1b00d4a
@@ -0,0 +1,82 @@
using System.Linq;
using MagnetismHeroes.Core;
using NUnit.Framework;
namespace MagnetismHeroes.Tests
{
public sealed class CompositionSessionTests
{
private sealed class SpyStore : IProgressStore
{
public ProgressSave Saved = new ProgressSave { completedStageIds = Enumerable.Range(1, 13).ToArray() };
public int Writes;
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; Writes++; error = null; return true; }
}
[Test] public void MoveAndMergeCompleteWithOneWriteAndPreserveEveryIdentity()
{
var store = new SpyStore { Saved = new ProgressSave { completedStageIds = Enumerable.Range(1, 5).ToArray() } };
var session = new GameSession(new[] { new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) }, store);
session.StartStage(6); var groups = session.Board.Groups; var ids = session.Board.Balls.Select(ball => ball.id).ToArray();
var writes = store.Writes;
var result = session.MoveAndMerge(groups[0].id, new Position3(0.4f, 0.2f, 1), groups[1].id);
Assert.That(result.Accepted, Is.True); Assert.That(store.Writes, Is.EqualTo(writes + 1));
Assert.That(session.Screen, Is.EqualTo(SessionScreen.FreePlay));
Assert.That(session.Board.Groups.Single().ballIds, Is.EquivalentTo(ids));
Assert.That(store.Saved.puzzleStates, Is.Empty); Assert.That(store.Saved.latestFreePlay, Is.Not.Null);
Assert.That(store.Saved.completedStageIds, Is.EqualTo(Enumerable.Range(1, 6).ToArray()));
}
[Test] public void UnknownMergeTargetDoesNotMoveOrWrite()
{
var store = new SpyStore(); var session = new GameSession(new[] { new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) }, store);
session.StartStage(6); var original = session.Board.Balls; var writes = store.Writes;
Assert.That(session.MoveAndMerge(session.Board.Groups[0].id, new Position3(1, 0, 0), "missing").Accepted, Is.False);
Assert.That(session.Board.Balls.Select(ball => ball.position), Is.EqualTo(original.Select(ball => ball.position)));
Assert.That(store.Writes, Is.EqualTo(writes));
}
[TestCase(0)] [TestCase(3)] public void EmptyAndEntireSplitKeepStateAndSave(int count)
{
var store = new SpyStore(); var session = new GameSession(new[] { new StageDefinition(14, StageKind.Composition, new[] { 3, 1 }, new[] { 2, 2 }) }, store);
session.StartStage(14); var group = session.Board.Groups[0]; var writes = store.Writes; var original = session.Board.Balls;
Assert.That(session.SplitAndMove(group.id, group.ballIds.Take(count).ToArray(), "new", new Position3(1, 1, 0)).Accepted, Is.False);
Assert.That(store.Writes, Is.EqualTo(writes)); Assert.That(session.Board.Groups.Length, Is.EqualTo(2));
Assert.That(session.Board.Balls.Select(ball => ball.position), Is.EqualTo(original.Select(ball => ball.position)));
}
[Test] public void DistantSelectionProducesExactlyTwoFriendsAndFinalCompositionIgnoresHistory()
{
var store = new SpyStore(); var session = new GameSession(new[] { new StageDefinition(14, StageKind.Composition, new[] { 3, 1 }, new[] { 2, 2 }) }, store);
session.StartStage(14); var groups = session.Board.Groups;
session.MoveAndMerge(groups[0].id, new Position3(0.1f, 0, 0), groups[1].id);
var all = session.Board.Groups.Single(); var ids = all.ballIds.ToArray(); var writes = store.Writes;
Assert.That(session.SplitAndMove(all.id, new[] { ids[0], ids[3] }, "split-result", new Position3(3, 0, 0)).Accepted, Is.True);
Assert.That(store.Writes, Is.EqualTo(writes + 1)); Assert.That(session.Screen, Is.EqualTo(SessionScreen.FreePlay));
Assert.That(session.Board.Groups.Select(group => group.ballIds.Length), Is.EqualTo(new[] { 2, 2 }));
Assert.That(session.Board.Groups.SelectMany(group => group.ballIds), Is.EquivalentTo(ids));
}
[Test] public void SplitAndMergeKeepCollectedAndCountedIdsThroughPuzzleRestore()
{
var definition = new StageDefinition(1, StageKind.Counting, new[] { 1, 1, 1 }, new[] { 3 });
var store = new SpyStore(); var session = new GameSession(new[] { definition }, store); session.StartStage(1);
var groups = session.Board.Groups; var ids = session.Board.Balls.Select(ball => ball.id).ToArray();
foreach (var group in groups) session.MoveGroup(group.id, new Position3(0, 1, 0));
session.Merge(groups[0].id, groups[1].id); session.Merge(groups[0].id, groups[2].id); session.Count(ids[0]);
var writes = store.Writes;
Assert.That(session.SplitAndMove(groups[0].id, new[] { ids[0], ids[2] }, "detached", new Position3(2, 0, 0)).Accepted, Is.True);
Assert.That(store.Writes, Is.EqualTo(writes + 1)); Assert.That(session.Board.CountedBallIds, Is.EqualTo(new[] { ids[0] }));
var restored = new GameSession(new[] { definition }, store); restored.StartStage(1);
Assert.That(restored.Board.CollectedBallIds, Is.EquivalentTo(ids)); Assert.That(restored.Board.CountedBallIds, Is.EqualTo(new[] { ids[0] }));
Assert.That(restored.Board.Groups.Length, Is.EqualTo(2));
Assert.That(restored.MoveAndMerge("detached", new Position3(-1, 0, 0), groups[0].id).Accepted, Is.True);
Assert.That(restored.Board.CountedBallIds, Is.EqualTo(new[] { ids[0] })); Assert.That(restored.Board.BallCount, Is.EqualTo(3));
}
[Test] public void InvalidCoordinatesNeverPartiallySplitOrMerge()
{
var store = new SpyStore(); var session = new GameSession(new[] { new StageDefinition(14, StageKind.Composition, new[] { 3, 1 }, new[] { 2, 2 }) }, store);
session.StartStage(14); var groups = session.Board.Groups; var original = session.Board.Balls; var writes = store.Writes;
Assert.That(session.MoveAndMerge(groups[0].id, new Position3(float.NaN, 0, 0), groups[1].id).Accepted, Is.False);
Assert.That(session.SplitAndMove(groups[0].id, new[] { groups[0].ballIds[0] }, "invalid", new Position3(float.PositiveInfinity, 0, 0)).Accepted, Is.False);
Assert.That(store.Writes, Is.EqualTo(writes)); Assert.That(session.Board.Groups.Select(group => group.ballIds.Length), Is.EqualTo(new[] { 3, 1 }));
Assert.That(session.Board.Balls.Select(ball => ball.position), Is.EqualTo(original.Select(ball => ball.position)));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d9ca4f319c077954381f2f4da39614e0
@@ -0,0 +1,137 @@
#if UNITY_EDITOR
using System.Collections;
using System.Linq;
using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Bootstrap;
using MagnetismHeroes.UnityRuntime.Presentation;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
using UnityEngine.TestTools;
namespace MagnetismHeroes.Tests
{
public sealed class CompositionPlayTests
{
private sealed class SpyStore : IProgressStore
{
public ProgressSave Save = new ProgressSave { completedStageIds = Enumerable.Range(1, 13).ToArray() };
public int Writes;
public bool TryLoad(out ProgressSave save, out string error) { save = Save; error = null; return true; }
public bool TrySave(ProgressSave save, out string error) { Save = save; Writes++; error = null; return true; }
}
private GameApp _app; private SpyStore _store; private Touchscreen _touchscreen;
private InputSettings _original, _settings; private HideFlags _flags;
[UnitySetUp] public IEnumerator Setup()
{
_original = InputSystem.settings; _flags = _original.hideFlags; _original.hideFlags = HideFlags.DontSave;
_settings = Object.Instantiate(_original); _settings.hideFlags = HideFlags.DontSave;
_settings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
_settings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus; InputSystem.settings = _settings;
foreach (var listener in Object.FindObjectsByType<AudioListener>(FindObjectsSortMode.None)) listener.enabled = false;
foreach (var camera in Object.FindObjectsByType<Camera>(FindObjectsSortMode.None)) camera.enabled = false;
_app = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab")).GetComponent<GameApp>();
_store = new SpyStore(); _app.Initialize(_store); _touchscreen = InputSystem.AddDevice<Touchscreen>(); yield return null;
}
[UnityTearDown] public IEnumerator TearDown()
{
InputSystem.RemoveDevice(_touchscreen); Object.Destroy(_app.gameObject);
InputSystem.settings = _original; _original.hideFlags = _flags; Object.Destroy(_settings); yield return null;
}
private Vector2 Point(string id) => _app.Presenter.Camera.WorldToScreenPoint(_app.Presenter.Balls[id].transform.position);
private void Tap(string id) { var point = Point(id); _app.Input.Begin(point, false); _app.Input.End(point); }
private void Touch(int id, UnityEngine.InputSystem.TouchPhase phase, Vector2 point) => InputSystem.QueueStateEvent(_touchscreen,
new TouchState { touchId = id, phase = phase, position = point });
private string PrepareSplit()
{
_app.StartStage(10); Physics.SyncTransforms(); var id = _app.Session.Board.Balls[0].id;
Tap(id); _app.Input.BeginSplit(); Tap(id); Assert.That(_app.Input.SelectedCount, Is.EqualTo(1)); return id;
}
[UnityTest] public IEnumerator NativeCanceledSplitRestoresCommittedBoardAndClearsUnconfirmedSelection()
{
var id = PrepareSplit(); yield return null; var point = Point(id); var saved = JsonUtility.ToJson(_store.Save); var writes = _store.Writes;
Touch(301, UnityEngine.InputSystem.TouchPhase.Began, point); Touch(301, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(-80, 20));
Touch(301, UnityEngine.InputSystem.TouchPhase.Canceled, point + new Vector2(-80, 20)); yield return null; yield return null;
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); Assert.That(_store.Writes, Is.EqualTo(writes));
Assert.That(_app.Input.SelectedCount, Is.Zero); Assert.That(_app.Presenter.Balls.Values.All(ball => !ball.Selected && !ball.ContactCandidate), Is.True);
foreach (var ball in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[ball.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(ball.position)));
}
[UnityTest] public IEnumerator NativePinchInSplitDragSameFrameCannotCommitSplitOrLeaveSelectionEffects()
{
var id = PrepareSplit(); yield return null; var point = Point(id); var writes = _store.Writes;
Touch(311, UnityEngine.InputSystem.TouchPhase.Began, point); Touch(311, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(-80, 15));
Touch(312, UnityEngine.InputSystem.TouchPhase.Began, point + new Vector2(80, 0));
Touch(311, UnityEngine.InputSystem.TouchPhase.Ended, point + new Vector2(-80, 15)); Touch(312, UnityEngine.InputSystem.TouchPhase.Ended, point + new Vector2(80, 0));
yield return null; yield return null;
Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(1)); Assert.That(_store.Writes, Is.EqualTo(writes + 1), "Only the camera end is committed.");
Assert.That(_app.Input.SelectedCount, Is.Zero); Assert.That(_app.Presenter.Balls.Values.All(ball => !ball.Selected && !ball.ContactCandidate), Is.True);
}
[UnityTest] public IEnumerator NativeShortSplitDragCompletesTenOnceAndDoesNotImmediatelyRemerge()
{
var id = PrepareSplit(); yield return null; var point = Point(id); var writes = _store.Writes; var ids = _app.Session.Board.Balls.Select(ball => ball.id).ToArray();
Touch(321, UnityEngine.InputSystem.TouchPhase.Began, point); Touch(321, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(-75, 0));
Touch(321, UnityEngine.InputSystem.TouchPhase.Ended, point + new Vector2(-75, 0)); yield return null; yield return null;
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Writes, Is.EqualTo(writes + 1));
Assert.That(_app.Session.Board.Groups.Select(group => group.ballIds.Length), Is.EqualTo(new[] { 1, 1 }));
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EquivalentTo(ids)); Assert.That(_app.Input.IsSplitting, Is.False);
yield return null; Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(2));
}
[UnityTest] public IEnumerator SixContactPreviewAndConfirmedMergePreserveShapeAndCompleteOnce()
{
_app.StartStage(6); yield return null; Physics.SyncTransforms(); var states = _app.Session.Board.Balls;
var point = Point(states[0].id); var destination = Point(states[1].id) - new Vector2(35, 0); var writes = _store.Writes;
_app.Input.Begin(point, false); _app.Input.Move(destination);
Assert.That(_app.Presenter.MergeCandidate, Is.Not.Null); Assert.That(_app.Presenter.Balls.Values.Count(ball => ball.ContactCandidate), Is.EqualTo(2));
Assert.That(_store.Writes, Is.EqualTo(writes));
_app.Input.End(destination); yield return null;
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Writes, Is.EqualTo(writes + 1));
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EquivalentTo(states.Select(ball => ball.id)));
Assert.That(Vector3.Distance(_app.Presenter.Balls[states[0].id].transform.position, _app.Presenter.Balls[states[1].id].transform.position), Is.EqualTo(1).Within(.001f));
Assert.That(_app.Presenter.Balls.Values.All(ball => !ball.ContactCandidate), Is.True);
Assert.That(_app.GetComponentsInChildren<GroupBadge>().Length, Is.EqualTo(1));
}
[UnityTest] public IEnumerator FourteenDisconnectedSelectionThenMergeIsAcceptedByFinalNumbers()
{
_app.StartStage(14); yield return null; Physics.SyncTransforms(); var group = _app.Session.Board.Groups[0];
Tap(group.ballIds[0]); _app.Input.BeginSplit(); Tap(group.ballIds[0]); Tap(group.ballIds[2]);
Assert.That(_app.Input.SelectedCount, Is.EqualTo(2)); var point = Point(group.ballIds[0]);
var endpoint = point + new Vector2(0, 65); _app.Input.Begin(point, false); _app.Input.Move(endpoint); _app.Input.End(endpoint); yield return null;
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle));
Assert.That(_app.Session.Board.Groups.Select(value => value.ballIds.Length).OrderBy(value => value), Is.EqualTo(new[] { 1, 1, 2 }));
var singles = _app.Session.Board.Groups.Where(value => value.ballIds.Length == 1).ToArray(); var writes = _store.Writes;
point = Point(singles[0].ballIds[0]); endpoint = Point(singles[1].ballIds[0]) - new Vector2(35, 0);
_app.Input.Begin(point, false); _app.Input.Move(endpoint); _app.Input.End(endpoint); yield return null;
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Writes, Is.EqualTo(writes + 1));
Assert.That(_app.Session.Board.Groups.Select(value => value.ballIds.Length), Is.EqualTo(new[] { 2, 2 }));
Assert.That(_app.GetComponentsInChildren<GroupBadge>().Length, Is.EqualTo(2));
Assert.That(_app.GetComponentsInChildren<TMPro.TextMeshPro>().Select(text => text.text), Is.EqualTo(new[] { "2", "2" }));
}
[UnityTest] public IEnumerator ZeroAllAndOutsideSplitPreserveStateAndOrbitKeepsNumbersAboveAllSpheres()
{
_app.StartStage(10); yield return null; Physics.SyncTransforms(); var group = _app.Session.Board.Groups.Single();
Tap(group.ballIds[0]); _app.Input.BeginSplit(); var saved = JsonUtility.ToJson(_store.Save); var writes = _store.Writes;
var point = Point(group.ballIds[0]); var end = point + new Vector2(-60, 0);
_app.Input.Begin(point, false); _app.Input.Move(end); _app.Input.End(end);
Assert.That(_app.Input.SelectedCount, Is.Zero);
Tap(group.ballIds[0]); Tap(group.ballIds[1]); point = Point(group.ballIds[0]);
_app.Input.Begin(point, false); _app.Input.Move(end); _app.Input.End(end);
Assert.That(_app.Input.SelectedCount, Is.EqualTo(2)); Assert.That(_store.Writes, Is.EqualTo(writes));
Tap(group.ballIds[1]); point = Point(group.ballIds[0]);
Touch(331, UnityEngine.InputSystem.TouchPhase.Began, point); Touch(331, UnityEngine.InputSystem.TouchPhase.Moved, end);
Touch(331, UnityEngine.InputSystem.TouchPhase.Ended, new Vector2(-1, point.y)); yield return null; yield return null;
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); Assert.That(_app.Input.SelectedCount, Is.Zero);
foreach (var delta in new[] { new Vector2(200, 250), new Vector2(-150, 400), new Vector2(300, -500) })
{
_app.Orbit.Orbit(delta); yield return null;
var camera = _app.Presenter.Camera; var label = _app.GetComponentsInChildren<TMPro.TextMeshPro>().Single();
float numberY = camera.WorldToScreenPoint(label.transform.position).y;
Assert.That(numberY, Is.GreaterThan(_app.Presenter.Balls.Values.Max(ball => camera.WorldToScreenPoint(ball.transform.position + camera.transform.up * .5f).y)));
Assert.That(_app.GetComponentsInChildren<GroupBadge>().Length, Is.EqualTo(1));
Assert.That(_app.Session.Board.Balls.Select(ball => ball.position), Is.EqualTo(_store.Save.puzzleStates.Single().balls.Select(ball => ball.position)));
}
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a072a0043d7af814aa3681d938d9a5bd
@@ -27,6 +27,9 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
[SerializeField] private UnityEngine.UI.Button _stageOneButton; [SerializeField] private UnityEngine.UI.Button _stageOneButton;
[SerializeField] private UnityEngine.UI.Button _homeButton; [SerializeField] private UnityEngine.UI.Button _homeButton;
[SerializeField] private UnityEngine.UI.Button _selectionHomeButton; [SerializeField] private UnityEngine.UI.Button _selectionHomeButton;
[SerializeField] private UnityEngine.UI.Button[] _compositionButtons = Array.Empty<UnityEngine.UI.Button>();
[SerializeField] private UnityEngine.UI.Button _splitButton;
[SerializeField] private UnityEngine.UI.Button _cancelSplitButton;
[SerializeField] private BoardPresenter _presenter; [SerializeField] private BoardPresenter _presenter;
[SerializeField] private OrbitCamera _orbit; [SerializeField] private OrbitCamera _orbit;
[SerializeField] private BoardInput _input; [SerializeField] private BoardInput _input;
@@ -46,6 +49,10 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
_stageOneButton.onClick.AddListener(() => StartStage(1)); _stageOneButton.onClick.AddListener(() => StartStage(1));
_homeButton.onClick.AddListener(ShowHome); _homeButton.onClick.AddListener(ShowHome);
_selectionHomeButton.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)); }
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(); });
} }
private void Start() private void Start()
{ {
@@ -61,27 +68,37 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
Refresh(true); Refresh(true);
} }
public void ShowSelection() public void ShowSelection()
{ _input.Cancel(); _voice.Clear(); Session.ShowSelection(); Refresh(true); } { _input.CancelSplit(); _voice.Clear(); Session.ShowSelection(); Refresh(true); }
public void ShowHome() public void ShowHome()
{ _input.Cancel(); _voice.Clear(); Session.ShowHome(); Refresh(true); } { _input.CancelSplit(); _voice.Clear(); Session.ShowHome(); Refresh(true); }
public void StartStage(int id) public void StartStage(int id)
{ {
_input.Cancel(); _voice.Clear(); _input.CancelSplit(); _voice.Clear(); bool restored = Session.HasUnfinishedStage(id);
if (!Session.StartStage(id)) return; if (!Session.StartStage(id)) return;
Refresh(true); Refresh(true);
// Restoring never replays historical count/celebration clips. // Restoring never replays historical count/celebration clips.
if (Session.Board.CollectedBallIds.Length == 0) _voice.Guide("guide_collect"); if (Session.Board.Mode == BoardMode.Counting && Session.Board.CollectedBallIds.Length == 0) _voice.Guide("guide_collect");
else if (!restored) _voice.Guide(id == 10 ? "guide_split" : "guide_merge");
} }
public void ContinueFreePlay() public void ContinueFreePlay()
{ _input.Cancel(); _voice.Clear(); if (Session.ContinueFreePlay()) Refresh(true); } { _input.CancelSplit(); _voice.Clear(); if (Session.ContinueFreePlay()) Refresh(true); }
public void CommitMove(string groupId, Position3 delta) public void CommitMove(string groupId, Position3 delta, string mergeTarget = null)
{ {
var result = Session.MoveGroup(groupId, delta); var result = mergeTarget == null ? Session.MoveGroup(groupId, delta) : Session.MoveAndMerge(groupId, delta, mergeTarget);
Refresh(false); Refresh(false);
if (result.Accepted && Session.Screen == SessionScreen.Puzzle && Session.Board.CanCount) _voice.Guide("guide_count"); if (result.Accepted && Session.Screen == SessionScreen.Puzzle && Session.Board.CanCount) _voice.Guide("guide_count");
} }
public bool CommitSplit(string groupId, string[] selected, Position3 delta)
{
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); }
return result.Accepted;
}
public void CountBall(string ballId) public void CountBall(string ballId)
{ {
if (Session.Board.Mode != BoardMode.Counting) return;
var result = Session.Count(ballId); var result = Session.Count(ballId);
if (result.Accepted) _voice.EnqueueCount(result.Count); if (result.Accepted) _voice.EnqueueCount(result.Count);
if (result.Accepted) Refresh(false); if (result.Accepted) Refresh(false);
@@ -99,6 +116,7 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
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);
_continueButton.gameObject.SetActive(Session.HasFreePlay); _continueButton.gameObject.SetActive(Session.HasFreePlay);
for (int index = 0; index < _compositionButtons.Length; index++) _compositionButtons[index].interactable = Session.IsUnlocked(_stages[index + 1].StageId);
if (applyCamera) _orbit.Apply(Session.Camera); if (applyCamera) _orbit.Apply(Session.Camera);
_presenter.Render(Session.Board); _presenter.Render(Session.Board);
if (!playing) return; if (!playing) return;
@@ -106,9 +124,28 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
_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}";
_instruction.text = screen == SessionScreen.FreePlay ? "해냈어! 친구와 자유롭게 놀아 보자." _instruction.text = screen == SessionScreen.FreePlay ? "해냈어! 친구와 자유롭게 놀아 보자."
: Session.Board.CanCount ? "모두 모았네! 하나씩 눌러 세어 보자." : "친구를 찾아 끌어와 볼까?"; : Session.Board.CanCount ? "모두 모았네! 하나씩 눌러 세어 보자." : "친구를 찾아 끌어와 볼까?";
if (Session.Board.Mode == BoardMode.Composition)
{
var definition = Session.Definitions.First(stage => stage.Id == Session.Board.StageId);
_stageTitle.text = $"{Session.Board.StageId} 친구 만들기";
_counter.text = "목표 " + string.Join(" · ", definition.TargetGroups);
_instruction.text = "붙이거나 나누어 목표 친구를 만들어 보자.";
}
RefreshSelection();
}
public void RefreshSelection()
{
if (Session == null || _splitButton == null) return;
var group = Session.Board?.Groups.FirstOrDefault(value => value.id == _input.SelectedGroup);
_splitButton.gameObject.SetActive(!_input.IsSplitting);
_splitButton.interactable = group != null && group.ballIds.Length > 1;
_cancelSplitButton.gameObject.SetActive(_input.IsSplitting);
if (_input.IsSplitting) _instruction.text = $"선택 {_input.SelectedCount} · 구를 골라 끌어내 보자.";
else if (Session.Board?.Mode == BoardMode.Composition) _instruction.text = "붙이거나 나누어 목표 친구를 만들어 보자.";
_presenter.ShowSelection(_input.SelectedIds);
} }
private void OnApplicationPause(bool paused) private void OnApplicationPause(bool paused)
{ if (paused && Session != null) { _input.Cancel(); _voice.Clear(); Session.SaveCurrent(); } } { if (paused && Session != null) { _input.CancelSplit(); _voice.Clear(); Session.SaveCurrent(); } }
private void OnApplicationQuit() { if (Session != null) { _input.Cancel(); Session.SaveCurrent(); } } private void OnApplicationQuit() { if (Session != null) { _input.Cancel(); Session.SaveCurrent(); } }
} }
} }
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using MagnetismHeroes.Core; using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Bootstrap; using MagnetismHeroes.UnityRuntime.Bootstrap;
using MagnetismHeroes.UnityRuntime.Presentation; using MagnetismHeroes.UnityRuntime.Presentation;
@@ -20,6 +21,13 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
private Vector2 _start, _last; private Vector2 _start, _last;
private string _ballId, _groupId; private string _ballId, _groupId;
private bool _dragged; private bool _dragged;
private string _selectedGroup;
private readonly HashSet<string> _selected = new HashSet<string>();
private bool _splitting, _splitDrag;
public bool IsSplitting => _splitting;
public int SelectedCount => _selected.Count;
public string SelectedGroup => _selectedGroup;
public IReadOnlyCollection<string> SelectedIds => _selected;
private Plane _dragPlane; private Plane _dragPlane;
private Vector3 _grabPoint, _previewDelta; private Vector3 _grabPoint, _previewDelta;
private float _pinchDistance; private float _pinchDistance;
@@ -39,6 +47,16 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
private readonly Dictionary<int, TouchContact> _touches = new Dictionary<int, TouchContact>(); private readonly Dictionary<int, TouchContact> _touches = new Dictionary<int, TouchContact>();
public bool AcceptInput => _app.Session != null && _app.Session.Board != null; public bool AcceptInput => _app.Session != null && _app.Session.Board != null;
public void BeginSplit()
{
Cancel();
var group = _app.Session?.Board?.Groups.FirstOrDefault(value => value.id == _selectedGroup);
if (group == null || group.ballIds.Length < 2) return;
_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(); }
public void Begin(Vector2 point, bool uiBlocked) public void Begin(Vector2 point, bool uiBlocked)
{ {
Cancel(); Cancel();
@@ -50,6 +68,8 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
if (Physics.Raycast(ray, out var hit, 100) && hit.collider.TryGetComponent<BallView>(out var ball)) if (Physics.Raycast(ray, out var hit, 100) && hit.collider.TryGetComponent<BallView>(out var ball))
{ {
_gesture = Gesture.Friend; _ballId = ball.BallId; _groupId = ball.GroupId; _gesture = Gesture.Friend; _ballId = ball.BallId; _groupId = ball.GroupId;
if (_splitting && _selectedGroup != _groupId) { _selected.Clear(); _selectedGroup = _groupId; _app.RefreshSelection(); }
_splitDrag = _splitting && _selected.Contains(_ballId);
_dragPlane = new Plane(_orbit.Camera.transform.forward, hit.point); _dragPlane = new Plane(_orbit.Camera.transform.forward, hit.point);
_grabPoint = hit.point; _grabPoint = hit.point;
} }
@@ -63,7 +83,12 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
if (_dragged && _gesture == Gesture.Friend) if (_dragged && _gesture == Gesture.Friend)
{ {
var ray = _orbit.Camera.ScreenPointToRay(point); var ray = _orbit.Camera.ScreenPointToRay(point);
if (_dragPlane.Raycast(ray, out var distance)) { _previewDelta = ray.GetPoint(distance) - _grabPoint; _presenter.PreviewGroup(_groupId, _previewDelta); } if (_dragPlane.Raycast(ray, out var distance))
{
_previewDelta = ray.GetPoint(distance) - _grabPoint;
if (_splitting) { if (_splitDrag) _presenter.PreviewSplit(_groupId, _selected.ToArray(), _previewDelta); }
else _presenter.PreviewGroup(_groupId, _previewDelta);
}
} }
else if (_dragged && _gesture == Gesture.Orbit) _orbit.Orbit(point - _last); else if (_dragged && _gesture == Gesture.Orbit) _orbit.Orbit(point - _last);
_last = point; _last = point;
@@ -71,15 +96,27 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
public void End(Vector2 point, bool canceled = false) public void End(Vector2 point, bool canceled = false)
{ {
if (canceled || !IsInside(point)) { Cancel(); return; } if (canceled || !IsInside(point)) { if (_splitting) CancelSplit(); else Cancel(); return; }
if (_gesture == Gesture.Friend) if (_gesture == Gesture.Friend)
{ {
if (_dragged) if (_dragged)
{ {
if (!_presenter.CanDropGroup(_groupId)) { Cancel(); return; } if (!_presenter.CanDropGroup(_groupId)) { if (_splitting) CancelSplit(); else Cancel(); return; }
_app.CommitMove(_groupId, BoardPresenter.ToPosition(_previewDelta)); if (_splitting)
{
if (_splitDrag && _app.CommitSplit(_groupId, _selected.ToArray(), BoardPresenter.ToPosition(_previewDelta)))
{ _splitting = false; _selected.Clear(); _selectedGroup = null; }
else _presenter.CancelPreview();
_app.RefreshSelection();
}
else _app.CommitMove(_groupId, BoardPresenter.ToPosition(_presenter.DropDelta), _presenter.MergeCandidate);
} }
else _app.CountBall(_ballId); else if (_splitting)
{
if (!_selected.Add(_ballId)) _selected.Remove(_ballId);
_app.RefreshSelection();
}
else { _selectedGroup = _groupId; _app.CountBall(_ballId); _app.RefreshSelection(); }
} }
else if (_gesture == Gesture.Orbit && _dragged) _app.Session.CommitCamera(_orbit.Snapshot()); else if (_gesture == Gesture.Orbit && _dragged) _app.Session.CommitCamera(_orbit.Snapshot());
else if (_gesture == Gesture.Pinch) _app.Session.CommitCamera(_orbit.Snapshot()); else if (_gesture == Gesture.Pinch) _app.Session.CommitCamera(_orbit.Snapshot());
@@ -88,7 +125,7 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
public void BeginPinch(float distance, bool uiBlocked) public void BeginPinch(float distance, bool uiBlocked)
{ {
Cancel(); if (_splitting) CancelSplit(); else Cancel();
_gesture = uiBlocked || !AcceptInput ? Gesture.Blocked : Gesture.Pinch; _gesture = uiBlocked || !AcceptInput ? Gesture.Blocked : Gesture.Pinch;
_pinchDistance = distance; _pinchDistance = distance;
} }
@@ -119,7 +156,7 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
private void FinishSingleTouch() private void FinishSingleTouch()
{ {
var touch = FindTouch(_fingerId); var touch = FindTouch(_fingerId);
if (!touch.HasValue || touch.Value.Phase != UnityEngine.InputSystem.TouchPhase.Ended) { Cancel(); return; } if (!touch.HasValue || touch.Value.Phase != UnityEngine.InputSystem.TouchPhase.Ended) { if (_splitting) CancelSplit(); else Cancel(); return; }
var point = touch.Value.Position; Move(point); End(point); var point = touch.Value.Position; Move(point); End(point);
} }
@@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: c20afbd330503e247998502bbd50c1cf
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 0
quality: 1
conversionMode: 0
preloadAudioData: 1
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: 0582a3ced773cc74d84dfc8b9370e626
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 0
quality: 1
conversionMode: 0
preloadAudioData: 1
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: d3c50a05b8af6204c92850ebded8814f
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 0
quality: 1
conversionMode: 0
preloadAudioData: 1
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:
@@ -9,6 +9,8 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
private MaterialPropertyBlock _colors; private MaterialPropertyBlock _colors;
private Color _normalColor; private Color _normalColor;
private float _encourageUntil; private float _encourageUntil;
public bool Selected { get; set; }
public bool ContactCandidate { get; set; }
public string BallId { get; private set; } public string BallId { get; private set; }
public string GroupId { get; private set; } public string GroupId { get; private set; }
public void Bind(string ballId, string groupId, bool counted) public void Bind(string ballId, string groupId, bool counted)
@@ -18,7 +20,8 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
private void Update() private void Update()
{ {
float glow = Mathf.Clamp01((_encourageUntil - Time.unscaledTime) / 0.35f); float glow = Mathf.Clamp01((_encourageUntil - Time.unscaledTime) / 0.35f);
_colors.SetColor("_BaseColor", Color.Lerp(_normalColor, Color.white, glow * 0.7f)); _body.SetPropertyBlock(_colors); var color = Selected ? new Color(1, 0.78f, 0.2f) : ContactCandidate ? new Color(0.5f, 1, 0.7f) : _normalColor;
_colors.SetColor("_BaseColor", Color.Lerp(color, Color.white, glow * 0.7f)); _body.SetPropertyBlock(_colors);
} }
} }
} }
@@ -15,6 +15,8 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
private BoardRules _board; private BoardRules _board;
public Camera Camera => _camera; public Camera Camera => _camera;
public IReadOnlyDictionary<string, BallView> Balls => _balls; public IReadOnlyDictionary<string, BallView> Balls => _balls;
public string MergeCandidate { get; private set; }
public Vector3 DropDelta { get; private set; }
public void Render(BoardRules board) public void Render(BoardRules board)
{ {
@@ -22,6 +24,7 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
foreach (var view in _balls.Values) { view.gameObject.SetActive(false); Destroy(view.gameObject); } foreach (var view in _balls.Values) { view.gameObject.SetActive(false); Destroy(view.gameObject); }
foreach (var badge in _badges) { badge.gameObject.SetActive(false); Destroy(badge.gameObject); } foreach (var badge in _badges) { badge.gameObject.SetActive(false); Destroy(badge.gameObject); }
_balls.Clear(); _badges.Clear(); _balls.Clear(); _badges.Clear();
MergeCandidate = null; DropDelta = Vector3.zero;
if (board == null) return; if (board == null) return;
foreach (var group in board.Groups) foreach (var group in board.Groups)
{ {
@@ -38,15 +41,59 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
public void PreviewGroup(string groupId, Vector3 delta) public void PreviewGroup(string groupId, Vector3 delta)
{ {
MergeCandidate = null; DropDelta = delta;
foreach (var state in _board.Balls) foreach (var state in _board.Balls)
_balls[state.id].transform.position = ToVector(state.position) + (_balls[state.id].GroupId == groupId ? delta : Vector3.zero); _balls[state.id].transform.position = ToVector(state.position) + (_balls[state.id].GroupId == groupId ? delta : Vector3.zero);
foreach (var ball in _balls.Values) ball.ContactCandidate = false;
float closest = 1.35f; BallView source = null, target = null;
foreach (var moving in _balls.Values.Where(ball => ball.GroupId == groupId))
foreach (var other in _balls.Values.Where(ball => ball.GroupId != groupId))
{
float distance = Vector3.Distance(moving.transform.position, other.transform.position);
if (distance < closest) { closest = distance; source = moving; target = other; }
}
if (target != null)
{
MergeCandidate = target.GroupId;
var direction = source.transform.position - target.transform.position;
if (direction.sqrMagnitude < 0.0001f) direction = -_camera.transform.right;
DropDelta += direction.normalized * (1 - closest);
foreach (var ball in _balls.Values.Where(ball => ball.GroupId == groupId)) ball.transform.position += DropDelta - delta;
var moving = _balls.Values.Where(ball => ball.GroupId == groupId).ToArray();
var fixedBalls = _balls.Values.Where(ball => ball.GroupId != groupId).ToArray();
var correction = direction.normalized * ContactPlacement.Clearance(moving.Select(ball => ball.transform.position).ToArray(), fixedBalls.Select(ball => ball.transform.position).ToArray(), direction.normalized);
DropDelta += correction;
foreach (var ball in moving) ball.transform.position += correction;
closest = float.PositiveInfinity;
foreach (var ball in moving)
foreach (var other in fixedBalls)
{
float distance = Vector3.Distance(ball.transform.position, other.transform.position);
if (distance < closest) { closest = distance; source = ball; target = other; }
}
MergeCandidate = target.GroupId;
source.ContactCandidate = target.ContactCandidate = true;
}
PositionBadges(); PositionBadges();
} }
public bool PreviewSplit(string groupId, IReadOnlyList<string> selected, Vector3 delta)
{
CancelPreview();
var positions = _board.PreviewSplit(groupId, selected, ToPosition(delta));
if (positions == null) return false;
foreach (var ball in positions) _balls[ball.id].transform.position = ToVector(ball.position);
PositionBadges(); return true;
}
public void ShowSelection(IReadOnlyCollection<string> selected)
{ foreach (var ball in _balls.Values) ball.Selected = selected.Contains(ball.BallId); }
public void CancelPreview() public void CancelPreview()
{ {
if (_board == null) return; if (_board == null) return;
foreach (var state in _board.Balls) _balls[state.id].transform.position = ToVector(state.position); MergeCandidate = null; DropDelta = Vector3.zero;
foreach (var state in _board.Balls) { _balls[state.id].transform.position = ToVector(state.position); _balls[state.id].ContactCandidate = false; }
PositionBadges(); PositionBadges();
} }
@@ -78,8 +125,11 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
{ {
var group = groups[index]; var group = groups[index];
var center = group.ballIds.Select(id => _balls[id].transform.position).Aggregate(Vector3.zero, (sum, value) => sum + value) / group.ballIds.Length; var center = group.ballIds.Select(id => _balls[id].transform.position).Aggregate(Vector3.zero, (sum, value) => sum + value) / group.ballIds.Length;
var representative = _balls[group.ballIds[0]].transform.position; var representative = group.ballIds.Select(id => _balls[id].transform.position)
_badges[index].Bind(group.ballIds.Length, center + _camera.transform.up * 0.95f, .OrderBy(point => Vector3.Dot(point - _camera.transform.position, _camera.transform.forward)).First();
// Place the number above the entire group's projected sphere bounds, including tilted views.
float highest = group.ballIds.Max(id => Vector3.Dot(_balls[id].transform.position - center, _camera.transform.up));
_badges[index].Bind(group.ballIds.Length, center + _camera.transform.up * (highest + 0.95f),
representative - _camera.transform.forward * 0.49f); representative - _camera.transform.forward * 0.49f);
_badges[index].FaceCamera(_camera); _badges[index].FaceCamera(_camera);
} }
@@ -0,0 +1,28 @@
using System.Collections.Generic;
using UnityEngine;
namespace MagnetismHeroes.UnityRuntime.Presentation
{
public static class ContactPlacement
{
// Leave the union of unit-sphere overlap intervals along the chosen contact direction.
// Translating the whole friend preserves its shape, including two identical rows.
public static float Clearance(IReadOnlyList<Vector3> moving, IReadOnlyList<Vector3> stationary, Vector3 direction)
{
var intervals = new List<(float start, float end)>();
foreach (var source in moving)
foreach (var target in stationary)
{
var offset = source - target; float along = Vector3.Dot(offset, direction);
float perpendicularSquared = offset.sqrMagnitude - along * along;
if (perpendicularSquared >= 1) continue;
float half = Mathf.Sqrt(Mathf.Max(0, 1 - perpendicularSquared));
intervals.Add((-along - half, -along + half));
}
intervals.Sort((a, b) => a.start.CompareTo(b.start)); float distance = 0;
foreach (var interval in intervals)
if (interval.start < distance + .0001f && interval.end > distance) distance = interval.end;
return distance;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 96ee547df6e2f924ca5109ed26f2f897
File diff suppressed because one or more lines are too long
@@ -7,9 +7,10 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
{ {
[SerializeField] private TMP_Text _number; [SerializeField] private TMP_Text _number;
[SerializeField] private Transform _face; [SerializeField] private Transform _face;
[SerializeField] private Transform _cape;
public void Bind(int count, Vector3 numberPosition, Vector3 facePosition) public void Bind(int count, Vector3 numberPosition, Vector3 facePosition)
{ _number.text = count.ToString(); _number.transform.position = numberPosition; _face.position = facePosition; } { _number.text = count.ToString(); _number.transform.position = numberPosition; _face.position = facePosition; if (_cape != null) _cape.position = facePosition; }
public void FaceCamera(Camera camera) public void FaceCamera(Camera camera)
{ _number.transform.rotation = camera.transform.rotation; _face.rotation = camera.transform.rotation; } { _number.transform.rotation = camera.transform.rotation; _face.rotation = camera.transform.rotation; if (_cape != null) _cape.rotation = camera.transform.rotation; }
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,149 @@
%YAML 1.1 %YAML 1.1
%TAG !u! tag:unity3d.com,2011: %TAG !u! tag:unity3d.com,2011:
--- !u!1 &10603908458933282
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1940951101562305691}
- component: {fileID: 7705461554355970294}
m_Layer: 0
m_Name: RibbonCape
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1940951101562305691
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 10603908458933282}
serializedVersion: 2
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: 3444733339480260935}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!120 &7705461554355970294
LineRenderer:
serializedVersion: 3
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 10603908458933282}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 0
m_LightProbeUsage: 0
m_ReflectionProbeUsage: 0
m_RayTracingMode: 0
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: e6eb301d5baaa3b4dba3ce2228f17fa3, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_Positions:
- {x: -0.4, y: -0.1, z: 0.55}
- {x: -0.55, y: -0.48, z: 0.6}
- {x: -0.28, y: -0.7, z: 0.6}
- {x: 0.1, y: -0.45, z: 0.6}
m_Parameters:
serializedVersion: 3
widthMultiplier: 0.09
widthCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 1
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0.33333334
outWeight: 0.33333334
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
colorGradient:
serializedVersion: 2
key0: {r: 1, g: 1, b: 1, a: 1}
key1: {r: 1, g: 1, b: 1, a: 1}
key2: {r: 0, g: 0, b: 0, a: 0}
key3: {r: 0, g: 0, b: 0, a: 0}
key4: {r: 0, g: 0, b: 0, a: 0}
key5: {r: 0, g: 0, b: 0, a: 0}
key6: {r: 0, g: 0, b: 0, a: 0}
key7: {r: 0, g: 0, b: 0, a: 0}
ctime0: 0
ctime1: 65535
ctime2: 0
ctime3: 0
ctime4: 0
ctime5: 0
ctime6: 0
ctime7: 0
atime0: 0
atime1: 65535
atime2: 0
atime3: 0
atime4: 0
atime5: 0
atime6: 0
atime7: 0
m_Mode: 0
m_ColorSpace: -1
m_NumColorKeys: 2
m_NumAlphaKeys: 2
numCornerVertices: 0
numCapVertices: 0
alignment: 0
textureMode: 0
textureScale: {x: 1, y: 1}
shadowBias: 0.5
generateLightingData: 0
m_UseWorldSpace: 0
m_Loop: 0
m_ApplyActiveColorSpace: 1
--- !u!1 &249671451578993778 --- !u!1 &249671451578993778
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -305,6 +449,7 @@ Transform:
m_Children: m_Children:
- {fileID: 2644936306256428015} - {fileID: 2644936306256428015}
- {fileID: 1885524188671059666} - {fileID: 1885524188671059666}
- {fileID: 1940951101562305691}
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &8586535435441923330 --- !u!114 &8586535435441923330
@@ -321,6 +466,7 @@ MonoBehaviour:
m_EditorClassIdentifier: MagnetismHeroes.UnityRuntime::MagnetismHeroes.UnityRuntime.Presentation.GroupBadge m_EditorClassIdentifier: MagnetismHeroes.UnityRuntime::MagnetismHeroes.UnityRuntime.Presentation.GroupBadge
_number: {fileID: 7156837666543227830} _number: {fileID: 7156837666543227830}
_face: {fileID: 1885524188671059666} _face: {fileID: 1885524188671059666}
_cape: {fileID: 1940951101562305691}
--- !u!1 &7995294815621610086 --- !u!1 &7995294815621610086
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -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: Stage06
m_EditorClassIdentifier: MagnetismHeroes.UnityRuntime::MagnetismHeroes.UnityRuntime.StageAssets.StageAsset
_stageId: 6
_kind: 1
_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: 6e1df3aeda3cea54b8441c528e7f503a
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -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: Stage10
m_EditorClassIdentifier: MagnetismHeroes.UnityRuntime::MagnetismHeroes.UnityRuntime.StageAssets.StageAsset
_stageId: 10
_kind: 1
_initialGroups: 02000000i
_targetGroups: 0100000001000000i
_ballPositions:
- {x: -0.5, y: 0.8, z: 0}
- {x: 0.5, y: 0.8, z: 0}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 65683957e8f990649a840e8648fd470a
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
%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: Stage14
m_EditorClassIdentifier: MagnetismHeroes.UnityRuntime::MagnetismHeroes.UnityRuntime.StageAssets.StageAsset
_stageId: 14
_kind: 1
_initialGroups: 0300000001000000i
_targetGroups: 0200000002000000i
_ballPositions:
- {x: -2, y: 0.8, z: 0}
- {x: -1, y: 0.8, z: 0}
- {x: 0, y: 0.8, z: 0}
- {x: 2, y: 0.8, z: 0}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a8fae6d8bb4a24246b5cfc1e89d7075f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,71 @@
{
"schemaVersion": 1,
"runId": "20261004T193323396066Z_c5daf2df",
"profileHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"catalogHash": "1c74dcfc4d67eb2583dac6168e164c242d7af349ef77e74f700426dc43c6a21f",
"selections": [
{
"id": "guide_merge",
"sourceRunId": "20261004T193323396066Z_c5daf2df",
"sourceFile": "generated/20261004T193323396066Z_c5daf2df/guide_merge.wav",
"sourceSha256": "f521af71107190360187a11d20d0f95f4aac14ee73d79a75bb85018d1bfa164d",
"editedFile": "edited/20261004T193323396066Z_c5daf2df/guide_merge.wav",
"runtimeFile": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/guide_merge.wav",
"editedMetadata": {
"sha256": "522db037e0358b01b84dfc68f13e62073495a011a96d8ca407be28f36e98e16d",
"sampleRate": 44100,
"channels": 1,
"frames": 158760,
"duration": 3.6,
"peak": 0.387054443359375,
"rms": 0.05469931662082672,
"qualityWarnings": []
},
"review": "not_listened",
"status": "draft",
"processing": "polyphase resample to mono PCM16 44100 Hz; no speech trimming"
},
{
"id": "guide_split",
"sourceRunId": "20261004T193323396066Z_c5daf2df",
"sourceFile": "generated/20261004T193323396066Z_c5daf2df/guide_split.wav",
"sourceSha256": "9362a2243fd44b607a50fe4ca7f3a4d64a64e8ceb919b9eb6bb7098adaf67eac",
"editedFile": "edited/20261004T193323396066Z_c5daf2df/guide_split.wav",
"runtimeFile": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/guide_split.wav",
"editedMetadata": {
"sha256": "72df6edc53a6e320e48b6bae18ba6bed43034028795021ff48d6f04bc4dc7649",
"sampleRate": 44100,
"channels": 1,
"frames": 236376,
"duration": 5.36,
"peak": 0.6300048828125,
"rms": 0.06916704773902893,
"qualityWarnings": []
},
"review": "not_listened",
"status": "draft",
"processing": "polyphase resample to mono PCM16 44100 Hz; no speech trimming"
},
{
"id": "demo_composition",
"sourceRunId": "20261004T193323396066Z_c5daf2df",
"sourceFile": "generated/20261004T193323396066Z_c5daf2df/demo_composition.wav",
"sourceSha256": "f1aa3029c780da9005ccf1f173a82025f17225d4ee0952e9a21a75aa5516821e",
"editedFile": "edited/20261004T193323396066Z_c5daf2df/demo_composition.wav",
"runtimeFile": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/demo_composition.wav",
"editedMetadata": {
"sha256": "570452e9fa15ec545b126cd8bdba504a3db6f30b74a95405ee228015368761e7",
"sampleRate": 44100,
"channels": 1,
"frames": 419832,
"duration": 9.52,
"peak": 0.636322021484375,
"rms": 0.06664438545703888,
"qualityWarnings": []
},
"review": "not_listened",
"status": "draft",
"processing": "polyphase resample to mono PCM16 44100 Hz; no speech trimming"
}
]
}
@@ -0,0 +1,71 @@
{
"schemaVersion": 1,
"runId": "20261004T194102211708Z_8c81e83d",
"profileHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"catalogHash": "6c5169ff0a481096042f113a8698327fbdc3c88097369ef8c6e3add7c8622828",
"selections": [
{
"id": "guide_merge",
"sourceRunId": "20261004T193323396066Z_c5daf2df",
"sourceFile": "generated/20261004T193323396066Z_c5daf2df/guide_merge.wav",
"sourceSha256": "f521af71107190360187a11d20d0f95f4aac14ee73d79a75bb85018d1bfa164d",
"editedFile": "edited/20261004T194102211708Z_8c81e83d/guide_merge.wav",
"runtimeFile": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/guide_merge.wav",
"editedMetadata": {
"sha256": "522db037e0358b01b84dfc68f13e62073495a011a96d8ca407be28f36e98e16d",
"sampleRate": 44100,
"channels": 1,
"frames": 158760,
"duration": 3.6,
"peak": 0.387054443359375,
"rms": 0.05469931662082672,
"qualityWarnings": []
},
"review": "not_listened",
"status": "draft",
"processing": "polyphase resample to mono PCM16 44100 Hz; no speech trimming"
},
{
"id": "guide_split",
"sourceRunId": "20261004T193323396066Z_c5daf2df",
"sourceFile": "generated/20261004T193323396066Z_c5daf2df/guide_split.wav",
"sourceSha256": "9362a2243fd44b607a50fe4ca7f3a4d64a64e8ceb919b9eb6bb7098adaf67eac",
"editedFile": "edited/20261004T194102211708Z_8c81e83d/guide_split.wav",
"runtimeFile": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/guide_split.wav",
"editedMetadata": {
"sha256": "72df6edc53a6e320e48b6bae18ba6bed43034028795021ff48d6f04bc4dc7649",
"sampleRate": 44100,
"channels": 1,
"frames": 236376,
"duration": 5.36,
"peak": 0.6300048828125,
"rms": 0.06916704773902893,
"qualityWarnings": []
},
"review": "not_listened",
"status": "draft",
"processing": "polyphase resample to mono PCM16 44100 Hz; no speech trimming"
},
{
"id": "demo_composition",
"sourceRunId": "20261004T193323396066Z_c5daf2df",
"sourceFile": "generated/20261004T193323396066Z_c5daf2df/demo_composition.wav",
"sourceSha256": "f1aa3029c780da9005ccf1f173a82025f17225d4ee0952e9a21a75aa5516821e",
"editedFile": "edited/20261004T194102211708Z_8c81e83d/demo_composition.wav",
"runtimeFile": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/demo_composition.wav",
"editedMetadata": {
"sha256": "570452e9fa15ec545b126cd8bdba504a3db6f30b74a95405ee228015368761e7",
"sampleRate": 44100,
"channels": 1,
"frames": 419832,
"duration": 9.52,
"peak": 0.636322021484375,
"rms": 0.06664438545703888,
"qualityWarnings": []
},
"review": "not_listened",
"status": "draft",
"processing": "polyphase resample to mono PCM16 44100 Hz; no speech trimming"
}
]
}
@@ -0,0 +1,452 @@
{
"schemaVersion": 1,
"clips": [
{
"id": "count_01",
"text": "하나",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": true
},
{
"id": "count_02",
"text": "둘",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_03",
"text": "셋",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_04",
"text": "넷",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_05",
"text": "다섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_06",
"text": "여섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_07",
"text": "일곱",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_08",
"text": "여덟",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_09",
"text": "아홉",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_10",
"text": "열",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "guide_collect",
"text": "친구를 찾아 끌어와 볼까?",
"usage": "guide_collect",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
},
{
"id": "guide_count",
"text": "모두 모았네! 하나씩 눌러 세어 보자.",
"usage": "guide_count",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
},
{
"id": "guide_merge",
"text": "친구를 가까이 끌어와 붙여 볼까?",
"usage": "composition-guide",
"stageIds": [
6,
14
],
"required": true
},
{
"id": "guide_split",
"text": "나누기를 누르고, 나눌 구를 골라 끌어내 보자.",
"usage": "composition-guide",
"stageIds": [
10,
14
],
"required": true
},
{
"id": "demo_composition",
"text": "친구를 붙이면 하나의 친구가 돼. 나누기를 누르고 구를 골라 끌어내면, 두 친구로 나뉘어. 둘과 둘을 만들어 보자.",
"usage": "composition-demo",
"stageIds": [
6,
10,
14
],
"required": false
}
]
}
@@ -0,0 +1,117 @@
{
"schemaVersion": 1,
"runId": "20261004T193323396066Z_c5daf2df",
"createdUtc": "2026-10-04T19:33:23.398573+00:00",
"modelRevision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
"catalogHash": "1c74dcfc4d67eb2583dac6168e164c242d7af349ef77e74f700426dc43c6a21f",
"profileHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"scope": "selected",
"regenerateRequested": true,
"inputSnapshots": {
"catalog": "catalog.json",
"profile": "profile.json"
},
"requestedIds": [
"guide_merge",
"guide_split",
"demo_composition"
],
"environment": {
"python": "3.12.15 | packaged by Anaconda, Inc. | (main, Oct 1 2026, 20:14:50) [MSC v.1942 64 bit (AMD64)]",
"packages": {
"qwen-tts": "0.1.1",
"torch": "2.11.0+cu128",
"torchaudio": "2.11.0",
"transformers": "4.57.3",
"soundfile": "0.14.0",
"huggingface-hub": "0.36.2"
},
"device": "NVIDIA GeForce RTX 4080 SUPER",
"cuda": "12.8",
"modelDevice": "cuda:0",
"cudaProbe": 140.0,
"modelLoadSeconds": 6.364993199938908,
"modelPeakVramBytes": 4181138432,
"modelDefaults": {
"do_sample": true,
"repetition_penalty": 1.05,
"temperature": 0.9,
"top_p": 1.0,
"top_k": 50,
"subtalker_dosample": true,
"subtalker_temperature": 0.9,
"subtalker_top_p": 1.0,
"subtalker_top_k": 50,
"max_new_tokens": 8192
},
"effectiveGenerationParameters": {
"do_sample": true,
"top_k": 50,
"top_p": 1,
"temperature": 0.9,
"repetition_penalty": 1.05,
"subtalker_dosample": true,
"subtalker_top_k": 50,
"subtalker_top_p": 1,
"subtalker_temperature": 0.9,
"max_new_tokens": 8192
},
"peakVramBytes": 4395508224
},
"clips": [
{
"id": "guide_merge",
"textHash": "10114a172f0beb4b89e004a2b0d20525eb92e961a3e2f4199d7f1493d52bde07",
"settingsHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"originalRunId": "20261004T193323396066Z_c5daf2df",
"file": "generated/20261004T193323396066Z_c5daf2df/guide_merge.wav",
"review": "not_listened",
"sha256": "f521af71107190360187a11d20d0f95f4aac14ee73d79a75bb85018d1bfa164d",
"sampleRate": 24000,
"channels": 1,
"frames": 86400,
"duration": 3.6,
"peak": 0.38671875,
"rms": 0.05468548461794853,
"qualityWarnings": [],
"result": "generated",
"generationSeconds": 8.76143110007979
},
{
"id": "guide_split",
"textHash": "9bb7cdc1a8c9cbcb6d2ea132dd92e074289bebdb3835be0dc47cc971807353fa",
"settingsHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"originalRunId": "20261004T193323396066Z_c5daf2df",
"file": "generated/20261004T193323396066Z_c5daf2df/guide_split.wav",
"review": "not_listened",
"sha256": "9362a2243fd44b607a50fe4ca7f3a4d64a64e8ceb919b9eb6bb7098adaf67eac",
"sampleRate": 24000,
"channels": 1,
"frames": 128640,
"duration": 5.36,
"peak": 0.62890625,
"rms": 0.06911510229110718,
"qualityWarnings": [],
"result": "generated",
"generationSeconds": 14.070215299958363
},
{
"id": "demo_composition",
"textHash": "50b079d47c9bd03bf8b762790ca977271286264eda554c0217b3062fc0f6b560",
"settingsHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"originalRunId": "20261004T193323396066Z_c5daf2df",
"file": "generated/20261004T193323396066Z_c5daf2df/demo_composition.wav",
"review": "not_listened",
"sha256": "f1aa3029c780da9005ccf1f173a82025f17225d4ee0952e9a21a75aa5516821e",
"sampleRate": 24000,
"channels": 1,
"frames": 228480,
"duration": 9.52,
"peak": 0.6328125,
"rms": 0.06663522124290466,
"qualityWarnings": [],
"result": "generated",
"generationSeconds": 18.75810609990731
}
]
}
@@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"modelId": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"modelRevision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
"language": "Korean",
"speaker": "Sohee",
"instruct": "5~7세 아이에게 이야기하듯 따뜻하고 밝게, 천천히 또렷하게 읽어 주세요. 숫자는 정확하게 읽고 과장된 비명이나 웃음소리는 넣지 마세요.",
"device": "cuda:0",
"dtype": "bfloat16",
"attention": "sdpa",
"seed": 42,
"generationParameters": {
"do_sample": true,
"repetition_penalty": 1.05,
"temperature": 0.9,
"top_p": 1,
"top_k": 50,
"subtalker_dosample": true,
"subtalker_temperature": 0.9,
"subtalker_top_p": 1,
"subtalker_top_k": 50,
"max_new_tokens": 8192
}
}
@@ -0,0 +1,450 @@
{
"schemaVersion": 1,
"clips": [
{
"id": "count_01",
"text": "하나",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": true
},
{
"id": "count_02",
"text": "둘",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_03",
"text": "셋",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_04",
"text": "넷",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_05",
"text": "다섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_06",
"text": "여섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_07",
"text": "일곱",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_08",
"text": "여덟",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_09",
"text": "아홉",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_10",
"text": "열",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "guide_collect",
"text": "친구를 찾아 끌어와 볼까?",
"usage": "guide_collect",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
},
{
"id": "guide_count",
"text": "모두 모았네! 하나씩 눌러 세어 보자.",
"usage": "guide_count",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
},
{
"id": "guide_merge",
"text": "친구를 가까이 끌어와 붙여 볼까?",
"usage": "composition-guide",
"stageIds": [
6,
14
],
"required": true
},
{
"id": "guide_split",
"text": "나누기를 누르고, 나눌 구를 골라 끌어내 보자.",
"usage": "composition-guide",
"stageIds": [
10,
14
],
"required": true
},
{
"id": "demo_composition",
"text": "친구를 붙이면 하나의 친구가 돼. 나누기를 누르고 구를 골라 끌어내면, 두 친구로 나뉘어. 둘과 둘을 만들어 보자.",
"usage": "composition-demo",
"stageIds": [
14
],
"required": false
}
]
}
@@ -0,0 +1,139 @@
{
"schemaVersion": 1,
"runId": "20261004T194102211708Z_8c81e83d",
"createdUtc": "2026-10-04T19:41:02.212706+00:00",
"modelRevision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
"catalogHash": "6c5169ff0a481096042f113a8698327fbdc3c88097369ef8c6e3add7c8622828",
"profileHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"scope": "selected",
"regenerateRequested": false,
"inputSnapshots": {
"catalog": "catalog.json",
"profile": "profile.json"
},
"requestedIds": [
"guide_merge",
"guide_split",
"demo_composition"
],
"environment": {
"python": "3.12.15 | packaged by Anaconda, Inc. | (main, Oct 1 2026, 20:14:50) [MSC v.1942 64 bit (AMD64)]",
"packages": {
"qwen-tts": "0.1.1",
"torch": "2.11.0+cu128",
"torchaudio": "2.11.0",
"transformers": "4.57.3",
"soundfile": "0.14.0",
"huggingface-hub": "0.36.2"
},
"modelLoad": "not_needed",
"requestedDevice": "cuda:0",
"sourceGenerations": [
{
"runId": "20261004T193323396066Z_c5daf2df",
"manifest": "generated/20261004T193323396066Z_c5daf2df/manifest.json",
"manifestSha256": "f8f62d760077f17c090601fadc7e8b08b9eaba657010840c7267e987a7556bad",
"environment": {
"python": "3.12.15 | packaged by Anaconda, Inc. | (main, Oct 1 2026, 20:14:50) [MSC v.1942 64 bit (AMD64)]",
"packages": {
"qwen-tts": "0.1.1",
"torch": "2.11.0+cu128",
"torchaudio": "2.11.0",
"transformers": "4.57.3",
"soundfile": "0.14.0",
"huggingface-hub": "0.36.2"
},
"device": "NVIDIA GeForce RTX 4080 SUPER",
"cuda": "12.8",
"modelDevice": "cuda:0",
"cudaProbe": 140.0,
"modelLoadSeconds": 6.364993199938908,
"modelPeakVramBytes": 4181138432,
"modelDefaults": {
"do_sample": true,
"repetition_penalty": 1.05,
"temperature": 0.9,
"top_p": 1.0,
"top_k": 50,
"subtalker_dosample": true,
"subtalker_temperature": 0.9,
"subtalker_top_p": 1.0,
"subtalker_top_k": 50,
"max_new_tokens": 8192
},
"effectiveGenerationParameters": {
"do_sample": true,
"top_k": 50,
"top_p": 1,
"temperature": 0.9,
"repetition_penalty": 1.05,
"subtalker_dosample": true,
"subtalker_top_k": 50,
"subtalker_top_p": 1,
"subtalker_temperature": 0.9,
"max_new_tokens": 8192
},
"peakVramBytes": 4395508224
}
}
],
"device": "NVIDIA GeForce RTX 4080 SUPER",
"deviceProbe": "nvidia-smi; no model or CUDA inference executed"
},
"clips": [
{
"id": "guide_merge",
"textHash": "10114a172f0beb4b89e004a2b0d20525eb92e961a3e2f4199d7f1493d52bde07",
"settingsHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"originalRunId": "20261004T193323396066Z_c5daf2df",
"file": "generated/20261004T193323396066Z_c5daf2df/guide_merge.wav",
"review": "not_listened",
"sha256": "f521af71107190360187a11d20d0f95f4aac14ee73d79a75bb85018d1bfa164d",
"sampleRate": 24000,
"channels": 1,
"frames": 86400,
"duration": 3.6,
"peak": 0.38671875,
"rms": 0.05468548461794853,
"qualityWarnings": [],
"result": "reused",
"generationSeconds": 8.76143110007979
},
{
"id": "guide_split",
"textHash": "9bb7cdc1a8c9cbcb6d2ea132dd92e074289bebdb3835be0dc47cc971807353fa",
"settingsHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"originalRunId": "20261004T193323396066Z_c5daf2df",
"file": "generated/20261004T193323396066Z_c5daf2df/guide_split.wav",
"review": "not_listened",
"sha256": "9362a2243fd44b607a50fe4ca7f3a4d64a64e8ceb919b9eb6bb7098adaf67eac",
"sampleRate": 24000,
"channels": 1,
"frames": 128640,
"duration": 5.36,
"peak": 0.62890625,
"rms": 0.06911510229110718,
"qualityWarnings": [],
"result": "reused",
"generationSeconds": 14.070215299958363
},
{
"id": "demo_composition",
"textHash": "50b079d47c9bd03bf8b762790ca977271286264eda554c0217b3062fc0f6b560",
"settingsHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"originalRunId": "20261004T193323396066Z_c5daf2df",
"file": "generated/20261004T193323396066Z_c5daf2df/demo_composition.wav",
"review": "not_listened",
"sha256": "f1aa3029c780da9005ccf1f173a82025f17225d4ee0952e9a21a75aa5516821e",
"sampleRate": 24000,
"channels": 1,
"frames": 228480,
"duration": 9.52,
"peak": 0.6328125,
"rms": 0.06663522124290466,
"qualityWarnings": [],
"result": "reused",
"generationSeconds": 18.75810609990731
}
]
}
@@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"modelId": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"modelRevision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
"language": "Korean",
"speaker": "Sohee",
"instruct": "5~7세 아이에게 이야기하듯 따뜻하고 밝게, 천천히 또렷하게 읽어 주세요. 숫자는 정확하게 읽고 과장된 비명이나 웃음소리는 넣지 마세요.",
"device": "cuda:0",
"dtype": "bfloat16",
"attention": "sdpa",
"seed": 42,
"generationParameters": {
"do_sample": true,
"repetition_penalty": 1.05,
"temperature": 0.9,
"top_p": 1,
"top_k": 50,
"subtalker_dosample": true,
"subtalker_temperature": 0.9,
"subtalker_top_p": 1,
"subtalker_top_k": 50,
"max_new_tokens": 8192
}
}
@@ -416,6 +416,35 @@
20 20
], ],
"required": true "required": true
},
{
"id": "guide_merge",
"text": "친구를 가까이 끌어와 붙여 볼까?",
"usage": "composition-guide",
"stageIds": [
6,
14
],
"required": true
},
{
"id": "guide_split",
"text": "나누기를 누르고, 나눌 구를 골라 끌어내 보자.",
"usage": "composition-guide",
"stageIds": [
10,
14
],
"required": true
},
{
"id": "demo_composition",
"text": "친구를 붙이면 하나의 친구가 돼. 나누기를 누르고 구를 골라 끌어내면, 두 친구로 나뉘어. 둘과 둘을 만들어 보자.",
"usage": "composition-demo",
"stageIds": [
14
],
"required": false
} }
] ]
} }
+3
View File
@@ -21,3 +21,6 @@
| count_01 | `13A882CE1620140D447635E14F2D4758045D5E348960C17386DF1C720050C100` | | count_01 | `13A882CE1620140D447635E14F2D4758045D5E348960C17386DF1C720050C100` |
| guide_collect | `62E15C9E38C36F4094A6C7E9C5148510141290B5F41DB5AAD9DC1D8F1A158594` | | guide_collect | `62E15C9E38C36F4094A6C7E9C5148510141290B5F41DB5AAD9DC1D8F1A158594` |
| guide_count | `DEBE29B89F3E2856F2BF4C5E01707A5F0A0657E5768C03BE385EC88FA2328B40` | | guide_count | `DEBE29B89F3E2856F2BF4C5E01707A5F0A0657E5768C03BE385EC88FA2328B40` |
# Composition 시제품 추가
Stage06/10/14 ScriptableObject와 GameApp의 대표 선택·나누기·취소 버튼은 `CompositionAssets.Build`가 Editor API로 생성/연결한다. GroupBadge에 묶음당 리본 망토 하나, BallView에 접촉 대상·선택 구의 색 표시를 연결했다. 구의 크기/경계와 기존 얼굴·숫자 시제품을 유지한다. 정적 한글 글꼴에 새 안내/버튼의 글자를 추가했다. 전체 AI 외형·최종 표정은 후속 단계다. 새 음성 3개는 아래 Qwen 등록 문서의 초안이며 기존 승인 파일은 그대로 유지한다.
+26
View File
@@ -0,0 +1,26 @@
# 합체·분리 대표 단계
실제 `Stage06/10/14.asset`은 각각 `[1,1] → [2]`, `[2] → [1,1]`, `[3,1] → [2,2]`다. GameApp 프리팹의 선택 버튼은 저장된 진행에 따른 기존 순차 해금을 사용한다. 새 저장에서는 6·10·14가 잠겨 있다. 중간 카탈로그에는 1·6·10·14만 있으며 전체 30단계는 후속 제작 범위다.
친구를 끌어 다른 묶음에 가까이 놓으면 가장 가까운 두 구의 접촉 위치에 붙는다. 대상 구의 초록색 미리보기는 저장하지 않는다. 각 구의 크기와 ID는 유지하고, 각 묶음에 숫자·대표 얼굴·얇은 리본 망토 하나씩을 표시한다. 숫자는 카메라 위쪽으로 묶음 전체 구의 투영 경계보다 높게 놓는다.
친구를 탭해 선택하고 **나누기**를 누른 뒤 구를 탭해 선택/해제한다. 선택 구를 끌어내면 선택과 나머지가 각각 하나의 친구로 모인다. 떨어진 선택도 허용한다. 0개·전체 선택은 상태·저장을 바꾸지 않는다. 선택 중 다른 친구를 탭하면 이전 선택을 해제한다. 핀치·터치 취소·영역 밖 해제·화면 전환은 미확정 이동과 선택 표시를 정리한다. 분리 확정 자체에서는 합체를 검사하지 않아 즉시 재합체하지 않는다. 다시 끌어 놓으면 합체할 수 있다.
`GameSession.MoveAndMerge`는 목적 그룹·모든 이동 좌표를 검증한 뒤 이동과 소속을 한 조작으로 확정한다. `SplitAndMove`는 전체 분리 배치를 먼저 계산/검증한 뒤 소속과 좌표를 확정한다. 두 API 모두 완료 판정과 저장 호출은 한 번이다. 선택·접촉·미리보기는 JSON v1에 추가하지 않는다. 14번은 조작 이력 대신 확정된 전체 묶음의 개수 `[2,2]`만 비교한다.
`CompositionAssets.Build`는 Editor API로 대표 정의·프리팹·정적 한글 글꼴·초안 음성을 연결한다. 공유 Main 씬은 수정하지 않는다. Main의 기존 GameApp 프리팹 인스턴스가 새 연결을 상속한다. 최종 외형, 6표정, 전체 시범은 후속 제작 범위다.
검증 명령:
```powershell
pwsh -NoProfile -File tools/verify.ps1 -Phase EditMode -Milestone Composition
pwsh -NoProfile -File tools/verify.ps1 -Phase PlayMode -Milestone Composition
pwsh -NoProfile -File tools/verify.ps1 -Phase AndroidBuild -Milestone Composition
pwsh -NoProfile -File tools/verify.ps1 -Phase AndroidSmoke -Milestone Composition
```
근거는 `artifacts/verification/Composition/`에 XML·명령/종료 코드·실제 source SHA/dirty·APK SHA·AVD serial·PNG·JSON 전후 쌍·실제 PCM으로 남긴다. 최초 RED는 도메인 72개 중 5개 실패(API 부재), PlayMode 28개 중 2개 실패(네이티브 취소/같은 프레임 핀치 뒤 선택광 잔존)였으며 `red-*` 파일에 보존한다. 기존 FirstSlice 25개 PlayMode 테스트는 변경하지 않았다. 새 음성의 기계 검사와 사람 청취 승인은 별개다.
Android 대표 검사는 앱 데이터에 정상적인 완료 1..5/1..9/1..13 진행 fixture만 쓴다. 제품에 직접 단계 실행 서버나 디버그 해금을 넣지 않는다. 에뮬레이터 결과는 실제 모바일 하드웨어 성능 판정이 아니다.
효과음 요청은 [audio-requests.md](audio-requests.md)의 `sfx_merge`, `sfx_split_select`, `sfx_split`를 사용한다. 사용자 요청에 따라 음원 선정·생성은 기다리며 현재 동작은 음성 안내와 무음 효과로 검사한다.
+15
View File
@@ -89,3 +89,18 @@ sidecar 전환 당시 `input-snapshot-migration.json`의 네 `runs[].originalMan
변경 전 원본 환경의 device만 `CORRUPTED_DEVICE_RECORD`로 바꾼 검사 복사본은 실제 `--validate-only`가 종료 0을 반환하는 결함을 재현했습니다. 두 새 회귀 검사는 RED 종료 1이었습니다. 보완 후 같은 검사 복사본의 실제 CLI는 원본 manifest SHA 불일치를 기록하고 종료 1을 반환했습니다. 원본 자료 다섯 회차는 각각 기록된 생성 범위를 검증하여 모두 종료 0이었고 전체 Python 18개 검사가 통과했습니다. 모델 loader와 장치 명령은 차단했고 torch·qwen_tts·huggingface_hub가 import되지 않았음을 기록했습니다. 실제 원본 manifest·입력·WAV·승인 이력은 변경하거나 재생성하지 않았습니다. 변경 전 원본 환경의 device만 `CORRUPTED_DEVICE_RECORD`로 바꾼 검사 복사본은 실제 `--validate-only`가 종료 0을 반환하는 결함을 재현했습니다. 두 새 회귀 검사는 RED 종료 1이었습니다. 보완 후 같은 검사 복사본의 실제 CLI는 원본 manifest SHA 불일치를 기록하고 종료 1을 반환했습니다. 원본 자료 다섯 회차는 각각 기록된 생성 범위를 검증하여 모두 종료 0이었고 전체 Python 18개 검사가 통과했습니다. 모델 loader와 장치 명령은 차단했고 torch·qwen_tts·huggingface_hub가 import되지 않았음을 기록했습니다. 실제 원본 manifest·입력·WAV·승인 이력은 변경하거나 재생성하지 않았습니다.
증거는 `artifacts/verification/FirstSlice/Voice/OriginIntegrity/`의 `red-tests.log`, `red-cli-proof.json` 및 `.log`, `green-tests.log`, `green-cli-proof.json` 및 `.log`에 있습니다. 증거는 `artifacts/verification/FirstSlice/Voice/OriginIntegrity/`의 `red-tests.log`, `red-cli-proof.json` 및 `.log`, `green-tests.log`, `green-cli-proof.json` 및 `.log`에 있습니다.
# Composition 새 안내 초안
`guide_merge`("친구를 가까이 끌어와 붙여 볼까?", 3.600초), `guide_split`("나누기를 누르고, 나눌 구를 골라 끌어내 보자.", 5.360초), `demo_composition`(9.520초)을 로컬 RTX4080 SUPER에서 같은 고정 Qwen3-TTS 1.7B CustomVoice revision/한국어 Sohee/seed42/bfloat16/sdpa/batch1로 생성했다. 시범 대본의 마지막 "둘과 둘을 만들어 보자"는 14번에서만 사용하므로 현재 카탈로그 stageIds는 `[14]`다. 6·10의 목표별 시범은 후속 제작 범위다.
생성 원본과 고정 입력 스냅샷은 `SourceAssets/Audio/Voice/generated/20261004T193323396066Z_c5daf2df/`에 있다. 14번 메타데이터 범위 수정은 원음 byte를 유지해 `20261004T194102211708Z_8c81e83d` reuse provenance로 기록했다. 대응 `edited/.../selection.json`은 모든 후보를 `not_listened`/`draft`로 분류한다. 생성·검증·prepare는 exit0, 오류0이다. 리샘플링만으로 PCM16 mono44.1kHz runtime WAV를 만들었고 말 줄이기·숫자 후보 수리는 하지 않았다. 사람 청취 승인 상태는 root가 별도로 관리한다.
runtime SHA256:
| ID | SHA256 |
|---|---|
| guide_merge | 522db037e0358b01b84dfc68f13e62073495a011a96d8ca407be28f36e98e16d |
| guide_split | 72df6edc53a6e320e48b6bae18ba6bed43034028795021ff48d6f04bc4dc7649 |
| demo_composition | 570452e9fa15ec545b126cd8bdba504a3db6f30b74a95405ee228015368761e7 |
기존 승인 count01/guide_collect/guide_count는 동일 byte이며 기존 생성·승인·라이선스 기록은 변경하지 않았다. 새 후보에도 같은 로컬 모델/코드 라이선스가 적용된다. 효과음·음악은 생성하지 않았다.
+129
View File
@@ -0,0 +1,129 @@
param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$EvidenceDir,[string]$Serial='emulator-5554')
$ErrorActionPreference='Stop'
$EvidenceDir=(Resolve-Path -LiteralPath $EvidenceDir).Path
$sdk=if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' }
$adb=Join-Path $sdk 'platform-tools/adb.exe'
$checks=[Collections.Generic.List[object]]::new(); $commands=[Collections.Generic.List[object]]::new(); $recorders=[Collections.Generic.List[object]]::new()
$package='com.magnetismheroes.game'; $activity="$package/com.unity3d.player.UnityPlayerGameActivity"; $remote="/sdcard/Android/data/$package/files/progress.json"
$result=[ordered]@{milestone='Composition';sourceCommit=(& git rev-parse HEAD);sourceDirty=[bool](& git status --porcelain);serial=$Serial;apkSha256=(Get-FileHash $ApkPath -Algorithm SHA256).Hash;status='failed';checks=@();commands=@()}
$networkBefore=$null
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 "Composition 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
}
function Save([string]$Name) { Adb @('pull',$remote,(Join-Path $EvidenceDir "$Name.json")) | Out-Null; return Get-Content (Join-Path $EvidenceDir "$Name.json") -Raw | ConvertFrom-Json }
function Fixture([int]$Stage) {
Adb @('shell','am','force-stop',$package) | Out-Null
$fixture=[ordered]@{schemaVersion=1;completedStageIds=@(1..($Stage-1));seenTutorialIds=@();puzzleStates=@();latestFreePlay=$null}
$path=Join-Path $EvidenceDir "fixture-$Stage.json"; $fixture | ConvertTo-Json -Depth 8 | Set-Content $path -Encoding utf8
Adb @('shell','mkdir','-p',"/sdcard/Android/data/$package/files") | Out-Null
Adb @('push',$path,$remote) | Out-Null
Adb @('shell','rm','-f',($remote+'.bak')) | Out-Null
Step "stage-$Stage-home" @('shell','am','start','-W','-n',$activity) 6500
Step "stage-$Stage-selection" @('shell','input','tap','1200','616')
}
function Restart([string]$Name) {
Adb @('shell','input','keyevent','3') | Out-Null; Adb @('shell','am','force-stop',$package) | Out-Null
Step "$Name-home" @('shell','am','start','-W','-n',$activity) 6500
}
function StartAudio([string]$Name,[int]$Seconds) {
$path=Join-Path $EvidenceDir "$Name.wav"
$arguments=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-audio.ps1'),'-OutputPath',$path,'-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
$recorders.Add(@{name=$Name;process=$process}); Start-Sleep -Seconds 2
}
function Touch([int[]]$Contacts) {
& (Join-Path $PSScriptRoot 'send-composition-touch.ps1') -Contacts $Contacts
$commands.Add(@{command=('EmulatorController.sendTouch contacts='+($Contacts -join ','));exitCode=0})
}
function SameJson($First,$Second) { return ($First | ConvertTo-Json -Depth 12 -Compress) -eq ($Second | ConvertTo-Json -Depth 12 -Compress) }
try {
Check 'clean-source' (!$result.sourceDirty) $result.sourceCommit
$build=Get-Content (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.apkSha256 -eq $result.apkSha256) $result.apkSha256
Adb @('install','-r',$ApkPath) | Out-Null; Adb @('logcat','-c') | Out-Null
$networkBefore=@{airplane=(Adb @('shell','settings','get','global','airplane_mode_on'));wifi=(Adb @('shell','settings','get','global','wifi_on'));data=(Adb @('shell','settings','get','global','mobile_data'))}
$result.networkBefore=$networkBefore
Adb @('shell','cmd','connectivity','airplane-mode','enable') | Out-Null; Adb @('shell','svc','wifi','disable') | Out-Null; Adb @('shell','svc','data','disable') | Out-Null
Check 'offline-real-device-network' ((Adb @('shell','settings','get','global','airplane_mode_on')) -eq '1' -and (Adb @('shell','settings','get','global','wifi_on')) -eq '0' -and (Adb @('shell','settings','get','global','mobile_data')) -eq '0' -and (Adb @('shell','dumpsys','connectivity')) -match 'Active default network: none') 'Airplane1 wifi0 data0; no active default network'
$result.device=@{model=(Adb @('shell','getprop','ro.product.model'));abi=(Adb @('shell','getprop','ro.product.cpu.abilist'));bridge=(Adb @('shell','getprop','ro.dalvik.vm.native.bridge'));api=(Adb @('shell','getprop','ro.build.version.sdk'))}
Check 'landscape-resolution' ((Adb @('shell','wm','size')) -match '1080x2400') 'Physical resolution1080x2400, landscape2400x1080'
Adb @('shell','pm','clear',$package) | Out-Null
Step 'new-save-home' @('shell','am','start','-W','-n',$activity) 6500
Step 'new-save-selection' @('shell','input','tap','1200','616')
Step 'new-save-locked-six' @('shell','input','tap','480','560')
$locked=Save 'new-save-after-locked-six'; Check 'new-save-six-remains-locked' ($locked.puzzleStates.Count -eq 0 -and $locked.completedStageIds.Count -eq 0) 'No puzzle created by tapping locked6'
Fixture 6; StartAudio 'audio-guide-merge' 10
Step 'six-initial-face-number' @('shell','input','tap','480','560') 1200
$sixBefore=Save 'six-before'; Check 'six-real-initial' ((($sixBefore.puzzleStates[0].groups | ForEach-Object {$_.ballIds.Count}) -join ',') -eq '1,1') 'Two initial singleton groups'
# Hold real Android touch while observing the contact candidate; no runtime control server.
Touch @(1009,510,401,1); Start-Sleep -Milliseconds 100; Touch @(1291,510,401,1); Step 'six-contact-candidate'
$preview=Save 'six-preview'; Check 'six-preview-not-saved' (SameJson $preview $sixBefore) 'Preview JSON equals committed initial JSON'
Touch @(1291,510,401,0); Step 'six-success-face-number'
$six=Save 'six-success'; Check 'six-final-two' ($six.completedStageIds -contains 6 -and $six.puzzleStates.Count -eq 0 -and $six.latestFreePlay.groups.Count -eq 1 -and $six.latestFreePlay.groups[0].ballIds.Count -eq 2) 'Completed6; one2 group; no unfinished'
Check 'six-ids-preserved' ((($six.latestFreePlay.balls.id | Sort-Object) -join ',') -eq (($sixBefore.puzzleStates[0].balls.id | Sort-Object) -join ',')) 'All original IDs preserved'
Fixture 10; StartAudio 'audio-guide-split' 12
Step 'ten-initial-face-number' @('shell','input','tap','1920','560') 1000
Step 'ten-choose-friend' @('shell','input','tap','1120','510'); Step 'ten-split-mode' @('shell','input','tap','312','832')
$tenBefore=Save 'ten-before'
Step 'ten-zero-selection-drag' @('shell','input','swipe','1120','510','850','510','500')
Check 'ten-zero-unchanged' (SameJson $tenBefore (Save 'ten-zero-after')) 'Zero selection preserves JSON'
Step 'ten-select-left' @('shell','input','tap','1120','510'); Step 'ten-select-right' @('shell','input','tap','1280','510')
Step 'ten-all-selection-drag' @('shell','input','swipe','1120','510','850','510','500')
Check 'ten-all-unchanged' (SameJson $tenBefore (Save 'ten-all-after')) 'Entire selection preserves JSON'
Step 'ten-deselect-right' @('shell','input','tap','1280','510'); Step 'ten-selected-count-face-number'
Touch @(1120,510,411,1); Start-Sleep -Milliseconds 100; Touch @(900,510,411,1); Step 'ten-split-preview'
# UI cancel is a separate product action; release first restores invalid/outside gesture via a real edge release.
Touch @(2500,510,411,0); Step 'ten-outside-cancel'
Check 'ten-outside-cancel-unchanged' (SameJson $tenBefore (Save 'ten-outside-after')) 'Off-board release keeps committed JSON'
Step 'ten-select-again' @('shell','input','tap','1120','510'); Step 'ten-split-again' @('shell','input','tap','312','832'); Step 'ten-selection-again' @('shell','input','tap','1120','510')
Touch @(1120,510,421,1); Touch @(1030,510,421,1); Touch @(1390,510,422,1)
Touch @(1030,510,421,0,1390,510,422,0); Step 'ten-pinch-switch'
$pinch=Save 'ten-pinch-after'; Check 'ten-pinch-no-split' ($pinch.puzzleStates[0].groups.Count -eq 1 -and (SameJson $pinch.puzzleStates[0].balls $tenBefore.puzzleStates[0].balls)) 'Pinch aborts split, all ball positions unchanged'
# Return to unchanged committed camera by restarting from the fresh fixture before the successful split.
Fixture 10; Step 'ten-success-start' @('shell','input','tap','1920','560'); Step 'ten-success-choose' @('shell','input','tap','1120','510')
Step 'ten-success-split' @('shell','input','tap','312','832'); Step 'ten-success-select' @('shell','input','tap','1120','510')
Step 'ten-success-drag' @('shell','input','swipe','1120','510','850','510','650')
$ten=Save 'ten-success'; Check 'ten-final-one-one-no-remerge' ($ten.completedStageIds -contains 10 -and $ten.puzzleStates.Count -eq 0 -and $ten.latestFreePlay.groups.Count -eq 2 -and @($ten.latestFreePlay.groups | Where-Object {$_.ballIds.Count -ne 1}).Count -eq 0) 'Completed10; exactly1,1; no auto-remerge'
Fixture 14; Step 'fourteen-initial' @('shell','input','tap','1200','366'); Step 'fourteen-choose-three' @('shell','input','tap','882','510')
Step 'fourteen-split-mode' @('shell','input','tap','312','832'); Step 'fourteen-select-end-one' @('shell','input','tap','882','510'); Step 'fourteen-select-end-two' @('shell','input','tap','1200','510')
Step 'fourteen-disconnected-selected' @(); Step 'fourteen-disconnected-split' @('shell','input','swipe','882','510','500','510','650')
$fourteenBefore=Save 'fourteen-unfinished-before'; $sizes=@($fourteenBefore.puzzleStates[0].groups | ForEach-Object {$_.ballIds.Count} | Sort-Object)
Check 'fourteen-disconnected-two-results' (($sizes -join ',') -eq '1,1,2') 'Selected disconnected ends form one2 friend; rest one1; original other1'
Restart 'fourteen-restore'; Step 'fourteen-restore-selection' @('shell','input','tap','1200','616'); Step 'fourteen-restored' @('shell','input','tap','1200','366')
Check 'fourteen-unfinished-restored' (SameJson $fourteenBefore (Save 'fourteen-unfinished-after')) 'All unfinished JSON including IDs positions groups camera restored'
Step 'fourteen-single-merge' @('shell','input','swipe','1041','510','1418','510','650')
$fourteen=Save 'fourteen-success'; Check 'fourteen-final-two-two' ($fourteen.completedStageIds -contains 14 -and $fourteen.puzzleStates.Count -eq 0 -and $fourteen.latestFreePlay.groups.Count -eq 2 -and @($fourteen.latestFreePlay.groups | Where-Object {$_.ballIds.Count -ne 2}).Count -eq 0) 'Exactly2,2 completes14 after split then merge'
Restart 'free-restore'; Step 'free-restored' @('shell','input','tap','1200','810')
Check 'free-json-restored' (SameJson $fourteen (Save 'free-after-restore')) 'Final free board restores all groups IDs positions camera'
Step 'free-rotate-underneath' @('shell','input','swipe','2100','650','2250','980','700')
foreach ($recorder in $recorders) {
if (!$recorder.process.WaitForExit(30000)) { throw 'PCM collection did not finish' }; $recorder.process.Refresh()
Check "pcm-recorder-$($recorder.name)" ($recorder.process.ExitCode -eq 0) "$($recorder.process.ExitCode)"
$id=if ($recorder.name -eq 'audio-guide-merge') {'guide_merge'} else {'guide_split'}
$comparison=Join-Path $EvidenceDir ($recorder.name+'-match.json')
& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/$id.wav" -CapturedPath (Join-Path $EvidenceDir ($recorder.name+'.wav')) -OutputPath $comparison
$match=Get-Content $comparison -Raw | ConvertFrom-Json; Check "pcm-$id" ($LASTEXITCODE -eq 0 -and $match.passed -and $match.normalizedCorrelation -ge .95) ($match | ConvertTo-Json -Compress)
}
Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content (Join-Path $EvidenceDir 'android-runtime.log') -Encoding utf8
Check 'no-runtime-fatal' (!(Get-Content (Join-Path $EvidenceDir 'android-runtime.log') -Raw | Select-String 'FATAL EXCEPTION|NullReferenceException|MissingReferenceException|IndexOutOfRangeException')) 'No fatal or missing-reference runtime exception'
$result.status='passed'
} catch { $result.error=$_.Exception.Message; Write-Host $result.error -ForegroundColor Red }
finally {
if ($networkBefore) {
Adb @('shell','cmd','connectivity','airplane-mode',$(if ($networkBefore.airplane -eq '1') {'enable'} else {'disable'})) | Out-Null
Adb @('shell','svc','wifi',$(if ($networkBefore.wifi -eq '1') {'enable'} else {'disable'})) | Out-Null
Adb @('shell','svc','data',$(if ($networkBefore.data -eq '1') {'enable'} else {'disable'})) | Out-Null
$result.networkAfter=@{airplane=(Adb @('shell','settings','get','global','airplane_mode_on'));wifi=(Adb @('shell','settings','get','global','wifi_on'));data=(Adb @('shell','settings','get','global','mobile_data'))}
}
$result.checks=$checks.ToArray(); $result.commands=$commands.ToArray(); $result.finishedUtc=[DateTime]::UtcNow.ToString('o')
$result | ConvertTo-Json -Depth 12 | Set-Content (Join-Path $EvidenceDir 'android-composition-evidence.json') -Encoding utf8
}
if ($result.status -ne 'passed') { exit 1 }; Write-Host "Composition actual Android checks passed: $($checks.Count). PNG visual review remains explicit."; exit 0
+29
View File
@@ -0,0 +1,29 @@
param([Parameter(Mandatory)][int[]]$Contacts, [int]$Port=8554)
$ErrorActionPreference='Stop'
if ($Contacts.Count -eq 0 -or $Contacts.Count % 4 -ne 0) { throw 'Contacts must contain repeated x,y,id,pressure quadruples.' }
[AppContext]::SetSwitch('System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport', $true)
$discovery=Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'Temp/avd/running') -Filter 'pid_*.ini' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
$tokenLine=Get-Content -LiteralPath $discovery.FullName | Where-Object { $_ -match '^grpc.token=' } | Select-Object -First 1
if (!$tokenLine) { throw 'Authenticated emulator discovery is required; tokens are never logged.' }
function Varint([int]$Value) {
[uint32]$number=$Value; $bytes=[Collections.Generic.List[byte]]::new()
while ($number -ge 128) { $bytes.Add([byte](($number -band 127) -bor 128)); $number=$number -shr 7 }
$bytes.Add([byte]$number); return ,$bytes.ToArray()
}
$message=[Collections.Generic.List[byte]]::new()
for ($offset=0; $offset -lt $Contacts.Count; $offset+=4) {
$touch=[Collections.Generic.List[byte]]::new()
for ($field=0; $field -lt 4; $field++) { $touch.Add([byte](8*($field+1))); $touch.AddRange([byte[]](Varint $Contacts[$offset+$field])) }
$message.Add(10); $message.AddRange([byte[]](Varint $touch.Count)); $message.AddRange($touch.ToArray())
}
$frame=[byte[]]::new($message.Count+5); $frame[1]=($message.Count -shr 24) -band 255; $frame[2]=($message.Count -shr 16) -band 255
$frame[3]=($message.Count -shr 8) -band 255; $frame[4]=$message.Count -band 255; $message.ToArray().CopyTo($frame,5)
$client=[Net.Http.HttpClient]::new(); $request=[Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::Post,"http://127.0.0.1:$Port/android.emulation.control.EmulatorController/sendTouch")
$request.Version=[Version]::new(2,0); $request.VersionPolicy=[Net.Http.HttpVersionPolicy]::RequestVersionExact
$request.Headers.Add('TE','trailers'); $request.Headers.Authorization=[Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer',$tokenLine.Substring('grpc.token='.Length))
$request.Content=[Net.Http.ByteArrayContent]::new($frame); $request.Content.Headers.ContentType=[Net.Http.Headers.MediaTypeHeaderValue]::new('application/grpc')
try {
$response=$client.SendAsync($request).GetAwaiter().GetResult(); $response.EnsureSuccessStatusCode() | Out-Null
$status=if ($response.TrailingHeaders.Contains('grpc-status')) { ($response.TrailingHeaders.GetValues('grpc-status') -join '') } else { '0' }
if ($status -ne '0') { throw "Emulator sendTouch grpc-status=$status" }
} finally { $request.Dispose(); $client.Dispose() }
+4
View File
@@ -1,4 +1,8 @@
param([Parameter(Mandatory)][string]$ApkPath, [string]$Milestone='FirstSlice', [Parameter(Mandatory)][string]$EvidenceDir, [string]$Serial='emulator-5554') param([Parameter(Mandatory)][string]$ApkPath, [string]$Milestone='FirstSlice', [Parameter(Mandatory)][string]$EvidenceDir, [string]$Serial='emulator-5554')
if ($Milestone -eq 'Composition') {
& (Join-Path $PSScriptRoot 'exercise-composition.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir -Serial $Serial
exit $LASTEXITCODE
}
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' } $sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' }
$adb = Join-Path $sdk 'platform-tools/adb.exe' $adb = Join-Path $sdk 'platform-tools/adb.exe'