added transformation item
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user