Preserve immutable decoration layouts in stage definitions

This commit is contained in:
NINI
2026-10-05 00:45:54 +09:00
parent 92243fccb6
commit 8593a35a59
3 changed files with 135 additions and 3 deletions
+40 -3
View File
@@ -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<Position3> BallPositions { get; }
public IReadOnlyList<DecorationPlacement> 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>() : (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<int> InitialGroups { get; }
public IReadOnlyList<int> TargetGroups { get; }
public IReadOnlyList<Position3> BallPositions { get; }
public StageLayout Layout { get; }
public IReadOnlyList<Position3> 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)
@@ -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<NotSupportedException>(() => ((IList<DecorationPlacement>)definition.Layout.Decorations)[0] = decorations[0]);
Assert.Throws<NotSupportedException>(() => ((IList<Position3>)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<ArgumentException>(() => new DecorationPlacement("", new Position3(), Identity, UnitScale));
Assert.Throws<ArgumentException>(() => new DecorationPlacement("arch", new Position3(float.NaN, 0, 0), Identity, UnitScale));
Assert.Throws<ArgumentException>(() => new DecorationPlacement("arch", new Position3(), new Rotation4(), UnitScale));
Assert.Throws<ArgumentException>(() => new DecorationPlacement("arch", new Position3(), Identity, new Position3(0, 1, 1)));
Assert.Throws<ArgumentException>(() => new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 },
decorations: new DecorationPlacement[] { null }));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d13c7f6ddacc35d40a7a177a7749b18c