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.
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)
{
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 int[] CompletedStageIds => (int[])_progress.completedStageIds.Clone();
public IReadOnlyList<StageDefinition> Definitions => Array.AsReadOnly(_definitions);
public bool HasUnfinishedStage(int stageId) => _progress.puzzleStates.Any(state => state.stageId == stageId);
public 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 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 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 _homeButton;
[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 OrbitCamera _orbit;
[SerializeField] private BoardInput _input;
@@ -46,6 +49,10 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
_stageOneButton.onClick.AddListener(() => StartStage(1));
_homeButton.onClick.AddListener(ShowHome);
_selectionHomeButton.onClick.AddListener(ShowHome);
for (int index = 0; index < _compositionButtons.Length; index++)
{ int stageId = _stages[index + 1].StageId; _compositionButtons[index].onClick.AddListener(() => StartStage(stageId)); }
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()
{
@@ -61,27 +68,37 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
Refresh(true);
}
public void ShowSelection()
{ _input.Cancel(); _voice.Clear(); Session.ShowSelection(); Refresh(true); }
{ _input.CancelSplit(); _voice.Clear(); Session.ShowSelection(); Refresh(true); }
public void ShowHome()
{ _input.Cancel(); _voice.Clear(); Session.ShowHome(); Refresh(true); }
{ _input.CancelSplit(); _voice.Clear(); Session.ShowHome(); Refresh(true); }
public void StartStage(int id)
{
_input.Cancel(); _voice.Clear();
_input.CancelSplit(); _voice.Clear(); bool restored = Session.HasUnfinishedStage(id);
if (!Session.StartStage(id)) return;
Refresh(true);
// 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()
{ _input.Cancel(); _voice.Clear(); if (Session.ContinueFreePlay()) Refresh(true); }
public void CommitMove(string groupId, Position3 delta)
{ _input.CancelSplit(); _voice.Clear(); if (Session.ContinueFreePlay()) Refresh(true); }
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);
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)
{
if (Session.Board.Mode != BoardMode.Counting) return;
var result = Session.Count(ballId);
if (result.Accepted) _voice.EnqueueCount(result.Count);
if (result.Accepted) Refresh(false);
@@ -99,6 +116,7 @@ namespace MagnetismHeroes.UnityRuntime.Bootstrap
bool playing = screen == SessionScreen.Puzzle || screen == SessionScreen.FreePlay;
_playPanel.SetActive(playing); _world.SetActive(playing);
_continueButton.gameObject.SetActive(Session.HasFreePlay);
for (int index = 0; index < _compositionButtons.Length; index++) _compositionButtons[index].interactable = Session.IsUnlocked(_stages[index + 1].StageId);
if (applyCamera) _orbit.Apply(Session.Camera);
_presenter.Render(Session.Board);
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}";
_instruction.text = screen == SessionScreen.FreePlay ? "해냈어! 친구와 자유롭게 놀아 보자."
: 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)
{ 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(); } }
}
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Bootstrap;
using MagnetismHeroes.UnityRuntime.Presentation;
@@ -20,6 +21,13 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
private Vector2 _start, _last;
private string _ballId, _groupId;
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 Vector3 _grabPoint, _previewDelta;
private float _pinchDistance;
@@ -39,6 +47,16 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
private readonly Dictionary<int, TouchContact> _touches = new Dictionary<int, TouchContact>();
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)
{
Cancel();
@@ -50,6 +68,8 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
if (Physics.Raycast(ray, out var hit, 100) && hit.collider.TryGetComponent<BallView>(out var ball))
{
_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);
_grabPoint = hit.point;
}
@@ -63,7 +83,12 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
if (_dragged && _gesture == Gesture.Friend)
{
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);
_last = point;
@@ -71,15 +96,27 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
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 (_dragged)
{
if (!_presenter.CanDropGroup(_groupId)) { Cancel(); return; }
_app.CommitMove(_groupId, BoardPresenter.ToPosition(_previewDelta));
if (!_presenter.CanDropGroup(_groupId)) { if (_splitting) CancelSplit(); else Cancel(); return; }
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.Pinch) _app.Session.CommitCamera(_orbit.Snapshot());
@@ -88,7 +125,7 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
public void BeginPinch(float distance, bool uiBlocked)
{
Cancel();
if (_splitting) CancelSplit(); else Cancel();
_gesture = uiBlocked || !AcceptInput ? Gesture.Blocked : Gesture.Pinch;
_pinchDistance = distance;
}
@@ -119,7 +156,7 @@ namespace MagnetismHeroes.UnityRuntime.Interaction
private void FinishSingleTouch()
{
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);
}
@@ -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 Color _normalColor;
private float _encourageUntil;
public bool Selected { get; set; }
public bool ContactCandidate { get; set; }
public string BallId { get; private set; }
public string GroupId { get; private set; }
public void Bind(string ballId, string groupId, bool counted)
@@ -18,7 +20,8 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
private void Update()
{
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;
public Camera Camera => _camera;
public IReadOnlyDictionary<string, BallView> Balls => _balls;
public string MergeCandidate { get; private set; }
public Vector3 DropDelta { get; private set; }
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 badge in _badges) { badge.gameObject.SetActive(false); Destroy(badge.gameObject); }
_balls.Clear(); _badges.Clear();
MergeCandidate = null; DropDelta = Vector3.zero;
if (board == null) return;
foreach (var group in board.Groups)
{
@@ -38,15 +41,59 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
public void PreviewGroup(string groupId, Vector3 delta)
{
MergeCandidate = null; DropDelta = delta;
foreach (var state in _board.Balls)
_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();
}
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()
{
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();
}
@@ -78,8 +125,11 @@ namespace MagnetismHeroes.UnityRuntime.Presentation
{
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 representative = _balls[group.ballIds[0]].transform.position;
_badges[index].Bind(group.ballIds.Length, center + _camera.transform.up * 0.95f,
var representative = group.ballIds.Select(id => _balls[id].transform.position)
.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);
_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 Transform _face;
[SerializeField] private Transform _cape;
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)
{ _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
%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
GameObject:
m_ObjectHideFlags: 0
@@ -305,6 +449,7 @@ Transform:
m_Children:
- {fileID: 2644936306256428015}
- {fileID: 1885524188671059666}
- {fileID: 1940951101562305691}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &8586535435441923330
@@ -321,6 +466,7 @@ MonoBehaviour:
m_EditorClassIdentifier: MagnetismHeroes.UnityRuntime::MagnetismHeroes.UnityRuntime.Presentation.GroupBadge
_number: {fileID: 7156837666543227830}
_face: {fileID: 1885524188671059666}
_cape: {fileID: 1940951101562305691}
--- !u!1 &7995294815621610086
GameObject:
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: