From 8593a35a59bb9c15930f13dd36b639138b461b7c Mon Sep 17 00:00:00 2001 From: NINI Date: Mon, 5 Oct 2026 00:45:54 +0900 Subject: [PATCH] Preserve immutable decoration layouts in stage definitions --- Assets/MagnetismHeroes/Core/GameData.cs | 43 ++++++++- .../Tests/EditMode/Core/StageLayoutTests.cs | 93 +++++++++++++++++++ .../EditMode/Core/StageLayoutTests.cs.meta | 2 + 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs create mode 100644 Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs.meta diff --git a/Assets/MagnetismHeroes/Core/GameData.cs b/Assets/MagnetismHeroes/Core/GameData.cs index 4e60882..d99b21b 100644 --- a/Assets/MagnetismHeroes/Core/GameData.cs +++ b/Assets/MagnetismHeroes/Core/GameData.cs @@ -69,16 +69,53 @@ namespace MagnetismHeroes.Core public FreePlayState latestFreePlay; } + public sealed class DecorationPlacement + { + public string AssetKey { get; } + public Position3 Position { get; } + public Rotation4 Rotation { get; } + public Position3 Scale { get; } + + public DecorationPlacement(string assetKey, Position3 position, Rotation4 rotation, Position3 scale) + { + if (string.IsNullOrWhiteSpace(assetKey) || !position.IsValid || !rotation.IsValid || !scale.IsValid + || scale.x <= 0 || scale.y <= 0 || scale.z <= 0) throw new ArgumentException("Invalid decoration key or transform."); + AssetKey = assetKey; + Position = position; + Rotation = rotation; + Scale = scale; + } + } + + public sealed class StageLayout + { + public IReadOnlyList BallPositions { get; } + public IReadOnlyList Decorations { get; } + + public StageLayout(Position3[] ballPositions, DecorationPlacement[] decorations = null) + { + if (ballPositions == null) throw new ArgumentException("Ball layout is required."); + var positions = (Position3[])ballPositions.Clone(); + foreach (var position in positions) if (!position.IsValid) throw new ArgumentException("Layout positions must be finite."); + var placements = decorations == null ? Array.Empty() : (DecorationPlacement[])decorations.Clone(); + foreach (var placement in placements) if (placement == null) throw new ArgumentException("Decoration placements cannot be null."); + BallPositions = Array.AsReadOnly(positions); + Decorations = Array.AsReadOnly(placements); + } + } + public sealed class StageDefinition { public int Id { get; } public StageKind Kind { get; } public IReadOnlyList InitialGroups { get; } public IReadOnlyList TargetGroups { get; } - public IReadOnlyList BallPositions { get; } + public StageLayout Layout { get; } + public IReadOnlyList BallPositions => Layout.BallPositions; public int Total { get; } - public StageDefinition(int id, StageKind kind, int[] initialGroups, int[] targetGroups, Position3[] ballPositions = null) + public StageDefinition(int id, StageKind kind, int[] initialGroups, int[] targetGroups, Position3[] ballPositions = null, + DecorationPlacement[] decorations = null) { if (id < 1 || id > 30 || (kind != StageKind.Counting && kind != StageKind.Composition)) throw new ArgumentException("Invalid stage ID or kind."); @@ -106,7 +143,7 @@ namespace MagnetismHeroes.Core Total = total; InitialGroups = Array.AsReadOnly(initial); TargetGroups = Array.AsReadOnly(target); - BallPositions = Array.AsReadOnly(positions); + Layout = new StageLayout(positions, decorations); } private static int[] CopyGroups(int[] groups) diff --git a/Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs b/Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs new file mode 100644 index 0000000..afbdbe3 --- /dev/null +++ b/Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MagnetismHeroes.Core; +using NUnit.Framework; + +namespace MagnetismHeroes.Tests.EditMode.Core +{ + public sealed class StageLayoutTests + { + private static readonly Rotation4 Identity = new Rotation4(0, 0, 0, 1); + private static readonly Position3 UnitScale = new Position3(1, 1, 1); + + [Test] + public void SameBallPositionsWithDifferentDecorationsRemainDistinctAndDoNotChangeTheMath() + { + var positions = new[] { new Position3(1, 2, 3), new Position3(4, 5, 6) }; + var garden = new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }, positions, + new[] { new DecorationPlacement("garden/arch", new Position3(3, 0, 1), Identity, UnitScale) }); + var cloud = new StageDefinition(7, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }, positions, + new[] { new DecorationPlacement("cloud/platform", new Position3(3, 4, 1), Identity, new Position3(2, 1, 2)) }); + Assert.That(garden.Layout.Decorations.Count, Is.EqualTo(1)); + Assert.That(cloud.Layout.Decorations.Count, Is.EqualTo(1)); + Assert.That(garden.Layout.Decorations[0].AssetKey, Is.EqualTo("garden/arch")); + Assert.That(cloud.Layout.Decorations[0].AssetKey, Is.EqualTo("cloud/platform")); + Assert.That(garden.Layout.BallPositions, Is.EqualTo(cloud.Layout.BallPositions)); + foreach (var definition in new[] { garden, cloud }) + { + var board = new BoardRules(definition); + Assert.That(board.BallCount, Is.EqualTo(2)); + Assert.That(board.IsSolved, Is.False); + Assert.That(board.TryMerge(board.Groups[0].id, board.Groups[1].id).Accepted, Is.True); + Assert.That(board.IsSolved, Is.True); + Assert.That(board.BallCount, Is.EqualTo(2)); + } + } + + [Test] + public void InputArraysAndReturnedTransformCopiesCannotChangeFixedLayout() + { + var positions = new[] { new Position3(1, 2, 3) }; + var decorations = new[] { new DecorationPlacement("workshop/bench", new Position3(4, 5, 6), Identity, UnitScale) }; + var definition = new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 }, positions, decorations); + positions[0] = new Position3(100, 0, 0); + decorations[0] = new DecorationPlacement("changed", new Position3(), Identity, UnitScale); + Assert.That(definition.Layout.Decorations.Count, Is.EqualTo(1)); + Assert.That(definition.BallPositions[0].x, Is.EqualTo(1)); + Assert.That(definition.Layout.Decorations[0].AssetKey, Is.EqualTo("workshop/bench")); + var positionCopy = definition.Layout.Decorations[0].Position; + var rotationCopy = definition.Layout.Decorations[0].Rotation; + var scaleCopy = definition.Layout.Decorations[0].Scale; + positionCopy.x = 100; rotationCopy.w = 0; scaleCopy.x = 100; + Assert.That(definition.Layout.Decorations[0].Position.x, Is.EqualTo(4)); + Assert.That(definition.Layout.Decorations[0].Rotation.w, Is.EqualTo(1)); + Assert.That(definition.Layout.Decorations[0].Scale.x, Is.EqualTo(1)); + Assert.Throws(() => ((IList)definition.Layout.Decorations)[0] = decorations[0]); + Assert.Throws(() => ((IList)definition.Layout.BallPositions)[0] = positions[0]); + } + + [Test] + public void PuzzleAndFreePlaySourceIdsSelectOriginalFixedDecorationLayoutOnRestore() + { + var definition = new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }, + new[] { new Position3(), new Position3(1, 0, 0) }, + new[] { new DecorationPlacement("garden/slide", new Position3(0, 0, 8), Identity, UnitScale) }); + var catalog = new[] { definition }; + var board = new BoardRules(definition); + board.TryMoveGroup(board.Groups[0].id, new Position3(5, 0, 0)); + var puzzle = board.ToPuzzleState(new CameraState()); + var restoredDefinition = catalog.Single(stage => stage.Id == puzzle.stageId); + var restored = new BoardRules(restoredDefinition, puzzle); + Assert.That(restoredDefinition.Layout.Decorations.Count, Is.EqualTo(1)); + Assert.That(restoredDefinition.Layout.Decorations[0].AssetKey, Is.EqualTo("garden/slide")); + Assert.That(restored.Balls[0].position.x, Is.EqualTo(5)); + Assert.That(restoredDefinition.BallPositions[0].x, Is.Zero); + var free = restored.ToFreePlayState(new CameraState()); + var freeDefinition = catalog.Single(stage => stage.Id == free.sourceStageId); + Assert.That(freeDefinition.Layout.Decorations[0].Position.z, Is.EqualTo(8)); + Assert.That(BoardRules.CreateFreePlay(free).BallCount, Is.EqualTo(2)); + } + + [Test] + public void InvalidDecorationTransformsAndMissingEntriesAreRejected() + { + Assert.Throws(() => new DecorationPlacement("", new Position3(), Identity, UnitScale)); + Assert.Throws(() => new DecorationPlacement("arch", new Position3(float.NaN, 0, 0), Identity, UnitScale)); + Assert.Throws(() => new DecorationPlacement("arch", new Position3(), new Rotation4(), UnitScale)); + Assert.Throws(() => new DecorationPlacement("arch", new Position3(), Identity, new Position3(0, 1, 1))); + Assert.Throws(() => new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 }, + decorations: new DecorationPlacement[] { null })); + } + } +} diff --git a/Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs.meta b/Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs.meta new file mode 100644 index 0000000..b6c1585 --- /dev/null +++ b/Assets/MagnetismHeroes/Tests/EditMode/Core/StageLayoutTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d13c7f6ddacc35d40a7a177a7749b18c \ No newline at end of file