added transformation item
This commit is contained in:
@@ -3,10 +3,31 @@ namespace ActionBlocks.Game.Core;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// A piece definition as it travels through the queue: its shape plus optional items
|
/// 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).
|
/// 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>
|
/// </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;
|
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>
|
/// <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.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.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.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();
|
private static readonly Dictionary<PieceType, Point[][]> Rotations = BuildRotations();
|
||||||
|
|
||||||
public static IReadOnlyList<PieceType> All { get; } = Enum.GetValues<PieceType>();
|
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;
|
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>
|
/// <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;
|
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
|
public enum PieceType : byte
|
||||||
{
|
{
|
||||||
I = 1,
|
I = 1,
|
||||||
@@ -10,6 +13,11 @@ public enum PieceType : byte
|
|||||||
S = 5,
|
S = 5,
|
||||||
Z = 6,
|
Z = 6,
|
||||||
T = 7,
|
T = 7,
|
||||||
|
Dot = 9,
|
||||||
|
SmallL = 10,
|
||||||
|
U = 11,
|
||||||
|
Plus = 12,
|
||||||
|
W = 13,
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class CellColor
|
public static class CellColor
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public sealed class SevenBagRandomizer(Random random) : IRandomizer
|
|||||||
{
|
{
|
||||||
if (_bag.Count == 0)
|
if (_bag.Count == 0)
|
||||||
{
|
{
|
||||||
var pieces = PieceDefinitions.All.ToArray();
|
var pieces = PieceDefinitions.Standard.ToArray();
|
||||||
random.Shuffle(pieces);
|
random.Shuffle(pieces);
|
||||||
foreach (var p in pieces)
|
foreach (var p in pieces)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed class ItemRegistry
|
|||||||
new ClearBoardItem(),
|
new ClearBoardItem(),
|
||||||
new SwapBoardItem(),
|
new SwapBoardItem(),
|
||||||
new InvisibilityItem(),
|
new InvisibilityItem(),
|
||||||
|
new TransformationItem(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
public IReadOnlyCollection<IItem> All => _items.Values;
|
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>
|
/// <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)
|
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);
|
private readonly double _chance = double.IsNaN(chance) ? 0 : Math.Clamp(chance, 0, 1);
|
||||||
|
|
||||||
public Core.PieceSpec Decorate(Core.PieceType type)
|
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);
|
return new Core.PieceSpec(type);
|
||||||
}
|
}
|
||||||
var items = new string?[MinosPerPiece];
|
var items = new string?[Core.PieceDefinitions.Minos(type, 0).Count];
|
||||||
var any = false;
|
var any = false;
|
||||||
for (var i = 0; i < MinosPerPiece; i++)
|
for (var i = 0; i < items.Length; i++)
|
||||||
{
|
{
|
||||||
if (random.NextDouble() < _chance)
|
if (random.NextDouble() < _chance)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -50,3 +50,14 @@ public sealed class InvisibilityItem : IItem
|
|||||||
public void Apply(ItemContext context) =>
|
public void Apply(ItemContext context) =>
|
||||||
context.Target.InvisibleUntil = Math.Max(context.Target.InvisibleUntil, context.Now) + DurationSeconds;
|
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 double InvisibleUntil { get; set; }
|
||||||
public bool IsInvisible => Now < InvisibleUntil;
|
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>
|
/// <summary>The player selected as item target (own id = self).</summary>
|
||||||
public string TargetId { get; set; }
|
public string TargetId { get; set; }
|
||||||
|
|
||||||
@@ -103,7 +107,12 @@ public sealed class PlayerGame
|
|||||||
|
|
||||||
public void Update(double deltaSeconds, double now)
|
public void Update(double deltaSeconds, double now)
|
||||||
{
|
{
|
||||||
|
var wasTransformed = IsTransformed;
|
||||||
Now = now;
|
Now = now;
|
||||||
|
if (wasTransformed && !IsTransformed)
|
||||||
|
{
|
||||||
|
RestoreTransformedPieces();
|
||||||
|
}
|
||||||
if (!IsAlive || Active is null)
|
if (!IsAlive || Active is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -280,11 +289,40 @@ public sealed class PlayerGame
|
|||||||
|
|
||||||
public void Forfeit() => TopOut();
|
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()
|
private void FillQueue()
|
||||||
{
|
{
|
||||||
while (_next.Count < Math.Max(1, _settings.PreviewCount))
|
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;
|
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;
|
HoldUsed = true;
|
||||||
if (Hold is null)
|
if (Hold is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ public sealed record PlayerStateDto(
|
|||||||
int Lines,
|
int Lines,
|
||||||
int Level,
|
int Level,
|
||||||
int PendingGarbage,
|
int PendingGarbage,
|
||||||
double InvisibleRemaining);
|
double InvisibleRemaining,
|
||||||
|
double TransformedRemaining);
|
||||||
|
|
||||||
/// <summary>A match event. <see cref="Type"/> is "itemUsed" or "eliminated".</summary>
|
/// <summary>A match event. <see cref="Type"/> is "itemUsed" or "eliminated".</summary>
|
||||||
public sealed record MatchEventDto(
|
public sealed record MatchEventDto(
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ public static class SnapshotMapper
|
|||||||
player.Lines,
|
player.Lines,
|
||||||
player.Level,
|
player.Level,
|
||||||
player.PendingGarbage,
|
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) =>
|
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 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
|
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]
|
[Fact]
|
||||||
public void Seven_bag_deals_every_shape_once_per_bag()
|
public void Seven_bag_deals_every_shape_once_per_bag()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -121,9 +121,96 @@ public class ItemTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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]
|
[Theory]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="3" y="1" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="1" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="2" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="2" width="1" height="1" fill="#ff6fd8"/><rect x="1" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="2" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="5" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="6" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="1" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="2" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="5" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="6" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="5" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="5" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="6" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="6" width="1" height="1" fill="#ff6fd8"/><rect x="0" y="0" width="1" height="1" fill="#ffffff"/><rect x="7" y="0" width="1" height="1" fill="#ffffff"/><rect x="0" y="7" width="1" height="1" fill="#ffffff"/><rect x="7" y="7" width="1" height="1" fill="#ffffff"/><rect x="1" y="1" width="1" height="1" fill="#ffffff"/><rect x="6" y="6" width="1" height="1" fill="#ffffff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,18 +0,0 @@
|
|||||||
import { itemVisual } from '../theme/assets';
|
|
||||||
import styles from './BlackoutTimer.module.css';
|
|
||||||
|
|
||||||
/** Seconds left on a player's blackout, in the corner of their board. */
|
|
||||||
export function BlackoutTimer({ remaining, small = false }: { remaining: number; small?: boolean }) {
|
|
||||||
if (remaining <= 0) return null;
|
|
||||||
const visual = itemVisual('blind');
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={[styles.timer, small ? styles.small : '', remaining < 1 ? styles.ending : ''].join(' ')}
|
|
||||||
style={{ color: visual.color, borderColor: visual.color }}
|
|
||||||
title={visual.name}
|
|
||||||
>
|
|
||||||
<img className={styles.icon} src={visual.icon} alt="" />
|
|
||||||
{remaining.toFixed(1)}s
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+15
-4
@@ -1,7 +1,16 @@
|
|||||||
.timer {
|
.timers {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
right: 4px;
|
right: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -10,8 +19,6 @@
|
|||||||
background: #000;
|
background: #000;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
pointer-events: none;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
@@ -23,6 +30,10 @@
|
|||||||
.small {
|
.small {
|
||||||
top: 2px;
|
top: 2px;
|
||||||
right: 2px;
|
right: 2px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small .timer {
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
padding: 2px 3px;
|
padding: 2px 3px;
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
@@ -34,7 +45,7 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blink during the last second so the return of the board isn't a surprise. */
|
/* Blink during the last second so the end of the effect isn't a surprise. */
|
||||||
.ending {
|
.ending {
|
||||||
animation: blink 0.25s steps(2) infinite;
|
animation: blink 0.25s steps(2) infinite;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { itemVisual } from '../theme/assets';
|
||||||
|
import styles from './EffectTimers.module.css';
|
||||||
|
|
||||||
|
/** A timed item effect on a player: the item id and the seconds it has left. */
|
||||||
|
export type Effect = [itemId: string, remaining: number];
|
||||||
|
|
||||||
|
/** Seconds left on a player's timed item effects (Blackout, Transformation), in the corner of their board. */
|
||||||
|
export function EffectTimers({ effects, small = false }: { effects: Effect[]; small?: boolean }) {
|
||||||
|
const active = effects.filter(([, remaining]) => remaining > 0);
|
||||||
|
if (active.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className={[styles.timers, small ? styles.small : ''].join(' ')}>
|
||||||
|
{active.map(([itemId, remaining]) => {
|
||||||
|
const visual = itemVisual(itemId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={itemId}
|
||||||
|
className={[styles.timer, remaining < 1 ? styles.ending : ''].join(' ')}
|
||||||
|
style={{ color: visual.color, borderColor: visual.color }}
|
||||||
|
title={visual.name}
|
||||||
|
>
|
||||||
|
<img className={styles.icon} src={visual.icon} alt="" />
|
||||||
|
{remaining.toFixed(1)}s
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import type { PlayerState } from '../net/types';
|
import type { PlayerState } from '../net/types';
|
||||||
import type { ItemFlash } from '../net/useGameConnection';
|
import type { ItemFlash } from '../net/useGameConnection';
|
||||||
import { BlackoutTimer } from './BlackoutTimer';
|
import { EffectTimers } from './EffectTimers';
|
||||||
import { BoardCanvas } from './BoardCanvas';
|
import { BoardCanvas } from './BoardCanvas';
|
||||||
import { ItemBar } from './ItemBar';
|
import { ItemBar } from './ItemBar';
|
||||||
import { ItemFlashOverlay } from './ItemFlashOverlay';
|
import { ItemFlashOverlay } from './ItemFlashOverlay';
|
||||||
@@ -30,7 +30,15 @@ export const MiniBoard = memo(function MiniBoard({ state, width, height, slot, s
|
|||||||
</div>
|
</div>
|
||||||
<BoardCanvas state={state} width={width} height={height} cell={cell} highlighted={selected}>
|
<BoardCanvas state={state} width={width} height={height} cell={cell} highlighted={selected}>
|
||||||
<ItemFlashOverlay flashes={flashes} small />
|
<ItemFlashOverlay flashes={flashes} small />
|
||||||
<BlackoutTimer remaining={state.alive ? state.invisibleRemaining : 0} small />
|
{state.alive && (
|
||||||
|
<EffectTimers
|
||||||
|
effects={[
|
||||||
|
['blind', state.invisibleRemaining],
|
||||||
|
['transform', state.transformedRemaining],
|
||||||
|
]}
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</BoardCanvas>
|
</BoardCanvas>
|
||||||
<div className={styles.items} style={{ width: width * cell + 6 }}>
|
<div className={styles.items} style={{ width: width * cell + 6 }}>
|
||||||
<ItemBar items={state.inventory} size={Math.max(10, cell)} />
|
<ItemBar items={state.inventory} size={Math.max(10, cell)} />
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ export interface PlayerState {
|
|||||||
name: string;
|
name: string;
|
||||||
alive: boolean;
|
alive: boolean;
|
||||||
place: number | null;
|
place: number | null;
|
||||||
/** One character per visible cell, row by row: '0' empty, '1'-'7' piece colors, '8' garbage. */
|
/** One character per visible cell, row by row: '0' empty, '1'-'7' piece colors, '8' garbage,
|
||||||
|
* '9'-'=' (values 9-13) odd shapes from Transformation. Decode with charCodeAt - 48. */
|
||||||
board: string;
|
board: string;
|
||||||
items: ItemCell[];
|
items: ItemCell[];
|
||||||
active: ActivePieceState | null;
|
active: ActivePieceState | null;
|
||||||
@@ -66,6 +67,7 @@ export interface PlayerState {
|
|||||||
level: number;
|
level: number;
|
||||||
pendingGarbage: number;
|
pendingGarbage: number;
|
||||||
invisibleRemaining: number;
|
invisibleRemaining: number;
|
||||||
|
transformedRemaining: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MatchEvent {
|
export interface MatchEvent {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useCurrentTrack, useSound } from '../audio/SoundContext';
|
import { useCurrentTrack, useSound } from '../audio/SoundContext';
|
||||||
import { useGameSounds } from '../audio/useGameSounds';
|
import { useGameSounds } from '../audio/useGameSounds';
|
||||||
import { BlackoutTimer } from '../components/BlackoutTimer';
|
import { EffectTimers } from '../components/EffectTimers';
|
||||||
import { BoardCanvas } from '../components/BoardCanvas';
|
import { BoardCanvas } from '../components/BoardCanvas';
|
||||||
import { ItemBar } from '../components/ItemBar';
|
import { ItemBar } from '../components/ItemBar';
|
||||||
import { ItemFlashOverlay } from '../components/ItemFlashOverlay';
|
import { ItemFlashOverlay } from '../components/ItemFlashOverlay';
|
||||||
@@ -130,7 +130,12 @@ export function GamePage({ match, lobby, playerId, flashes, actions }: Props) {
|
|||||||
highlighted={targetId === playerId}
|
highlighted={targetId === playerId}
|
||||||
>
|
>
|
||||||
<ItemFlashOverlay flashes={flashesFor(me.id)} />
|
<ItemFlashOverlay flashes={flashesFor(me.id)} />
|
||||||
<BlackoutTimer remaining={me.invisibleRemaining} />
|
<EffectTimers
|
||||||
|
effects={[
|
||||||
|
['blind', me.invisibleRemaining],
|
||||||
|
['transform', me.transformedRemaining],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
{match.phase === 'countdown' && (
|
{match.phase === 'countdown' && (
|
||||||
<div className={styles.overlay}>
|
<div className={styles.overlay}>
|
||||||
<span className={styles.countdown}>{Math.ceil(match.countdown)}</span>
|
<span className={styles.countdown}>{Math.ceil(match.countdown)}</span>
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ export function drawPreviewPiece(
|
|||||||
if (!shape) return;
|
if (!shape) return;
|
||||||
const w = Math.max(...shape.map(([x]) => x)) + 1;
|
const w = Math.max(...shape.map(([x]) => x)) + 1;
|
||||||
const h = Math.max(...shape.map(([, y]) => y)) + 1;
|
const h = Math.max(...shape.map(([, y]) => y)) + 1;
|
||||||
|
// Three-row shapes (Transformation) are shrunk to fit the preview box.
|
||||||
|
cell = Math.min(cell, Math.floor(boxHeight / (h + 0.5)));
|
||||||
const ox = offsetX + (boxWidth - w * cell) / 2;
|
const ox = offsetX + (boxWidth - w * cell) / 2;
|
||||||
const oy = offsetY + (boxHeight - h * cell) / 2;
|
const oy = offsetY + (boxHeight - h * cell) / 2;
|
||||||
const color = dimmed ? '#555555' : (blockColors[piece.type] ?? '#fff');
|
const color = dimmed ? '#555555' : (blockColors[piece.type] ?? '#fff');
|
||||||
|
|||||||
@@ -7,4 +7,10 @@ export const pieceShapes: Record<number, [number, number][]> = {
|
|||||||
5: [[1, 0], [2, 0], [0, 1], [1, 1]],
|
5: [[1, 0], [2, 0], [0, 1], [1, 1]],
|
||||||
6: [[0, 0], [1, 0], [1, 1], [2, 1]],
|
6: [[0, 0], [1, 0], [1, 1], [2, 1]],
|
||||||
7: [[1, 0], [0, 1], [1, 1], [2, 1]],
|
7: [[1, 0], [0, 1], [1, 1], [2, 1]],
|
||||||
|
// Odd shapes, only dealt during a Transformation.
|
||||||
|
9: [[0, 0]],
|
||||||
|
10: [[0, 0], [0, 1], [1, 1]],
|
||||||
|
11: [[0, 0], [2, 0], [0, 1], [1, 1], [2, 1]],
|
||||||
|
12: [[1, 0], [0, 1], [1, 1], [2, 1], [1, 2]],
|
||||||
|
13: [[0, 0], [0, 1], [1, 1], [1, 2], [2, 2]],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export const itemVisuals: Record<string, ItemVisual> = {
|
|||||||
color: '#c452ff',
|
color: '#c452ff',
|
||||||
description: 'Hides placed blocks and the drop shadow for 4 seconds. Stacks.',
|
description: 'Hides placed blocks and the drop shadow for 4 seconds. Stacks.',
|
||||||
},
|
},
|
||||||
|
transform: {
|
||||||
|
icon: asset('assets/items/transform.svg'),
|
||||||
|
name: 'Transformation',
|
||||||
|
color: '#ff6fd8',
|
||||||
|
description: "Turns the target's next pieces into odd shapes for 8 seconds. Stacks.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unknownItem: ItemVisual = {
|
export const unknownItem: ItemVisual = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Block colors by cell value (see PlayerState.board). Swap these to restyle the blocks.
|
* Block colors by cell value (see PlayerState.board). Swap these to restyle the blocks.
|
||||||
* Index 0 is the empty cell, 8 is garbage.
|
* Index 0 is the empty cell, 1-7 the standard pieces, 8 garbage, 9-13 the Transformation shapes.
|
||||||
*/
|
*/
|
||||||
export const blockColors: Record<number, string> = {
|
export const blockColors: Record<number, string> = {
|
||||||
1: '#3fe3f2',
|
1: '#3fe3f2',
|
||||||
@@ -11,6 +11,11 @@ export const blockColors: Record<number, string> = {
|
|||||||
6: '#ff4256',
|
6: '#ff4256',
|
||||||
7: '#c452ff',
|
7: '#c452ff',
|
||||||
8: '#8c8c8c',
|
8: '#8c8c8c',
|
||||||
|
9: '#ff6fd8',
|
||||||
|
10: '#2fd6a3',
|
||||||
|
11: '#b8ff3f',
|
||||||
|
12: '#f2f2f2',
|
||||||
|
13: '#ff7a3a',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const boardColors = {
|
export const boardColors = {
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ That gives items on almost every piece and a near-instant start, which is useful
|
|||||||
|
|
||||||
**Server**
|
**Server**
|
||||||
|
|
||||||
- File-scoped namespaces, nullable reference types enabled, one main type per file. Small closely related types may share a file, e.g. all five items in `Items.cs`.
|
- File-scoped namespaces, nullable reference types enabled, one main type per file. Small closely related types may share a file, e.g. all items in `Items.cs`.
|
||||||
- `ActionBlocks.Game` must stay free of ASP.NET or serialization dependencies. Mapping to the wire format happens in `ActionBlocks.Server/Dtos`.
|
- `ActionBlocks.Game` must stay free of ASP.NET or serialization dependencies. Mapping to the wire format happens in `ActionBlocks.Server/Dtos`.
|
||||||
- Game objects are not thread-safe. Only touch them inside `lock (lobby.Sync)`.
|
- Game objects are not thread-safe. Only touch them inside `lock (lobby.Sync)`.
|
||||||
- Prefer adding a value to `GameSettings`, or an implementation of an existing interface, over hard-coding numbers in logic.
|
- Prefer adding a value to `GameSettings`, or an implementation of an existing interface, over hard-coding numbers in logic.
|
||||||
|
|||||||
+4
-4
@@ -38,7 +38,7 @@ public sealed class SpeedUpItem : IItem
|
|||||||
|
|
||||||
### 2. Server: add any new state the item needs
|
### 2. Server: add any new state the item needs
|
||||||
|
|
||||||
If existing `PlayerGame` methods (`InsertGarbageNow`, `RemoveBottomLine`, `ClearBoard`, `SwapBoards`, `InvisibleUntil`) aren't enough, add a property or method to `PlayerGame`. For the example:
|
If existing `PlayerGame` methods (`InsertGarbageNow`, `RemoveBottomLine`, `ClearBoard`, `SwapBoards`, `InvisibleUntil`, `Transform`) aren't enough, add a property or method to `PlayerGame`. For the example:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public double FastUntil { get; set; }
|
public double FastUntil { get; set; }
|
||||||
@@ -62,7 +62,7 @@ public static ItemRegistry Default { get; } = new(
|
|||||||
|
|
||||||
### 4. Server: test it
|
### 4. Server: test it
|
||||||
|
|
||||||
Add a test to `tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs`, and update `Registry_contains_five_unique_items` to the new count.
|
Add a test to `tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs`, and update `Registry_contains_six_unique_items` to the new count.
|
||||||
|
|
||||||
### 5. Website: add the visuals
|
### 5. Website: add the visuals
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ The id key must match `IItem.Id`. Unknown ids still work but show `unknown.svg`.
|
|||||||
|
|
||||||
### 6. Website: show the effect (only if needed)
|
### 6. Website: show the effect (only if needed)
|
||||||
|
|
||||||
If the item needs a visual effect, as Blackout does, expose the state in the snapshot (see [below](#add-new-state-to-the-snapshot)) and use it in `GamePage` / `draw.ts`.
|
If the item needs a visual effect, as Blackout does, expose the state in the snapshot (see [below](#add-new-state-to-the-snapshot)) and use it in `GamePage` / `draw.ts`. For a timed effect, add an entry to the `effects` list of `EffectTimers` in `GamePage` and `MiniBoard` to show a countdown.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ Keep snapshots small. They are sent ~30 times per second for every player to eve
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Item icon | Replace the file in `public/assets/items/`, or point `itemVisuals[id].icon` elsewhere |
|
| Item icon | Replace the file in `public/assets/items/`, or point `itemVisuals[id].icon` elsewhere |
|
||||||
| Logo | Put an image in `public/assets/`, set `uiAssets.logo: asset('assets/logo.png')` |
|
| Logo | Put an image in `public/assets/`, set `uiAssets.logo: asset('assets/logo.png')` |
|
||||||
| Block colors | `src/theme/colors.ts` (`blockColors[1..7]` pieces, `[8]` garbage) |
|
| Block colors | `src/theme/colors.ts` (`blockColors[1..7]` pieces, `[8]` garbage, `[9..13]` Transformation shapes) |
|
||||||
| Block style (bevel, texture, …) | `drawBlock` in `src/render/draw.ts` |
|
| Block style (bevel, texture, …) | `drawBlock` in `src/render/draw.ts` |
|
||||||
| Ghost look | `drawGhostCell` in `draw.ts`, `boardColors.ghostAlpha` |
|
| Ghost look | `drawGhostCell` in `draw.ts`, `boardColors.ghostAlpha` |
|
||||||
| UI colors, borders, font | CSS variables in `src/theme/global.css` |
|
| UI colors, borders, font | CSS variables in `src/theme/global.css` |
|
||||||
|
|||||||
+1
-1
@@ -93,7 +93,7 @@ Layout: **hold + stats + "1 YOU"**, then the **own board** with the garbage mete
|
|||||||
- If the target dies, an effect resets it to yourself, which mirrors the server rule. The selected board gets a cyan frame, and its slot number is filled cyan.
|
- If the target dies, an effect resets it to yourself, which mirrors the server rule. The selected board gets a cyan frame, and its slot number is filled cyan.
|
||||||
- **Leave.** The Leave button in the corner calls `leaveLobby`. While you are still alive in a match, leaving gives up that match, so the first click only turns the button into a red `SURE?` for 3 s and a second click leaves. After you are out, or once the match has finished, one click is enough. No browser dialog is used, because dialogs block the page.
|
- **Leave.** The Leave button in the corner calls `leaveLobby`. While you are still alive in a match, leaving gives up that match, so the first click only turns the button into a red `SURE?` for 3 s and a second click leaves. After you are out, or once the match has finished, one click is enough. No browser dialog is used, because dialogs block the page.
|
||||||
- **Overlays on the own board:** `ItemFlashOverlay` (flashes whose `targetId` is you), the countdown number, and "YOU WIN!" or "<name> WINS".
|
- **Overlays on the own board:** `ItemFlashOverlay` (flashes whose `targetId` is you), the countdown number, and "YOU WIN!" or "<name> WINS".
|
||||||
- **Blackout.** `hideStack={me.invisibleRemaining > 0}` makes `drawBoard` skip locked cells, item icons and the ghost.
|
- **Blackout.** `hideStack={me.invisibleRemaining > 0}` makes `drawBoard` skip locked cells, item icons and the ghost. `EffectTimers` shows the seconds left on Blackout and Transformation in the board corner.
|
||||||
- **Garbage meter.** A red bar whose height is `pendingGarbage × cell`.
|
- **Garbage meter.** A red bar whose height is `pendingGarbage × cell`.
|
||||||
- **Inventory bar.** 10 slots, with the oldest item outlined, plus the hint line `F: <next item> > <target>`.
|
- **Inventory bar.** 10 slots, with the oldest item outlined, plus the hint line `F: <next item> > <target>`.
|
||||||
- **Mini boards.** `MiniBoard` is memoized. It shows the slot number, the name, the board, a flash overlay and the opponent's **item bar beneath the board**.
|
- **Mini boards.** `MiniBoard` is memoized. It shows the slot number, the name, the board, a flash overlay and the opponent's **item bar beneath the board**.
|
||||||
|
|||||||
+3
-2
@@ -28,7 +28,7 @@ Pieces spawn in the two buffer rows directly above the visible field. They are h
|
|||||||
## Pieces and randomizer
|
## Pieces and randomizer
|
||||||
|
|
||||||
- There are 7 shapes. Each has 4 rotation states (0 = spawn, 1 = right, 2 = 180°, 3 = left). States are derived by rotating the spawn shape inside its bounding box (`Core/PieceDefinitions.cs`).
|
- There are 7 shapes. Each has 4 rotation states (0 = spawn, 1 = right, 2 = 180°, 3 = left). States are derived by rotating the spawn shape inside its bounding box (`Core/PieceDefinitions.cs`).
|
||||||
- The **bag randomizer** shuffles all 7 shapes into a bag and deals them one by one, then refills (`Core/Randomizer.cs`). This guarantees every shape appears exactly once in every 7 pieces.
|
- The **bag randomizer** shuffles all 7 shapes into a bag and deals them one by one, then refills (`Core/Randomizer.cs`). This guarantees every shape appears exactly once in every 7 pieces. The odd Transformation shapes are never in the bag.
|
||||||
|
|
||||||
## Rotation (SRS)
|
## Rotation (SRS)
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ A player is eliminated when any of these happens:
|
|||||||
- Everyone sees every opponent's inventory in the item bar beneath their mini board.
|
- Everyone sees every opponent's inventory in the item bar beneath their mini board.
|
||||||
- Every client is told when an item hits someone (`ItemUsedEvent`): which player was hit and which item, but not who used it. The item name flashes on top of the hit player's board. That is the big board for yourself, or the mini board for an opponent.
|
- Every client is told when an item hits someone (`ItemUsedEvent`): which player was hit and which item, but not who used it. The item name flashes on top of the hit player's board. That is the big board for yourself, or the mini board for an opponent.
|
||||||
|
|
||||||
### The five items
|
### The six items
|
||||||
|
|
||||||
| Id | Name | Effect | Class |
|
| Id | Name | Effect | Class |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -142,6 +142,7 @@ A player is eliminated when any of these happens:
|
|||||||
| `nuke` | Wipe Out | Removes **all** locked blocks of the target. The active piece is unaffected. | `ClearBoardItem` |
|
| `nuke` | Wipe Out | Removes **all** locked blocks of the target. The active piece is unaffected. | `ClearBoardItem` |
|
||||||
| `swap` | Switch | The user and the target exchange all locked blocks. Active pieces, hold, queues, inventories and pending garbage stay with their owners. If an active piece now overlaps, it is pushed up. Does nothing when used on yourself. | `SwapBoardItem` |
|
| `swap` | Switch | The user and the target exchange all locked blocks. Active pieces, hold, queues, inventories and pending garbage stay with their owners. If an active piece now overlaps, it is pushed up. Does nothing when used on yourself. | `SwapBoardItem` |
|
||||||
| `blind` | Blackout | For 4 seconds, the target cannot see their locked blocks or their ghost piece. Only the falling piece stays visible. Durations stack: each further Blackout adds 4 s to the time that is left. | `InvisibilityItem` |
|
| `blind` | Blackout | For 4 seconds, the target cannot see their locked blocks or their ghost piece. Only the falling piece stays visible. Durations stack: each further Blackout adds 4 s to the time that is left. | `InvisibilityItem` |
|
||||||
|
| `transform` | Transformation | For 8 seconds, the target's upcoming pieces become odd shapes: Dot, small L, U, Plus or W (random). The whole next queue changes at once; the falling piece does not. Items inside a piece move to the new shape. When the time runs out, pieces still in the queue or hold change back to what they were. Durations stack like Blackout. | `TransformationItem` |
|
||||||
|
|
||||||
Every item can also be used on yourself: target `1`. *Wipe Out* and *Clear Line* are useful defensively.
|
Every item can also be used on yourself: target `1`. *Wipe Out* and *Clear Line* are useful defensively.
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -143,11 +143,12 @@ sequenceDiagram
|
|||||||
"lines": 12,
|
"lines": 12,
|
||||||
"level": 2,
|
"level": 2,
|
||||||
"pendingGarbage": 3, // incoming lines waiting to rise
|
"pendingGarbage": 3, // incoming lines waiting to rise
|
||||||
"invisibleRemaining": 0 // seconds of Blackout left (> 0 = hide stack + ghost)
|
"invisibleRemaining": 0, // seconds of Blackout left (> 0 = hide stack + ghost)
|
||||||
|
"transformedRemaining": 0 // seconds of Transformation left (queue shows odd shapes)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Board encoding.** One character per cell: `'0'` is empty, `'1'`–`'7'` are piece colors (I, J, L, O, S, Z, T), `'8'` is garbage. Index = `y * width + x`, where y = 0 is the **top visible row**.
|
**Board encoding.** One character per cell: `'0'` is empty, `'1'`–`'7'` are piece colors (I, J, L, O, S, Z, T), `'8'` is garbage, and `'9'`, `':'`, `';'`, `'<'`, `'='` (values 9–13) are the Transformation shapes (Dot, SmallL, U, Plus, W). Decode with `charCode - 48`. Index = `y * width + x`, where y = 0 is the **top visible row**.
|
||||||
|
|
||||||
**Coordinates.** All `x, y` values are in visible coordinates. Active-piece cells can have **negative y** while the piece is still above the visible area. Clients skip those cells when drawing.
|
**Coordinates.** All `x, y` values are in visible coordinates. Active-piece cells can have **negative y** while the piece is still above the visible area. Clients skip those cells when drawing.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user