diff --git a/actionBlocks_server/src/ActionBlocks.Game/Core/Piece.cs b/actionBlocks_server/src/ActionBlocks.Game/Core/Piece.cs index d55308b..4b45531 100644 --- a/actionBlocks_server/src/ActionBlocks.Game/Core/Piece.cs +++ b/actionBlocks_server/src/ActionBlocks.Game/Core/Piece.cs @@ -3,10 +3,31 @@ namespace ActionBlocks.Game.Core; /// /// A piece definition as it travels through the queue: its shape plus optional items /// hidden inside its minos. [i] is the item in mino i (null = none). +/// is set on a transformed piece and holds the piece it was before. /// -public sealed record PieceSpec(PieceType Type, IReadOnlyList? Items = null) +public sealed record PieceSpec(PieceType Type, IReadOnlyList? Items = null, PieceSpec? Original = null) { public bool HasItem => Items?.Any(i => i is not null) == true; + + /// The same piece as . Items move to the new minos in order; extras are dropped. + 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); + } + + /// Undoes . + public PieceSpec Restore() => Original ?? this; } /// A piece placed somewhere on the board (position = top-left corner of its bounding box). diff --git a/actionBlocks_server/src/ActionBlocks.Game/Core/PieceDefinitions.cs b/actionBlocks_server/src/ActionBlocks.Game/Core/PieceDefinitions.cs index 3930d0e..624367a 100644 --- a/actionBlocks_server/src/ActionBlocks.Game/Core/PieceDefinitions.cs +++ b/actionBlocks_server/src/ActionBlocks.Game/Core/PieceDefinitions.cs @@ -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 Rotations = BuildRotations(); public static IReadOnlyList All { get; } = Enum.GetValues(); + /// The seven four-cell shapes dealt by the randomizer. + public static IReadOnlyList Standard { get; } = All.Where(t => t <= PieceType.T).ToArray(); + + /// Oddball shapes that only appear while a player is transformed. + public static IReadOnlyList Odd { get; } = All.Where(t => t > PieceType.T).ToArray(); + public static int BoxSize(PieceType type) => SpawnShapes[type].BoxSize; /// Mino offsets of a piece in the given rotation state (0 = spawn, 1 = R, 2 = 180, 3 = L). diff --git a/actionBlocks_server/src/ActionBlocks.Game/Core/PieceType.cs b/actionBlocks_server/src/ActionBlocks.Game/Core/PieceType.cs index 8e07dd4..565245b 100644 --- a/actionBlocks_server/src/ActionBlocks.Game/Core/PieceType.cs +++ b/actionBlocks_server/src/ActionBlocks.Game/Core/PieceType.cs @@ -1,6 +1,9 @@ namespace ActionBlocks.Game.Core; -/// The seven four-cell piece shapes. Numeric values double as cell colors (1-7). +/// +/// 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). +/// 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 diff --git a/actionBlocks_server/src/ActionBlocks.Game/Core/Randomizer.cs b/actionBlocks_server/src/ActionBlocks.Game/Core/Randomizer.cs index 44aa8ae..20d95bb 100644 --- a/actionBlocks_server/src/ActionBlocks.Game/Core/Randomizer.cs +++ b/actionBlocks_server/src/ActionBlocks.Game/Core/Randomizer.cs @@ -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) { diff --git a/actionBlocks_server/src/ActionBlocks.Game/Items/ItemRegistry.cs b/actionBlocks_server/src/ActionBlocks.Game/Items/ItemRegistry.cs index 40057bb..371cb33 100644 --- a/actionBlocks_server/src/ActionBlocks.Game/Items/ItemRegistry.cs +++ b/actionBlocks_server/src/ActionBlocks.Game/Items/ItemRegistry.cs @@ -17,6 +17,7 @@ public sealed class ItemRegistry new ClearBoardItem(), new SwapBoardItem(), new InvisibilityItem(), + new TransformationItem(), ]); public IReadOnlyCollection All => _items.Values; @@ -42,8 +43,6 @@ public sealed class ItemRegistry /// Decides which minos of a new piece carry an item. Each mino rolls chance (0..1) on its own. 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) { diff --git a/actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs b/actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs index e3bc148..62ef8d0 100644 --- a/actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs +++ b/actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs @@ -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); +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/PlayerGame.cs b/actionBlocks_server/src/ActionBlocks.Game/PlayerGame.cs index 3c45f1f..e23976f 100644 --- a/actionBlocks_server/src/ActionBlocks.Game/PlayerGame.cs +++ b/actionBlocks_server/src/ActionBlocks.Game/PlayerGame.cs @@ -92,6 +92,10 @@ public sealed class PlayerGame public double InvisibleUntil { get; set; } public bool IsInvisible => Now < InvisibleUntil; + /// Match time until which this player's upcoming pieces are odd shapes. + public double TransformedUntil { get; private set; } + public bool IsTransformed => Now < TransformedUntil; + /// The player selected as item target (own id = self). 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(); + /// + /// Turns the queue into odd shapes for (added to any time left). + /// The falling piece stays as it is; pieces still queued or held change back when the time runs out. + /// + 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) { diff --git a/actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs b/actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs index 4aa4632..e00b808 100644 --- a/actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs +++ b/actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs @@ -39,7 +39,8 @@ public sealed record PlayerStateDto( int Lines, int Level, int PendingGarbage, - double InvisibleRemaining); + double InvisibleRemaining, + double TransformedRemaining); /// A match event. is "itemUsed" or "eliminated". public sealed record MatchEventDto( diff --git a/actionBlocks_server/src/ActionBlocks.Server/Dtos/SnapshotMapper.cs b/actionBlocks_server/src/ActionBlocks.Server/Dtos/SnapshotMapper.cs index e0c11a7..09dfcb4 100644 --- a/actionBlocks_server/src/ActionBlocks.Server/Dtos/SnapshotMapper.cs +++ b/actionBlocks_server/src/ActionBlocks.Server/Dtos/SnapshotMapper.cs @@ -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 { diff --git a/actionBlocks_server/tests/ActionBlocks.Game.Tests/CoreTests.cs b/actionBlocks_server/tests/ActionBlocks.Game.Tests/CoreTests.cs index 48090b2..f4bdf16 100644 --- a/actionBlocks_server/tests/ActionBlocks.Game.Tests/CoreTests.cs +++ b/actionBlocks_server/tests/ActionBlocks.Game.Tests/CoreTests.cs @@ -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() { diff --git a/actionBlocks_server/tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs b/actionBlocks_server/tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs index 6246c7c..96850e3 100644 --- a/actionBlocks_server/tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs +++ b/actionBlocks_server/tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs @@ -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] diff --git a/actionBlocks_website/public/assets/items/transform.svg b/actionBlocks_website/public/assets/items/transform.svg new file mode 100644 index 0000000..38ce3db --- /dev/null +++ b/actionBlocks_website/public/assets/items/transform.svg @@ -0,0 +1 @@ + diff --git a/actionBlocks_website/src/components/BlackoutTimer.tsx b/actionBlocks_website/src/components/BlackoutTimer.tsx deleted file mode 100644 index 1bea904..0000000 --- a/actionBlocks_website/src/components/BlackoutTimer.tsx +++ /dev/null @@ -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 ( -
- - {remaining.toFixed(1)}s -
- ); -} diff --git a/actionBlocks_website/src/components/BlackoutTimer.module.css b/actionBlocks_website/src/components/EffectTimers.module.css similarity index 74% rename from actionBlocks_website/src/components/BlackoutTimer.module.css rename to actionBlocks_website/src/components/EffectTimers.module.css index ace4e98..250e6dc 100644 --- a/actionBlocks_website/src/components/BlackoutTimer.module.css +++ b/actionBlocks_website/src/components/EffectTimers.module.css @@ -1,7 +1,16 @@ -.timer { +.timers { position: absolute; top: 4px; right: 4px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + pointer-events: none; + z-index: 2; +} + +.timer { display: flex; align-items: center; gap: 6px; @@ -10,8 +19,6 @@ background: #000; font-size: 12px; line-height: 1; - pointer-events: none; - z-index: 2; } .icon { @@ -23,6 +30,10 @@ .small { top: 2px; right: 2px; + gap: 2px; +} + +.small .timer { gap: 3px; padding: 2px 3px; border-width: 2px; @@ -34,7 +45,7 @@ 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 { animation: blink 0.25s steps(2) infinite; } diff --git a/actionBlocks_website/src/components/EffectTimers.tsx b/actionBlocks_website/src/components/EffectTimers.tsx new file mode 100644 index 0000000..1c51f81 --- /dev/null +++ b/actionBlocks_website/src/components/EffectTimers.tsx @@ -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 ( +
+ {active.map(([itemId, remaining]) => { + const visual = itemVisual(itemId); + return ( +
+ + {remaining.toFixed(1)}s +
+ ); + })} +
+ ); +} diff --git a/actionBlocks_website/src/components/MiniBoard.tsx b/actionBlocks_website/src/components/MiniBoard.tsx index 5bd0c85..c4875c6 100644 --- a/actionBlocks_website/src/components/MiniBoard.tsx +++ b/actionBlocks_website/src/components/MiniBoard.tsx @@ -1,7 +1,7 @@ import { memo } from 'react'; import type { PlayerState } from '../net/types'; import type { ItemFlash } from '../net/useGameConnection'; -import { BlackoutTimer } from './BlackoutTimer'; +import { EffectTimers } from './EffectTimers'; import { BoardCanvas } from './BoardCanvas'; import { ItemBar } from './ItemBar'; import { ItemFlashOverlay } from './ItemFlashOverlay'; @@ -30,7 +30,15 @@ export const MiniBoard = memo(function MiniBoard({ state, width, height, slot, s - + {state.alive && ( + + )}
diff --git a/actionBlocks_website/src/net/types.ts b/actionBlocks_website/src/net/types.ts index b439307..0eb5710 100644 --- a/actionBlocks_website/src/net/types.ts +++ b/actionBlocks_website/src/net/types.ts @@ -52,7 +52,8 @@ export interface PlayerState { name: string; alive: boolean; 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; items: ItemCell[]; active: ActivePieceState | null; @@ -66,6 +67,7 @@ export interface PlayerState { level: number; pendingGarbage: number; invisibleRemaining: number; + transformedRemaining: number; } export interface MatchEvent { diff --git a/actionBlocks_website/src/pages/GamePage.tsx b/actionBlocks_website/src/pages/GamePage.tsx index 1063ab5..d732686 100644 --- a/actionBlocks_website/src/pages/GamePage.tsx +++ b/actionBlocks_website/src/pages/GamePage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCurrentTrack, useSound } from '../audio/SoundContext'; import { useGameSounds } from '../audio/useGameSounds'; -import { BlackoutTimer } from '../components/BlackoutTimer'; +import { EffectTimers } from '../components/EffectTimers'; import { BoardCanvas } from '../components/BoardCanvas'; import { ItemBar } from '../components/ItemBar'; import { ItemFlashOverlay } from '../components/ItemFlashOverlay'; @@ -130,7 +130,12 @@ export function GamePage({ match, lobby, playerId, flashes, actions }: Props) { highlighted={targetId === playerId} > - + {match.phase === 'countdown' && (
{Math.ceil(match.countdown)} diff --git a/actionBlocks_website/src/render/draw.ts b/actionBlocks_website/src/render/draw.ts index 4045e51..78223e6 100644 --- a/actionBlocks_website/src/render/draw.ts +++ b/actionBlocks_website/src/render/draw.ts @@ -135,6 +135,8 @@ export function drawPreviewPiece( if (!shape) return; const w = Math.max(...shape.map(([x]) => x)) + 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 oy = offsetY + (boxHeight - h * cell) / 2; const color = dimmed ? '#555555' : (blockColors[piece.type] ?? '#fff'); diff --git a/actionBlocks_website/src/render/shapes.ts b/actionBlocks_website/src/render/shapes.ts index 6487c20..7a1c6a0 100644 --- a/actionBlocks_website/src/render/shapes.ts +++ b/actionBlocks_website/src/render/shapes.ts @@ -7,4 +7,10 @@ export const pieceShapes: Record = { 5: [[1, 0], [2, 0], [0, 1], [1, 1]], 6: [[0, 0], [1, 0], [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]], }; diff --git a/actionBlocks_website/src/theme/assets.ts b/actionBlocks_website/src/theme/assets.ts index 221d84f..d3d1e30 100644 --- a/actionBlocks_website/src/theme/assets.ts +++ b/actionBlocks_website/src/theme/assets.ts @@ -52,6 +52,12 @@ export const itemVisuals: Record = { color: '#c452ff', 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 = { diff --git a/actionBlocks_website/src/theme/colors.ts b/actionBlocks_website/src/theme/colors.ts index cccdb69..3ed88fa 100644 --- a/actionBlocks_website/src/theme/colors.ts +++ b/actionBlocks_website/src/theme/colors.ts @@ -1,6 +1,6 @@ /** * 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 = { 1: '#3fe3f2', @@ -11,6 +11,11 @@ export const blockColors: Record = { 6: '#ff4256', 7: '#c452ff', 8: '#8c8c8c', + 9: '#ff6fd8', + 10: '#2fd6a3', + 11: '#b8ff3f', + 12: '#f2f2f2', + 13: '#ff7a3a', }; export const boardColors = { diff --git a/doc/development.md b/doc/development.md index 34bc2ad..fbe29b7 100644 --- a/doc/development.md +++ b/doc/development.md @@ -73,7 +73,7 @@ That gives items on almost every piece and a near-instant start, which is useful **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`. - 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. diff --git a/doc/extending.md b/doc/extending.md index 4acb029..38cbd54 100644 --- a/doc/extending.md +++ b/doc/extending.md @@ -38,7 +38,7 @@ public sealed class SpeedUpItem : IItem ### 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 public double FastUntil { get; set; } @@ -62,7 +62,7 @@ public static ItemRegistry Default { get; } = new( ### 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 @@ -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) -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 | | 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` | | Ghost look | `drawGhostCell` in `draw.ts`, `boardColors.ghostAlpha` | | UI colors, borders, font | CSS variables in `src/theme/global.css` | diff --git a/doc/frontend.md b/doc/frontend.md index e7b8596..5b9bfcc 100644 --- a/doc/frontend.md +++ b/doc/frontend.md @@ -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. - **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 " 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`. - **Inventory bar.** 10 slots, with the oldest item outlined, plus the hint line `F: > `. - **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**. diff --git a/doc/game-rules.md b/doc/game-rules.md index 73909f4..4700e17 100644 --- a/doc/game-rules.md +++ b/doc/game-rules.md @@ -28,7 +28,7 @@ Pieces spawn in the two buffer rows directly above the visible field. They are h ## 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`). -- 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) @@ -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. - 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 | |---|---|---|---| @@ -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` | | `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` | +| `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. diff --git a/doc/protocol.md b/doc/protocol.md index 8e418f5..21a228a 100644 --- a/doc/protocol.md +++ b/doc/protocol.md @@ -143,11 +143,12 @@ sequenceDiagram "lines": 12, "level": 2, "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.