added transformation item

This commit is contained in:
2026-10-25 22:08:09 +01:00
parent 0fb223cdee
commit 6184cdba0a
27 changed files with 307 additions and 52 deletions
@@ -3,10 +3,31 @@ namespace ActionBlocks.Game.Core;
/// <summary>
/// A piece definition as it travels through the queue: its shape plus optional items
/// hidden inside its minos. <see cref="Items"/>[i] is the item in mino i (null = none).
/// <see cref="Original"/> is set on a transformed piece and holds the piece it was before.
/// </summary>
public sealed record PieceSpec(PieceType Type, IReadOnlyList<string?>? Items = null)
public sealed record PieceSpec(PieceType Type, IReadOnlyList<string?>? Items = null, PieceSpec? Original = null)
{
public bool HasItem => Items?.Any(i => i is not null) == true;
/// <summary>The same piece as <paramref name="type"/>. Items move to the new minos in order; extras are dropped.</summary>
public PieceSpec Transform(PieceType type)
{
var original = Original ?? this;
var carried = original.Items?.Where(i => i is not null).ToList() ?? [];
string?[]? items = null;
if (carried.Count > 0)
{
items = new string?[PieceDefinitions.Minos(type, 0).Count];
for (var i = 0; i < Math.Min(items.Length, carried.Count); i++)
{
items[i] = carried[i];
}
}
return new PieceSpec(type, items, original);
}
/// <summary>Undoes <see cref="Transform"/>.</summary>
public PieceSpec Restore() => Original ?? this;
}
/// <summary>A piece placed somewhere on the board (position = top-left corner of its bounding box).</summary>
@@ -18,12 +18,23 @@ public static class PieceDefinitions
[PieceType.S] = (3, [new(1, 0), new(2, 0), new(0, 1), new(1, 1)]),
[PieceType.Z] = (3, [new(0, 0), new(1, 0), new(1, 1), new(2, 1)]),
[PieceType.T] = (3, [new(1, 0), new(0, 1), new(1, 1), new(2, 1)]),
[PieceType.Dot] = (1, [new(0, 0)]),
[PieceType.SmallL] = (2, [new(0, 0), new(0, 1), new(1, 1)]),
[PieceType.U] = (3, [new(0, 0), new(2, 0), new(0, 1), new(1, 1), new(2, 1)]),
[PieceType.Plus] = (3, [new(1, 0), new(0, 1), new(1, 1), new(2, 1), new(1, 2)]),
[PieceType.W] = (3, [new(0, 0), new(0, 1), new(1, 1), new(1, 2), new(2, 2)]),
};
private static readonly Dictionary<PieceType, Point[][]> Rotations = BuildRotations();
public static IReadOnlyList<PieceType> All { get; } = Enum.GetValues<PieceType>();
/// <summary>The seven four-cell shapes dealt by the randomizer.</summary>
public static IReadOnlyList<PieceType> Standard { get; } = All.Where(t => t <= PieceType.T).ToArray();
/// <summary>Oddball shapes that only appear while a player is transformed.</summary>
public static IReadOnlyList<PieceType> Odd { get; } = All.Where(t => t > PieceType.T).ToArray();
public static int BoxSize(PieceType type) => SpawnShapes[type].BoxSize;
/// <summary>Mino offsets of a piece in the given rotation state (0 = spawn, 1 = R, 2 = 180, 3 = L).</summary>
@@ -1,6 +1,9 @@
namespace ActionBlocks.Game.Core;
/// <summary>The seven four-cell piece shapes. Numeric values double as cell colors (1-7).</summary>
/// <summary>
/// Piece shapes. Numeric values double as cell colors: 1-7 are the seven four-cell shapes,
/// 9-13 the odd shapes dealt only during a Transformation (8 is garbage).
/// </summary>
public enum PieceType : byte
{
I = 1,
@@ -10,6 +13,11 @@ public enum PieceType : byte
S = 5,
Z = 6,
T = 7,
Dot = 9,
SmallL = 10,
U = 11,
Plus = 12,
W = 13,
}
public static class CellColor
@@ -14,7 +14,7 @@ public sealed class SevenBagRandomizer(Random random) : IRandomizer
{
if (_bag.Count == 0)
{
var pieces = PieceDefinitions.All.ToArray();
var pieces = PieceDefinitions.Standard.ToArray();
random.Shuffle(pieces);
foreach (var p in pieces)
{
@@ -17,6 +17,7 @@ public sealed class ItemRegistry
new ClearBoardItem(),
new SwapBoardItem(),
new InvisibilityItem(),
new TransformationItem(),
]);
public IReadOnlyCollection<IItem> All => _items.Values;
@@ -42,8 +43,6 @@ public sealed class ItemRegistry
/// <summary>Decides which minos of a new piece carry an item. Each mino rolls <c>chance</c> (0..1) on its own.</summary>
public sealed class ItemSpawner(ItemRegistry registry, double chance, Random random)
{
private const int MinosPerPiece = 4;
private readonly double _chance = double.IsNaN(chance) ? 0 : Math.Clamp(chance, 0, 1);
public Core.PieceSpec Decorate(Core.PieceType type)
@@ -52,9 +51,9 @@ public sealed class ItemSpawner(ItemRegistry registry, double chance, Random ran
{
return new Core.PieceSpec(type);
}
var items = new string?[MinosPerPiece];
var items = new string?[Core.PieceDefinitions.Minos(type, 0).Count];
var any = false;
for (var i = 0; i < MinosPerPiece; i++)
for (var i = 0; i < items.Length; i++)
{
if (random.NextDouble() < _chance)
{
@@ -50,3 +50,14 @@ public sealed class InvisibilityItem : IItem
public void Apply(ItemContext context) =>
context.Target.InvisibleUntil = Math.Max(context.Target.InvisibleUntil, context.Now) + DurationSeconds;
}
public sealed class TransformationItem : IItem
{
public const double DurationSeconds = 8;
public string Id => "transform";
public string DisplayName => "Transformation";
// Stacks like Blackout.
public void Apply(ItemContext context) => context.Target.Transform(context.Now, DurationSeconds);
}
@@ -92,6 +92,10 @@ public sealed class PlayerGame
public double InvisibleUntil { get; set; }
public bool IsInvisible => Now < InvisibleUntil;
/// <summary>Match time until which this player's upcoming pieces are odd shapes.</summary>
public double TransformedUntil { get; private set; }
public bool IsTransformed => Now < TransformedUntil;
/// <summary>The player selected as item target (own id = self).</summary>
public string TargetId { get; set; }
@@ -103,7 +107,12 @@ public sealed class PlayerGame
public void Update(double deltaSeconds, double now)
{
var wasTransformed = IsTransformed;
Now = now;
if (wasTransformed && !IsTransformed)
{
RestoreTransformedPieces();
}
if (!IsAlive || Active is null)
{
return;
@@ -280,11 +289,40 @@ public sealed class PlayerGame
public void Forfeit() => TopOut();
/// <summary>
/// Turns the queue into odd shapes for <paramref name="seconds"/> (added to any time left).
/// The falling piece stays as it is; pieces still queued or held change back when the time runs out.
/// </summary>
public void Transform(double now, double seconds)
{
TransformedUntil = Math.Max(TransformedUntil, now) + seconds;
for (var i = 0; i < _next.Count; i++)
{
if (_next[i].Original is null)
{
_next[i] = TransformRandomly(_next[i]);
}
}
}
private PieceSpec TransformRandomly(PieceSpec spec) =>
spec.Transform(PieceDefinitions.Odd[_random.Next(PieceDefinitions.Odd.Count)]);
private void RestoreTransformedPieces()
{
for (var i = 0; i < _next.Count; i++)
{
_next[i] = _next[i].Restore();
}
Hold = Hold?.Restore();
}
private void FillQueue()
{
while (_next.Count < Math.Max(1, _settings.PreviewCount))
{
_next.Add(_itemSpawner.Decorate(_randomizer.Next()));
var spec = _itemSpawner.Decorate(_randomizer.Next());
_next.Add(IsTransformed ? TransformRandomly(spec) : spec);
}
}
@@ -402,7 +440,8 @@ public sealed class PlayerGame
{
return;
}
var current = Active.Spec;
// A piece left over from an expired Transformation changes back once it is put away.
var current = IsTransformed ? Active.Spec : Active.Spec.Restore();
HoldUsed = true;
if (Hold is null)
{
@@ -39,7 +39,8 @@ public sealed record PlayerStateDto(
int Lines,
int Level,
int PendingGarbage,
double InvisibleRemaining);
double InvisibleRemaining,
double TransformedRemaining);
/// <summary>A match event. <see cref="Type"/> is "itemUsed" or "eliminated".</summary>
public sealed record MatchEventDto(
@@ -51,7 +51,8 @@ public static class SnapshotMapper
player.Lines,
player.Level,
player.PendingGarbage,
Math.Round(Math.Max(0, player.InvisibleUntil - match.Now), 2));
Math.Round(Math.Max(0, player.InvisibleUntil - match.Now), 2),
Math.Round(Math.Max(0, player.TransformedUntil - match.Now), 2));
}
private static ActivePieceDto ToDto(ActivePiece piece, Board board) =>
@@ -62,7 +63,7 @@ public static class SnapshotMapper
private static PieceDto ToDto(PieceSpec spec) => new((int)spec.Type, ToItems(spec));
private static string?[] ToItems(PieceSpec spec) => spec.Items?.ToArray() ?? new string?[4];
private static string?[] ToItems(PieceSpec spec) => spec.Items?.ToArray() ?? new string?[PieceDefinitions.Minos(spec.Type, 0).Count];
private static MatchEventDto ToDto(MatchEvent e) => e switch
{
@@ -28,6 +28,24 @@ public class CoreTests
}
}
[Fact]
public void Rotations_keep_the_mino_count()
{
foreach (var type in PieceDefinitions.All)
{
var count = PieceDefinitions.Minos(type, 0).Count;
Assert.All(Enumerable.Range(1, 3), r => Assert.Equal(count, PieceDefinitions.Minos(type, r).Distinct().Count()));
}
}
[Fact]
public void Seven_bag_never_deals_odd_shapes()
{
var randomizer = new SevenBagRandomizer(new Random(42));
Assert.All(Enumerable.Range(0, 70), _ => Assert.Contains(randomizer.Next(), PieceDefinitions.Standard));
}
[Fact]
public void Seven_bag_deals_every_shape_once_per_bag()
{
@@ -121,9 +121,96 @@ public class ItemTests
}
[Fact]
public void Registry_contains_five_unique_items()
public void Transformation_turns_the_queue_into_odd_shapes_but_not_the_falling_piece()
{
Assert.Equal(5, ItemRegistry.Default.All.Select(i => i.Id).Distinct().Count());
var target = CreatePlayer("b", pieces: [PieceType.T, PieceType.O, PieceType.S]);
target.Start();
new TransformationItem().Apply(Context(target, target, now: 10));
Assert.Equal(PieceType.T, target.Active!.Type);
Assert.All(target.Next, p => Assert.Contains(p.Type, PieceDefinitions.Odd));
}
[Fact]
public void Transformation_deals_odd_shapes_while_active()
{
var target = CreatePlayer("b");
target.Start();
new TransformationItem().Apply(Context(target, target));
for (var i = 0; i < 8; i++)
{
target.HandleInput(InputAction.HardDrop);
}
Assert.True(target.IsAlive);
Assert.Contains(target.Active!.Type, PieceDefinitions.Odd);
Assert.All(target.Next, p => Assert.Contains(p.Type, PieceDefinitions.Odd));
}
[Fact]
public void Transformation_expiry_restores_queue_and_hold()
{
var target = CreatePlayer("b", pieces: [PieceType.T, PieceType.O, PieceType.S]);
target.Start();
var queued = target.Next.Select(p => p.Type).ToList();
new TransformationItem().Apply(Context(target, target));
target.HandleInput(InputAction.HardDrop);
target.HandleInput(InputAction.Hold);
Assert.Contains(target.Hold!.Type, PieceDefinitions.Odd);
target.Update(0.01, 7.9);
Assert.True(target.IsTransformed);
target.Update(0.01, 8.1);
Assert.False(target.IsTransformed);
Assert.Equal(queued[0], target.Hold!.Type);
Assert.All(target.Next, p => Assert.Contains(p.Type, PieceDefinitions.Standard));
}
[Fact]
public void Transformation_durations_stack()
{
var target = CreatePlayer("b");
target.Start();
new TransformationItem().Apply(Context(target, target, now: 10));
new TransformationItem().Apply(Context(target, target, now: 12));
target.Update(0.01, 25.9);
Assert.True(target.IsTransformed);
target.Update(0.01, 26.1);
Assert.False(target.IsTransformed);
}
[Fact]
public void Transformed_piece_keeps_its_items_and_restores_them()
{
var spec = new PieceSpec(PieceType.T, [null, "nuke", null, "swap"]);
var plus = spec.Transform(PieceType.Plus);
var dot = plus.Transform(PieceType.Dot);
Assert.Equal(["nuke", "swap", null, null, null], plus.Items);
Assert.Equal(["nuke"], dot.Items);
Assert.Same(spec, dot.Restore());
}
[Fact]
public void Registry_contains_six_unique_items()
{
Assert.Equal(6, ItemRegistry.Default.All.Select(i => i.Id).Distinct().Count());
}
[Fact]
public void Spawner_sizes_items_to_the_shape()
{
var spawner = new ItemSpawner(ItemRegistry.Default, 1, new Random(1));
Assert.Equal(5, spawner.Decorate(PieceType.Plus).Items!.Count);
Assert.Single(spawner.Decorate(PieceType.Dot).Items!);
}
[Theory]