This commit is contained in:
2026-10-23 19:23:03 +02:00
parent 8c8240de63
commit 19428550ca
90 changed files with 6912 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
# Action Blocks
Online player-vs-player falling-block game with items.
| Component | Path | Tech |
|---|---|---|
| Game server (logic, lobbies, players) | `actionBlocks_server/` | ASP.NET Core 10, SignalR |
| Website | `actionBlocks_website/` | React 19 + Vite + TypeScript, CSS Modules, served by nginx |
## Gameplay
- 10x20 field, hold, 5-piece preview, ghost piece, bag randomizer, wall kicks, 0.5 s lock delay (15 move resets).
- Garbage from line clears (attack table incl. spins, back-to-back, combos) goes to opponents in a **round-robin cycle**.
- 1 in 10 pieces carries an **item** inside one of its blocks. Clear the line containing that block to collect it (max 10, FIFO).
- **1-9** selects the item target (1 = you, 2.. = opponents in the order shown). **F** uses the first item on the target.
- You never see who targets you, but every opponent's items are shown beneath their mini field.
- Item names flash on top of the field they hit.
Controls: arrows or WASD to move/rotate/soft drop, Space for hard drop, Z/Q/Ctrl to rotate left, C/Shift to hold.
## Local development
```bash
# server on :5080
cd actionBlocks_server && dotnet run --project src/ActionBlocks.Server --urls http://localhost:5080
dotnet test # game logic tests
# website on :5173, proxies /hub to the server
cd actionBlocks_website && npm install && npm run dev
```
## Deployment
```bash
docker network create actionBlocks_website # if not already created
docker network create actionBlocks_server
docker compose up -d --build
```
The reverse proxy (attached to both networks) should route:
- the site to `actionblocks-website:80`
- `/hub` to `actionblocks-server:8080`, with WebSocket upgrade headers forwarded
| Variable | Container | Purpose |
|---|---|---|
| `SERVER_URL` | website | Hub URL as seen by the browser. Empty means `<site origin>/hub`. Written into `config.js` at container start. |
| `ALLOWED_ORIGINS` | server | Comma-separated CORS origins. Empty allows any origin. |
| `HUB_PATH` | server | Hub endpoint path (default `/hub`). |
| `Game__*` | server | Overrides any `GameSettings` value, e.g. `Game__ItemSpawnChance=0.2`. |
## Extending
**Adding an item**
1. Create a class implementing `IItem` in `actionBlocks_server/src/ActionBlocks.Game/Items/`.
2. Register it in `ItemRegistry.Default`.
3. Add an entry with the same id to `itemVisuals` in `actionBlocks_website/src/theme/assets.ts`, and add an icon in `public/assets/items/`.
**Changing rules**
- Values: `GameSettings`.
- Behaviour behind interfaces: `IRotationSystem`, `IRandomizer`, `IGarbageCalculator`, `IScoreCalculator`, `IGarbageTargeter`.
- The match flow lives in `Match`, and the per-player simulation in `PlayerGame`.
**Changing graphics**
- Every image path is in `src/theme/assets.ts`. You can set an image logo there with `uiAssets.logo`.
- Block colors are in `src/theme/colors.ts`.
- Block drawing style is in `src/render/draw.ts`.
- Key bindings are in `src/config/keybindings.ts`.
+6
View File
@@ -0,0 +1,6 @@
**/bin/
**/obj/
**/.vs/
**/*.user
Dockerfile
.dockerignore
+4
View File
@@ -0,0 +1,4 @@
bin/
obj/
*.user
.vs/
@@ -0,0 +1,71 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActionBlocks.Game", "src\ActionBlocks.Game\ActionBlocks.Game.csproj", "{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActionBlocks.Server", "src\ActionBlocks.Server\ActionBlocks.Server.csproj", "{864B69BE-238A-4816-A3FF-95F0649FD99A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActionBlocks.Game.Tests", "tests\ActionBlocks.Game.Tests\ActionBlocks.Game.Tests.csproj", "{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|x64.ActiveCfg = Debug|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|x64.Build.0 = Debug|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|x86.ActiveCfg = Debug|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|x86.Build.0 = Debug|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|Any CPU.Build.0 = Release|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|x64.ActiveCfg = Release|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|x64.Build.0 = Release|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|x86.ActiveCfg = Release|Any CPU
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|x86.Build.0 = Release|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|x64.ActiveCfg = Debug|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|x64.Build.0 = Debug|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|x86.ActiveCfg = Debug|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|x86.Build.0 = Debug|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|Any CPU.Build.0 = Release|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|x64.ActiveCfg = Release|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|x64.Build.0 = Release|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|x86.ActiveCfg = Release|Any CPU
{864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|x86.Build.0 = Release|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|x64.ActiveCfg = Debug|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|x64.Build.0 = Debug|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|x86.ActiveCfg = Debug|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|x86.Build.0 = Debug|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|Any CPU.Build.0 = Release|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|x64.ActiveCfg = Release|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|x64.Build.0 = Release|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|x86.ActiveCfg = Release|Any CPU
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{864B69BE-238A-4816-A3FF-95F0649FD99A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
+21
View File
@@ -0,0 +1,21 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ActionBlocks.Server.sln ./
COPY src/ActionBlocks.Game/ActionBlocks.Game.csproj src/ActionBlocks.Game/
COPY src/ActionBlocks.Server/ActionBlocks.Server.csproj src/ActionBlocks.Server/
COPY tests/ActionBlocks.Game.Tests/ActionBlocks.Game.Tests.csproj tests/ActionBlocks.Game.Tests/
RUN dotnet restore
COPY . .
RUN dotnet test --no-restore -c Release \
&& dotnet publish src/ActionBlocks.Server/ActionBlocks.Server.csproj --no-restore -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app ./
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
USER $APP_UID
ENTRYPOINT ["dotnet", "ActionBlocks.Server.dll"]
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ActionBlocks.Game</RootNamespace>
</PropertyGroup>
</Project>
@@ -0,0 +1,114 @@
namespace ActionBlocks.Game.Core;
/// <summary>
/// The grid of locked cells. Row 0 is the top of the hidden buffer zone; the visible playfield
/// starts at <see cref="BufferRows"/>.
/// </summary>
public sealed class Board
{
private List<Cell[]> _rows;
public Board(int width, int visibleRows, int bufferRows)
{
Width = width;
VisibleRows = visibleRows;
BufferRows = bufferRows;
_rows = Enumerable.Range(0, Height).Select(_ => NewRow()).ToList();
}
public int Width { get; }
public int VisibleRows { get; }
public int BufferRows { get; }
public int Height => VisibleRows + BufferRows;
public Cell this[int x, int y]
{
get => _rows[y][x];
set => _rows[y][x] = value;
}
public bool IsInside(int x, int y) => x >= 0 && x < Width && y >= 0 && y < Height;
/// <summary>True if the position is outside the board or occupied.</summary>
public bool IsBlocked(int x, int y) => !IsInside(x, y) || !_rows[y][x].IsEmpty;
public bool Fits(ActivePiece piece) => piece.Cells().All(c => !IsBlocked(c.X, c.Y));
public bool IsEmpty => _rows.All(r => r.All(c => c.IsEmpty));
public void Place(ActivePiece piece)
{
var i = 0;
foreach (var c in piece.Cells())
{
var item = i == piece.Spec.ItemMinoIndex ? piece.Spec.ItemId : null;
_rows[c.Y][c.X] = new Cell((byte)piece.Type, item);
i++;
}
}
public IReadOnlyList<int> FullRows() =>
Enumerable.Range(0, Height).Where(y => _rows[y].All(c => !c.IsEmpty)).ToList();
/// <summary>Removes the given rows, drops everything above, and returns the item ids the rows contained.</summary>
public IReadOnlyList<string> ClearRows(IReadOnlyCollection<int> rows)
{
var items = new List<string>();
var remaining = new List<Cell[]>(Height);
for (var y = 0; y < Height; y++)
{
if (rows.Contains(y))
{
items.AddRange(_rows[y].Where(c => c.ItemId is not null).Select(c => c.ItemId!));
}
else
{
remaining.Add(_rows[y]);
}
}
while (remaining.Count < Height)
{
remaining.Insert(0, NewRow());
}
_rows = remaining;
return items;
}
/// <summary>
/// Pushes garbage rows in from the bottom. Returns true if a filled cell was pushed out of the top
/// of the buffer zone (a top out).
/// </summary>
public bool AddGarbage(int count, int holeColumn)
{
var overflow = false;
for (var i = 0; i < count; i++)
{
overflow |= _rows[0].Any(c => !c.IsEmpty);
_rows.RemoveAt(0);
var row = Enumerable.Repeat(new Cell(CellColor.Garbage), Width).ToArray();
row[Math.Clamp(holeColumn, 0, Width - 1)] = Cell.Empty;
_rows.Add(row);
}
return overflow;
}
public void RemoveBottomRow()
{
_rows.RemoveAt(Height - 1);
_rows.Insert(0, NewRow());
}
public void Clear() => _rows = Enumerable.Range(0, Height).Select(_ => NewRow()).ToList();
/// <summary>Exchanges all locked cells with another board of the same size.</summary>
public void SwapContents(Board other)
{
if (other.Width != Width || other.Height != Height)
{
throw new InvalidOperationException("Boards must have equal dimensions to swap.");
}
(_rows, other._rows) = (other._rows, _rows);
}
private Cell[] NewRow() => Enumerable.Repeat(Cell.Empty, Width).ToArray();
}
@@ -0,0 +1,14 @@
namespace ActionBlocks.Game.Core;
/// <summary>One board cell. <see cref="ItemId"/> is set when the cell carries an item.</summary>
public readonly record struct Cell(byte Color, string? ItemId = null)
{
public static readonly Cell Empty = new(CellColor.Empty);
public bool IsEmpty => Color == CellColor.Empty;
}
public readonly record struct Point(int X, int Y)
{
public static Point operator +(Point a, Point b) => new(a.X + b.X, a.Y + b.Y);
}
@@ -0,0 +1,26 @@
namespace ActionBlocks.Game.Core;
/// <summary>
/// A piece definition as it travels through the queue: its shape plus an optional item
/// hidden inside one of its minos.
/// </summary>
public sealed record PieceSpec(PieceType Type, string? ItemId = null, int ItemMinoIndex = -1)
{
public bool HasItem => ItemId is not null;
}
/// <summary>A piece placed somewhere on the board (position = top-left corner of its bounding box).</summary>
public sealed record ActivePiece(PieceSpec Spec, int X, int Y, int Rotation)
{
public PieceType Type => Spec.Type;
public IEnumerable<Point> Cells()
{
foreach (var m in PieceDefinitions.Minos(Spec.Type, Rotation))
{
yield return new Point(X + m.X, Y + m.Y);
}
}
public ActivePiece Moved(int dx, int dy) => this with { X = X + dx, Y = Y + dy };
}
@@ -0,0 +1,50 @@
namespace ActionBlocks.Game.Core;
/// <summary>
/// Shape data for all pieces. Coordinates are relative to the piece's bounding box, y grows downwards.
/// Rotation states are derived by rotating the spawn shape inside its box, which keeps the mino
/// order stable across rotations (needed to track which mino carries an item).
/// </summary>
public static class PieceDefinitions
{
public const int RotationCount = 4;
private static readonly Dictionary<PieceType, (int BoxSize, Point[] Spawn)> SpawnShapes = new()
{
[PieceType.I] = (4, [new(0, 1), new(1, 1), new(2, 1), new(3, 1)]),
[PieceType.J] = (3, [new(0, 0), new(0, 1), new(1, 1), new(2, 1)]),
[PieceType.L] = (3, [new(2, 0), new(0, 1), new(1, 1), new(2, 1)]),
[PieceType.O] = (2, [new(0, 0), new(1, 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.T] = (3, [new(1, 0), new(0, 1), new(1, 1), new(2, 1)]),
};
private static readonly Dictionary<PieceType, Point[][]> Rotations = BuildRotations();
public static IReadOnlyList<PieceType> All { get; } = Enum.GetValues<PieceType>();
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>
public static IReadOnlyList<Point> Minos(PieceType type, int rotation) => Rotations[type][Mod(rotation)];
public static int Mod(int rotation) => ((rotation % RotationCount) + RotationCount) % RotationCount;
private static Dictionary<PieceType, Point[][]> BuildRotations()
{
var result = new Dictionary<PieceType, Point[][]>();
foreach (var (type, (size, spawn)) in SpawnShapes)
{
var states = new Point[RotationCount][];
states[0] = spawn;
for (var r = 1; r < RotationCount; r++)
{
// Clockwise rotation inside an n x n box: (x, y) -> (n - 1 - y, x)
states[r] = states[r - 1].Select(p => new Point(size - 1 - p.Y, p.X)).ToArray();
}
result[type] = states;
}
return result;
}
}
@@ -0,0 +1,19 @@
namespace ActionBlocks.Game.Core;
/// <summary>The seven four-cell piece shapes. Numeric values double as cell colors (1-7).</summary>
public enum PieceType : byte
{
I = 1,
J = 2,
L = 3,
O = 4,
S = 5,
Z = 6,
T = 7,
}
public static class CellColor
{
public const byte Empty = 0;
public const byte Garbage = 8;
}
@@ -0,0 +1,26 @@
namespace ActionBlocks.Game.Core;
public interface IRandomizer
{
PieceType Next();
}
/// <summary>"Bag" randomizer: every piece shape once per shuffled bag.</summary>
public sealed class SevenBagRandomizer(Random random) : IRandomizer
{
private readonly Queue<PieceType> _bag = new();
public PieceType Next()
{
if (_bag.Count == 0)
{
var pieces = PieceDefinitions.All.ToArray();
random.Shuffle(pieces);
foreach (var p in pieces)
{
_bag.Enqueue(p);
}
}
return _bag.Dequeue();
}
}
@@ -0,0 +1,63 @@
namespace ActionBlocks.Game.Core;
public interface IRotationSystem
{
/// <summary>
/// Tries to rotate <paramref name="piece"/> by <paramref name="direction"/> (+1 = clockwise, -1 = counter-clockwise).
/// Returns the rotated piece and the index of the kick that was used, or null if no kick fits.
/// </summary>
(ActivePiece Piece, int KickIndex)? TryRotate(Board board, ActivePiece piece, int direction);
}
/// <summary>Standard rotation system with wall and floor kicks.</summary>
public sealed class SuperRotationSystem : IRotationSystem
{
// Kick tables use the conventional y-up notation and are flipped when applied.
private static readonly Dictionary<(int From, int To), Point[]> JlstzKicks = new()
{
[(0, 1)] = [new(0, 0), new(-1, 0), new(-1, 1), new(0, -2), new(-1, -2)],
[(1, 0)] = [new(0, 0), new(1, 0), new(1, -1), new(0, 2), new(1, 2)],
[(1, 2)] = [new(0, 0), new(1, 0), new(1, -1), new(0, 2), new(1, 2)],
[(2, 1)] = [new(0, 0), new(-1, 0), new(-1, 1), new(0, -2), new(-1, -2)],
[(2, 3)] = [new(0, 0), new(1, 0), new(1, 1), new(0, -2), new(1, -2)],
[(3, 2)] = [new(0, 0), new(-1, 0), new(-1, -1), new(0, 2), new(-1, 2)],
[(3, 0)] = [new(0, 0), new(-1, 0), new(-1, -1), new(0, 2), new(-1, 2)],
[(0, 3)] = [new(0, 0), new(1, 0), new(1, 1), new(0, -2), new(1, -2)],
};
private static readonly Dictionary<(int From, int To), Point[]> IKicks = new()
{
[(0, 1)] = [new(0, 0), new(-2, 0), new(1, 0), new(-2, -1), new(1, 2)],
[(1, 0)] = [new(0, 0), new(2, 0), new(-1, 0), new(2, 1), new(-1, -2)],
[(1, 2)] = [new(0, 0), new(-1, 0), new(2, 0), new(-1, 2), new(2, -1)],
[(2, 1)] = [new(0, 0), new(1, 0), new(-2, 0), new(1, -2), new(-2, 1)],
[(2, 3)] = [new(0, 0), new(2, 0), new(-1, 0), new(2, 1), new(-1, -2)],
[(3, 2)] = [new(0, 0), new(-2, 0), new(1, 0), new(-2, -1), new(1, 2)],
[(3, 0)] = [new(0, 0), new(1, 0), new(-2, 0), new(1, -2), new(-2, 1)],
[(0, 3)] = [new(0, 0), new(-1, 0), new(2, 0), new(-1, 2), new(2, -1)],
};
private static readonly Point[] NoKicks = [new(0, 0)];
public (ActivePiece Piece, int KickIndex)? TryRotate(Board board, ActivePiece piece, int direction)
{
var from = PieceDefinitions.Mod(piece.Rotation);
var to = PieceDefinitions.Mod(piece.Rotation + direction);
var kicks = piece.Type switch
{
PieceType.O => NoKicks,
PieceType.I => IKicks[(from, to)],
_ => JlstzKicks[(from, to)],
};
for (var i = 0; i < kicks.Length; i++)
{
var candidate = piece with { Rotation = to, X = piece.X + kicks[i].X, Y = piece.Y - kicks[i].Y };
if (board.Fits(candidate))
{
return (candidate, i);
}
}
return null;
}
}
@@ -0,0 +1,33 @@
namespace ActionBlocks.Game;
/// <summary>Tunable rules for a match. Change values here (or pass a custom instance) to alter gameplay.</summary>
public sealed record GameSettings
{
public int BoardWidth { get; init; } = 10;
public int VisibleRows { get; init; } = 20;
public int BufferRows { get; init; } = 20;
public int PreviewCount { get; init; } = 5;
public double LockDelaySeconds { get; init; } = 0.5;
public int MaxLockResets { get; init; } = 15;
/// <summary>Soft drop moves this many times faster than normal gravity.</summary>
public double SoftDropFactor { get; init; } = 20;
public double MaxSoftDropRowsPerSecond { get; init; } = 60;
public int LinesPerLevel { get; init; } = 10;
public int MaxLevel { get; init; } = 15;
/// <summary>Chance (0..1) that a newly generated piece carries an item.</summary>
public double ItemSpawnChance { get; init; } = 0.1;
public int MaxInventory { get; init; } = 10;
public double CountdownSeconds { get; init; } = 3;
/// <summary>Seconds per row for a level (marathon speed curve).</summary>
public double GravitySecondsPerRow(int level)
{
var l = Math.Clamp(level, 1, MaxLevel);
return Math.Pow(0.8 - (l - 1) * 0.007, l - 1);
}
}
@@ -0,0 +1,89 @@
namespace ActionBlocks.Game.Garbage;
public enum SpinKind
{
None,
Mini,
Full,
}
/// <summary>Everything that is known about a lock that cleared (or did not clear) lines.</summary>
public sealed record ClearInfo(int Lines, SpinKind Spin, bool BackToBack, int Combo, bool PerfectClear)
{
/// <summary>Quads and spins that clear lines keep a back-to-back chain going.</summary>
public bool IsDifficult => Lines == 4 || (Spin != SpinKind.None && Lines > 0);
}
public interface IGarbageCalculator
{
int LinesToSend(ClearInfo clear);
}
/// <summary>Line attack table used by common multiplayer rule sets.</summary>
public sealed class StandardGarbageCalculator : IGarbageCalculator
{
private static readonly int[] ComboTable = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5];
public int LinesToSend(ClearInfo clear)
{
if (clear.Lines == 0)
{
return 0;
}
var lines = clear.Spin switch
{
SpinKind.Full => clear.Lines * 2,
SpinKind.Mini => clear.Lines - 1,
_ => clear.Lines == 4 ? 4 : clear.Lines - 1,
};
if (clear.BackToBack)
{
lines += 1;
}
lines += ComboTable[Math.Clamp(clear.Combo, 0, ComboTable.Length - 1)];
if (clear.PerfectClear)
{
lines += 10;
}
return lines;
}
}
public interface IScoreCalculator
{
int Score(ClearInfo clear, int level);
}
public sealed class StandardScoreCalculator : IScoreCalculator
{
public int Score(ClearInfo clear, int level)
{
var basePoints = (clear.Spin, clear.Lines) switch
{
(SpinKind.Full, 0) => 400,
(SpinKind.Full, 1) => 800,
(SpinKind.Full, 2) => 1200,
(SpinKind.Full, _) => 1600,
(SpinKind.Mini, 0) => 100,
(SpinKind.Mini, 1) => 200,
(SpinKind.Mini, _) => 400,
(_, 1) => 100,
(_, 2) => 300,
(_, 3) => 500,
(_, 4) => 800,
_ => 0,
};
var points = clear.BackToBack ? basePoints * 3 / 2 : basePoints;
if (clear.Lines > 0 && clear.Combo > 0)
{
points += 50 * clear.Combo;
}
return points * level;
}
}
@@ -0,0 +1,25 @@
namespace ActionBlocks.Game.Items;
/// <summary>
/// An item that can be found inside pieces and used on a player.
/// To add a new item: implement this interface and register it in <see cref="ItemRegistry"/>.
/// The frontend needs a matching icon entry for <see cref="Id"/>.
/// </summary>
public interface IItem
{
/// <summary>Stable identifier shared with the frontend (icons, colors).</summary>
string Id { get; }
/// <summary>Name shown when the item is used.</summary>
string DisplayName { get; }
/// <summary>Relative chance of this item being picked when an item spawns.</summary>
int SpawnWeight => 1;
void Apply(ItemContext context);
}
public sealed record ItemContext(PlayerGame User, PlayerGame Target, Random Random, double Now)
{
public bool IsSelfTarget => User == Target;
}
@@ -0,0 +1,54 @@
namespace ActionBlocks.Game.Items;
/// <summary>All items that exist in the game. Register new items here.</summary>
public sealed class ItemRegistry
{
private readonly Dictionary<string, IItem> _items;
public ItemRegistry(IEnumerable<IItem> items)
{
_items = items.ToDictionary(i => i.Id);
}
public static ItemRegistry Default { get; } = new(
[
new AddGarbageLineItem(),
new ClearBottomLineItem(),
new ClearBoardItem(),
new SwapBoardItem(),
new InvisibilityItem(),
]);
public IReadOnlyCollection<IItem> All => _items.Values;
public IItem? Get(string id) => _items.GetValueOrDefault(id);
public IItem PickRandom(Random random)
{
var total = _items.Values.Sum(i => i.SpawnWeight);
var roll = random.Next(total);
foreach (var item in _items.Values)
{
roll -= item.SpawnWeight;
if (roll < 0)
{
return item;
}
}
return _items.Values.First();
}
}
/// <summary>Decides whether a new piece carries an item and in which of its minos.</summary>
public sealed class ItemSpawner(ItemRegistry registry, double chance, Random random)
{
public Core.PieceSpec Decorate(Core.PieceType type)
{
if (registry.All.Count == 0 || random.NextDouble() >= chance)
{
return new Core.PieceSpec(type);
}
var item = registry.PickRandom(random);
return new Core.PieceSpec(type, item.Id, random.Next(4));
}
}
@@ -0,0 +1,51 @@
namespace ActionBlocks.Game.Items;
public sealed class AddGarbageLineItem : IItem
{
public string Id => "add_line";
public string DisplayName => "Add Line";
public void Apply(ItemContext context) =>
context.Target.InsertGarbageNow(1, context.Random.Next(context.Target.Board.Width));
}
public sealed class ClearBottomLineItem : IItem
{
public string Id => "clear_line";
public string DisplayName => "Clear Line";
public void Apply(ItemContext context) => context.Target.RemoveBottomLine();
}
public sealed class ClearBoardItem : IItem
{
public string Id => "nuke";
public string DisplayName => "Wipe Out";
public void Apply(ItemContext context) => context.Target.ClearBoard();
}
public sealed class SwapBoardItem : IItem
{
public string Id => "swap";
public string DisplayName => "Switch";
public void Apply(ItemContext context)
{
if (!context.IsSelfTarget)
{
PlayerGame.SwapBoards(context.User, context.Target);
}
}
}
public sealed class InvisibilityItem : IItem
{
public const double DurationSeconds = 4;
public string Id => "blind";
public string DisplayName => "Blackout";
public void Apply(ItemContext context) =>
context.Target.InvisibleUntil = Math.Max(context.Target.InvisibleUntil, context.Now + DurationSeconds);
}
@@ -0,0 +1,222 @@
using ActionBlocks.Game.Core;
using ActionBlocks.Game.Garbage;
using ActionBlocks.Game.Items;
using ActionBlocks.Game.Targeting;
namespace ActionBlocks.Game;
public enum MatchPhase
{
Countdown,
Running,
Finished,
}
public abstract record MatchEvent;
/// <summary>An item hit a player. Deliberately does not say who used it.</summary>
public sealed record ItemUsedEvent(string TargetId, string ItemId, string ItemName) : MatchEvent;
public sealed record PlayerEliminatedEvent(string PlayerId, int Place) : MatchEvent;
public sealed record MatchPlayer(string Id, string Name);
/// <summary>
/// One round between a set of players. Owns every <see cref="PlayerGame"/>, routes garbage and items
/// between them and decides the winner. Not thread-safe; callers serialize access.
/// </summary>
public sealed class Match
{
private readonly List<PlayerGame> _players;
private readonly ItemRegistry _items;
private readonly IGarbageTargeter _garbageTargeter;
private readonly Random _random;
private readonly List<MatchEvent> _events = [];
private readonly Dictionary<string, int> _places = new();
public Match(
IEnumerable<MatchPlayer> players,
GameSettings? settings = null,
ItemRegistry? items = null,
IGarbageTargeter? garbageTargeter = null,
int? seed = null)
{
Settings = settings ?? new GameSettings();
_items = items ?? ItemRegistry.Default;
_garbageTargeter = garbageTargeter ?? new CyclingGarbageTargeter();
_random = seed is null ? new Random() : new Random(seed.Value);
// Every player receives the same piece sequence, but items are rolled independently.
var pieceSeed = _random.Next();
var rotation = new SuperRotationSystem();
var garbage = new StandardGarbageCalculator();
var score = new StandardScoreCalculator();
_players = players.Select(p =>
{
var playerRandom = new Random(_random.Next());
return new PlayerGame(
p.Id,
p.Name,
Settings,
new SevenBagRandomizer(new Random(pieceSeed)),
new ItemSpawner(_items, Settings.ItemSpawnChance, playerRandom),
rotation,
garbage,
score,
playerRandom);
}).ToList();
CountdownRemaining = Settings.CountdownSeconds;
Phase = CountdownRemaining > 0 ? MatchPhase.Countdown : MatchPhase.Running;
if (Phase == MatchPhase.Running)
{
StartPlayers();
}
}
public GameSettings Settings { get; }
public IReadOnlyList<PlayerGame> Players => _players;
public MatchPhase Phase { get; private set; }
public double CountdownRemaining { get; private set; }
public double Now { get; private set; }
public string? WinnerId { get; private set; }
public IReadOnlyDictionary<string, int> Places => _places;
public ItemRegistry Items => _items;
public PlayerGame? Find(string playerId) => _players.FirstOrDefault(p => p.PlayerId == playerId);
public IReadOnlyList<MatchEvent> DrainEvents()
{
var result = _events.ToList();
_events.Clear();
return result;
}
public void Update(double deltaSeconds)
{
switch (Phase)
{
case MatchPhase.Countdown:
CountdownRemaining -= deltaSeconds;
if (CountdownRemaining <= 0)
{
CountdownRemaining = 0;
Phase = MatchPhase.Running;
StartPlayers();
}
return;
case MatchPhase.Finished:
return;
}
Now += deltaSeconds;
foreach (var player in _players)
{
player.Update(deltaSeconds, Now);
}
AfterStateChange();
}
public void HandleInput(string playerId, InputAction action)
{
if (Phase != MatchPhase.Running || Find(playerId) is not { IsAlive: true } player)
{
return;
}
if (action == InputAction.UseItem)
{
UseItem(player);
}
else
{
player.HandleInput(action);
}
AfterStateChange();
}
/// <summary>Selects who receives the player's items. Unknown or eliminated targets fall back to self.</summary>
public void SelectTarget(string playerId, string targetId)
{
if (Find(playerId) is not { } player)
{
return;
}
player.TargetId = Find(targetId) is { IsAlive: true } ? targetId : playerId;
}
public void Forfeit(string playerId)
{
if (Find(playerId) is { IsAlive: true } player)
{
player.Forfeit();
AfterStateChange();
}
}
private void StartPlayers()
{
foreach (var player in _players)
{
player.Start();
}
AfterStateChange();
}
private void UseItem(PlayerGame user)
{
var target = Find(user.TargetId) is { IsAlive: true } t ? t : user;
if (user.Inventory.Count == 0)
{
return;
}
var itemId = user.TakeFirstItem()!;
if (_items.Get(itemId) is not { } item)
{
return;
}
item.Apply(new ItemContext(user, target, _random, Now));
_events.Add(new ItemUsedEvent(target.PlayerId, item.Id, item.DisplayName));
}
private void AfterStateChange()
{
foreach (var player in _players)
{
foreach (var attack in player.DrainOutgoingAttacks())
{
_garbageTargeter.NextTarget(player, _players)?.QueueGarbage(attack);
}
}
var alive = _players.Count(p => p.IsAlive);
// Players eliminated in the same step share the best place still available.
var newlyEliminated = _players.Where(p => !p.IsAlive && !_places.ContainsKey(p.PlayerId)).ToList();
foreach (var player in newlyEliminated)
{
_places[player.PlayerId] = alive + 1;
_events.Add(new PlayerEliminatedEvent(player.PlayerId, alive + 1));
}
foreach (var player in _players.Where(p => Find(p.TargetId) is { IsAlive: false }))
{
player.TargetId = player.PlayerId;
}
var finished = _players.Count > 1 ? alive <= 1 : alive == 0;
if (Phase == MatchPhase.Running && finished)
{
Phase = MatchPhase.Finished;
var winner = _players.FirstOrDefault(p => p.IsAlive);
if (winner is not null)
{
_places[winner.PlayerId] = 1;
WinnerId = winner.PlayerId;
}
else if (_players.Count > 0)
{
WinnerId = _players.MinBy(p => _places[p.PlayerId])!.PlayerId;
}
}
}
}
@@ -0,0 +1,559 @@
using ActionBlocks.Game.Core;
using ActionBlocks.Game.Garbage;
using ActionBlocks.Game.Items;
namespace ActionBlocks.Game;
public enum InputAction
{
MoveLeft,
MoveRight,
RotateCw,
RotateCcw,
SoftDropStart,
SoftDropStop,
HardDrop,
Hold,
UseItem,
}
/// <summary>
/// Simulation of a single player's field: falling piece, hold, queue, line clears, garbage and items.
/// The object is not thread-safe; the owning <see cref="Match"/> serializes access.
/// </summary>
public sealed class PlayerGame
{
private readonly GameSettings _settings;
private readonly IRandomizer _randomizer;
private readonly ItemSpawner _itemSpawner;
private readonly IRotationSystem _rotation;
private readonly IGarbageCalculator _garbageCalculator;
private readonly IScoreCalculator _scoreCalculator;
private readonly Random _random;
private readonly List<PieceSpec> _next = [];
private readonly List<string> _inventory = [];
private readonly List<int> _pendingGarbage = [];
private readonly List<int> _outgoingAttacks = [];
private double _gravityProgress;
private double _lockTimer;
private int _lockResets;
private int _lowestY;
private bool _lastActionWasRotation;
private int _lastKickIndex;
private bool _backToBackActive;
public PlayerGame(
string playerId,
string name,
GameSettings settings,
IRandomizer randomizer,
ItemSpawner itemSpawner,
IRotationSystem rotation,
IGarbageCalculator garbageCalculator,
IScoreCalculator scoreCalculator,
Random random)
{
PlayerId = playerId;
Name = name;
TargetId = playerId;
_settings = settings;
_randomizer = randomizer;
_itemSpawner = itemSpawner;
_rotation = rotation;
_garbageCalculator = garbageCalculator;
_scoreCalculator = scoreCalculator;
_random = random;
Board = new Board(settings.BoardWidth, settings.VisibleRows, settings.BufferRows);
}
public string PlayerId { get; }
public string Name { get; }
public Board Board { get; }
public ActivePiece? Active { get; private set; }
public PieceSpec? Hold { get; private set; }
public bool HoldUsed { get; private set; }
public IReadOnlyList<PieceSpec> Next => _next;
public IReadOnlyList<string> Inventory => _inventory;
public int PendingGarbage => _pendingGarbage.Sum();
public bool IsSoftDropping { get; private set; }
public int Score { get; private set; }
public int Lines { get; private set; }
public int Level { get; private set; } = 1;
public int Combo { get; private set; } = -1;
public ClearInfo? LastClear { get; private set; }
public bool IsAlive { get; private set; } = true;
public double Now { get; private set; }
/// <summary>Match time until which locked cells and the ghost piece are hidden from this player.</summary>
public double InvisibleUntil { get; set; }
public bool IsInvisible => Now < InvisibleUntil;
/// <summary>The player selected as item target (own id = self).</summary>
public string TargetId { get; set; }
public void Start()
{
FillQueue();
SpawnNext();
}
public void Update(double deltaSeconds, double now)
{
Now = now;
if (!IsAlive || Active is null)
{
return;
}
var secondsPerRow = _settings.GravitySecondsPerRow(Level);
var rowsPerSecond = 1 / secondsPerRow;
if (IsSoftDropping)
{
rowsPerSecond = Math.Min(rowsPerSecond * _settings.SoftDropFactor, _settings.MaxSoftDropRowsPerSecond);
}
_gravityProgress += deltaSeconds * rowsPerSecond;
while (_gravityProgress >= 1)
{
_gravityProgress -= 1;
if (!TryMove(0, 1))
{
_gravityProgress = 0;
break;
}
if (IsSoftDropping)
{
Score += 1;
}
}
if (IsGrounded())
{
_lockTimer += deltaSeconds;
if (_lockTimer >= _settings.LockDelaySeconds || _lockResets >= _settings.MaxLockResets)
{
Lock();
}
}
}
public void HandleInput(InputAction action)
{
if (!IsAlive || Active is null)
{
if (action == InputAction.SoftDropStop)
{
IsSoftDropping = false;
}
return;
}
switch (action)
{
case InputAction.MoveLeft:
TryShift(-1);
break;
case InputAction.MoveRight:
TryShift(1);
break;
case InputAction.RotateCw:
TryRotate(1);
break;
case InputAction.RotateCcw:
TryRotate(-1);
break;
case InputAction.SoftDropStart:
IsSoftDropping = true;
break;
case InputAction.SoftDropStop:
IsSoftDropping = false;
break;
case InputAction.HardDrop:
HardDrop();
break;
case InputAction.Hold:
HoldPiece();
break;
case InputAction.UseItem:
// Items need a target and are resolved by the match.
break;
}
}
/// <summary>Where the active piece would land.</summary>
public ActivePiece? Ghost()
{
if (Active is null)
{
return null;
}
var ghost = Active;
while (Board.Fits(ghost.Moved(0, 1)))
{
ghost = ghost.Moved(0, 1);
}
return ghost;
}
/// <summary>Queues incoming garbage; it rises when the player next locks a piece without clearing lines.</summary>
public void QueueGarbage(int lines)
{
if (lines > 0 && IsAlive)
{
_pendingGarbage.Add(lines);
}
}
/// <summary>Returns and clears the attacks produced since the last call.</summary>
public IReadOnlyList<int> DrainOutgoingAttacks()
{
var result = _outgoingAttacks.ToList();
_outgoingAttacks.Clear();
return result;
}
public string? TakeFirstItem()
{
if (_inventory.Count == 0)
{
return null;
}
var item = _inventory[0];
_inventory.RemoveAt(0);
return item;
}
public void AddItem(string itemId)
{
if (_inventory.Count < _settings.MaxInventory)
{
_inventory.Add(itemId);
}
}
public void InsertGarbageNow(int lines, int holeColumn)
{
if (!IsAlive)
{
return;
}
if (Board.AddGarbage(lines, holeColumn))
{
TopOut();
return;
}
ResolveActiveOverlap();
}
public void RemoveBottomLine()
{
if (!IsAlive)
{
return;
}
Board.RemoveBottomRow();
ResolveActiveOverlap();
}
public void ClearBoard()
{
if (IsAlive)
{
Board.Clear();
}
}
public static void SwapBoards(PlayerGame a, PlayerGame b)
{
if (!a.IsAlive || !b.IsAlive)
{
return;
}
a.Board.SwapContents(b.Board);
a.ResolveActiveOverlap();
b.ResolveActiveOverlap();
}
public void Forfeit() => TopOut();
private void FillQueue()
{
while (_next.Count < Math.Max(1, _settings.PreviewCount))
{
_next.Add(_itemSpawner.Decorate(_randomizer.Next()));
}
}
private void SpawnNext()
{
var spec = _next[0];
_next.RemoveAt(0);
FillQueue();
Spawn(spec);
}
private void Spawn(PieceSpec spec)
{
var box = PieceDefinitions.BoxSize(spec.Type);
// Spawn in the two rows above the visible field, centered and rounded to the left.
var piece = new ActivePiece(spec, (Board.Width - box) / 2, Board.BufferRows - 2, 0);
if (!Board.Fits(piece))
{
Active = piece;
TopOut();
return;
}
Active = piece;
_gravityProgress = 0;
_lockTimer = 0;
_lockResets = 0;
_lastActionWasRotation = false;
_lowestY = piece.Y;
// Pieces move down one row immediately after appearing.
TryMove(0, 1);
}
private bool IsGrounded() => Active is not null && !Board.Fits(Active.Moved(0, 1));
private bool TryMove(int dx, int dy)
{
if (Active is null)
{
return false;
}
var moved = Active.Moved(dx, dy);
if (!Board.Fits(moved))
{
return false;
}
Active = moved;
_lastActionWasRotation = false;
if (moved.Y > _lowestY)
{
_lowestY = moved.Y;
_lockResets = 0;
_lockTimer = 0;
}
return true;
}
private void TryShift(int dx)
{
var wasGrounded = IsGrounded();
if (TryMove(dx, 0))
{
RegisterLockReset(wasGrounded);
}
}
private void TryRotate(int direction)
{
if (Active is null)
{
return;
}
var wasGrounded = IsGrounded();
var result = _rotation.TryRotate(Board, Active, direction);
if (result is null)
{
return;
}
Active = result.Value.Piece;
_lastActionWasRotation = true;
_lastKickIndex = result.Value.KickIndex;
if (Active.Y > _lowestY)
{
_lowestY = Active.Y;
_lockResets = 0;
_lockTimer = 0;
}
RegisterLockReset(wasGrounded);
}
private void RegisterLockReset(bool wasGrounded)
{
if (wasGrounded || IsGrounded())
{
_lockResets++;
_lockTimer = 0;
}
}
private void HardDrop()
{
var distance = 0;
while (Active is not null && Board.Fits(Active.Moved(0, 1)))
{
Active = Active.Moved(0, 1);
_lastActionWasRotation = false;
distance++;
}
Score += distance * 2;
Lock();
}
private void HoldPiece()
{
if (HoldUsed || Active is null)
{
return;
}
var current = Active.Spec;
HoldUsed = true;
if (Hold is null)
{
Hold = current;
SpawnNext();
}
else
{
var held = Hold;
Hold = current;
Spawn(held);
}
}
private SpinKind DetectSpin(ActivePiece piece)
{
if (piece.Type != PieceType.T || !_lastActionWasRotation)
{
return SpinKind.None;
}
// Corners of the 3x3 box: top-left, top-right, bottom-right, bottom-left.
Point[] corners = [new(0, 0), new(2, 0), new(2, 2), new(0, 2)];
var occupied = corners.Select(c => Board.IsBlocked(piece.X + c.X, piece.Y + c.Y)).ToArray();
if (occupied.Count(o => o) < 3)
{
return SpinKind.None;
}
// The two corners on the side the T points to.
var rotation = PieceDefinitions.Mod(piece.Rotation);
var front = (occupied[rotation], occupied[(rotation + 1) % 4]);
var fullFront = front is (true, true);
return fullFront || _lastKickIndex == 4 ? SpinKind.Full : SpinKind.Mini;
}
private void Lock()
{
if (Active is null)
{
return;
}
var piece = Active;
var spin = DetectSpin(piece);
var lockOut = piece.Cells().All(c => c.Y < Board.BufferRows);
Board.Place(piece);
Active = null;
if (lockOut)
{
TopOut();
return;
}
var rows = Board.FullRows();
foreach (var item in Board.ClearRows(rows))
{
AddItem(item);
}
var lines = rows.Count;
Combo = lines > 0 ? Combo + 1 : -1;
var difficult = lines == 4 || (spin != SpinKind.None && lines > 0);
var backToBack = lines > 0 && difficult && _backToBackActive;
if (lines > 0)
{
_backToBackActive = difficult;
}
var clear = new ClearInfo(lines, spin, backToBack, Math.Max(Combo, 0), lines > 0 && Board.IsEmpty);
if (lines > 0 || spin != SpinKind.None)
{
LastClear = clear;
}
Score += _scoreCalculator.Score(clear, Level);
Lines += lines;
Level = Math.Min(_settings.MaxLevel, 1 + Lines / _settings.LinesPerLevel);
var attack = CancelPendingGarbage(_garbageCalculator.LinesToSend(clear));
if (attack > 0)
{
_outgoingAttacks.Add(attack);
}
if (lines == 0 && !ApplyPendingGarbage())
{
return;
}
HoldUsed = false;
SpawnNext();
}
/// <summary>Uses outgoing lines to cancel incoming garbage first; returns what is left to send.</summary>
private int CancelPendingGarbage(int attack)
{
while (attack > 0 && _pendingGarbage.Count > 0)
{
var cancel = Math.Min(attack, _pendingGarbage[0]);
attack -= cancel;
_pendingGarbage[0] -= cancel;
if (_pendingGarbage[0] == 0)
{
_pendingGarbage.RemoveAt(0);
}
}
return attack;
}
/// <summary>Raises all pending garbage. Returns false if the player topped out.</summary>
private bool ApplyPendingGarbage()
{
foreach (var chunk in _pendingGarbage)
{
if (Board.AddGarbage(chunk, _random.Next(Board.Width)))
{
_pendingGarbage.Clear();
TopOut();
return false;
}
}
_pendingGarbage.Clear();
return true;
}
/// <summary>After the board changed under the active piece, push the piece up until it fits again.</summary>
private void ResolveActiveOverlap()
{
if (Active is null || Board.Fits(Active))
{
return;
}
for (var dy = 1; dy <= Board.Height; dy++)
{
var candidate = Active.Moved(0, -dy);
if (Board.Fits(candidate))
{
Active = candidate;
_lowestY = candidate.Y;
return;
}
}
TopOut();
}
private void TopOut()
{
IsAlive = false;
IsSoftDropping = false;
_pendingGarbage.Clear();
}
}
@@ -0,0 +1,34 @@
namespace ActionBlocks.Game.Targeting;
public interface IGarbageTargeter
{
/// <summary>Picks who receives the next garbage attack from <paramref name="sender"/>, or null if nobody can.</summary>
PlayerGame? NextTarget(PlayerGame sender, IReadOnlyList<PlayerGame> players);
}
/// <summary>Cycles through the living opponents of each sender in turn.</summary>
public sealed class CyclingGarbageTargeter : IGarbageTargeter
{
private readonly Dictionary<string, int> _cursor = new();
public PlayerGame? NextTarget(PlayerGame sender, IReadOnlyList<PlayerGame> players)
{
var opponents = players.Where(p => p != sender).ToList();
if (opponents.Count == 0)
{
return null;
}
var start = _cursor.GetValueOrDefault(sender.PlayerId);
for (var i = 0; i < opponents.Count; i++)
{
var index = (start + i) % opponents.Count;
if (opponents[index].IsAlive)
{
_cursor[sender.PlayerId] = index + 1;
return opponents[index];
}
}
return null;
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ActionBlocks.Server</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ActionBlocks.Game\ActionBlocks.Game.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,60 @@
namespace ActionBlocks.Server.Dtos;
public sealed record LobbyPlayerDto(string Id, string Name, bool IsHost);
public sealed record ResultDto(string Id, string Name, int Place, int Score, int Lines);
public sealed record LobbyDto(
string Code,
string HostId,
string Phase,
int MaxPlayers,
IReadOnlyList<LobbyPlayerDto> Players,
IReadOnlyList<ResultDto>? LastResults);
public sealed record JoinResultDto(string PlayerId, LobbyDto Lobby);
public sealed record PieceDto(int Type, string? ItemId, int ItemIndex);
public sealed record ItemCellDto(int X, int Y, string ItemId);
public sealed record ActivePieceDto(int Type, int[][] Cells, int ItemIndex, string? ItemId);
public sealed record PlayerStateDto(
string Id,
string Name,
bool Alive,
int? Place,
string Board,
IReadOnlyList<ItemCellDto> Items,
ActivePieceDto? Active,
int[][]? Ghost,
PieceDto? Hold,
bool HoldUsed,
IReadOnlyList<PieceDto> Next,
IReadOnlyList<string> Inventory,
int Score,
int Lines,
int Level,
int PendingGarbage,
double InvisibleRemaining);
/// <summary>A match event. <see cref="Type"/> is "itemUsed" or "eliminated".</summary>
public sealed record MatchEventDto(
string Type,
string? TargetId = null,
string? ItemId = null,
string? ItemName = null,
string? PlayerId = null,
int? Place = null);
public sealed record MatchStateDto(
string Phase,
double Countdown,
string? WinnerId,
int Width,
int Height,
IReadOnlyList<PlayerStateDto> Players,
IReadOnlyList<MatchEventDto> Events);
public sealed record ItemInfoDto(string Id, string Name);
@@ -0,0 +1,71 @@
using System.Text;
using ActionBlocks.Game;
using ActionBlocks.Game.Core;
namespace ActionBlocks.Server.Dtos;
/// <summary>Converts game objects into compact network snapshots. Only the visible rows are sent.</summary>
public static class SnapshotMapper
{
public static MatchStateDto ToDto(Match match, IReadOnlyList<MatchEvent> events) => new(
match.Phase.ToString().ToLowerInvariant(),
Math.Round(match.CountdownRemaining, 2),
match.WinnerId,
match.Settings.BoardWidth,
match.Settings.VisibleRows,
match.Players.Select(p => ToDto(p, match)).ToList(),
events.Select(ToDto).ToList());
private static PlayerStateDto ToDto(PlayerGame player, Match match)
{
var board = player.Board;
var cells = new StringBuilder(board.Width * board.VisibleRows);
var items = new List<ItemCellDto>();
for (var y = board.BufferRows; y < board.Height; y++)
{
for (var x = 0; x < board.Width; x++)
{
var cell = board[x, y];
cells.Append((char)('0' + cell.Color));
if (cell.ItemId is not null)
{
items.Add(new ItemCellDto(x, y - board.BufferRows, cell.ItemId));
}
}
}
return new PlayerStateDto(
player.PlayerId,
player.Name,
player.IsAlive,
match.Places.TryGetValue(player.PlayerId, out var place) ? place : null,
cells.ToString(),
items,
player.Active is { } active && player.IsAlive ? ToDto(active, board) : null,
player.IsAlive ? player.Ghost() is { } ghost ? ToCells(ghost, board) : null : null,
player.Hold is { } hold ? ToDto(hold) : null,
player.HoldUsed,
player.Next.Select(ToDto).ToList(),
player.Inventory.ToList(),
player.Score,
player.Lines,
player.Level,
player.PendingGarbage,
Math.Round(Math.Max(0, player.InvisibleUntil - match.Now), 2));
}
private static ActivePieceDto ToDto(ActivePiece piece, Board board) =>
new((int)piece.Type, ToCells(piece, board), piece.Spec.ItemMinoIndex, piece.Spec.ItemId);
private static int[][] ToCells(ActivePiece piece, Board board) =>
piece.Cells().Select(c => new[] { c.X, c.Y - board.BufferRows }).ToArray();
private static PieceDto ToDto(PieceSpec spec) => new((int)spec.Type, spec.ItemId, spec.ItemMinoIndex);
private static MatchEventDto ToDto(MatchEvent e) => e switch
{
ItemUsedEvent used => new MatchEventDto("itemUsed", TargetId: used.TargetId, ItemId: used.ItemId, ItemName: used.ItemName),
PlayerEliminatedEvent eliminated => new MatchEventDto("eliminated", PlayerId: eliminated.PlayerId, Place: eliminated.Place),
_ => new MatchEventDto("unknown"),
};
}
@@ -0,0 +1,134 @@
using ActionBlocks.Game;
using ActionBlocks.Server.Dtos;
using ActionBlocks.Server.Lobbies;
using Microsoft.AspNetCore.SignalR;
namespace ActionBlocks.Server.Hubs;
/// <summary>
/// Client-facing API. Server-to-client messages: "LobbyUpdated" (LobbyDto) and "MatchState" (MatchStateDto).
/// </summary>
public sealed class GameHub(LobbyManager lobbies, GameSettings settings) : Hub
{
public const string LobbyUpdated = "LobbyUpdated";
public const string MatchState = "MatchState";
public async Task<JoinResultDto> CreateLobby(string name)
{
var (lobby, player) = Run(() => lobbies.Create(Context.ConnectionId, name));
return await Joined(lobby, player);
}
public async Task<JoinResultDto> JoinLobby(string code, string name)
{
var (lobby, player) = Run(() => lobbies.Join(Context.ConnectionId, code, name));
return await Joined(lobby, player);
}
public async Task LeaveLobby()
{
var lobby = lobbies.FindByConnection(Context.ConnectionId);
if (lobby is null)
{
return;
}
await Groups.RemoveFromGroupAsync(Context.ConnectionId, lobby.Code);
await LeaveAndNotify();
}
public async Task StartGame()
{
var lobby = lobbies.FindByConnection(Context.ConnectionId) ?? throw new HubException("You are not in a lobby.");
LobbyDto dto;
lock (lobby.Sync)
{
var player = lobby.FindByConnection(Context.ConnectionId);
if (player is null || player.Id != lobby.HostId)
{
throw new HubException("Only the host can start the game.");
}
if (lobby.IsPlaying)
{
throw new HubException("A game is already running.");
}
lobby.StartMatch(settings);
dto = lobby.ToDto();
}
await Clients.Group(lobby.Code).SendAsync(LobbyUpdated, dto);
}
public void Input(InputAction action) =>
WithPlayer((lobby, player) =>
{
lobby.Match?.HandleInput(player.Id, action);
lobby.Dirty = true;
});
public void SelectTarget(string targetId) =>
WithPlayer((lobby, player) => lobby.Match?.SelectTarget(player.Id, targetId));
public IReadOnlyList<ItemInfoDto> GetItems() =>
Game.Items.ItemRegistry.Default.All.Select(i => new ItemInfoDto(i.Id, i.DisplayName)).ToList();
public override async Task OnDisconnectedAsync(Exception? exception)
{
await LeaveAndNotify();
await base.OnDisconnectedAsync(exception);
}
private async Task<JoinResultDto> Joined(Lobby lobby, LobbyPlayer player)
{
await Groups.AddToGroupAsync(Context.ConnectionId, lobby.Code);
LobbyDto dto;
lock (lobby.Sync)
{
dto = lobby.ToDto();
}
await Clients.OthersInGroup(lobby.Code).SendAsync(LobbyUpdated, dto);
return new JoinResultDto(player.Id, dto);
}
private async Task LeaveAndNotify()
{
var lobby = lobbies.Leave(Context.ConnectionId);
if (lobby is null)
{
return;
}
LobbyDto dto;
lock (lobby.Sync)
{
lobby.Dirty = true;
dto = lobby.ToDto();
}
await Clients.Group(lobby.Code).SendAsync(LobbyUpdated, dto);
}
private void WithPlayer(Action<Lobby, LobbyPlayer> action)
{
var lobby = lobbies.FindByConnection(Context.ConnectionId);
if (lobby is null)
{
return;
}
lock (lobby.Sync)
{
if (lobby.FindByConnection(Context.ConnectionId) is { } player)
{
action(lobby, player);
}
}
}
private static T Run<T>(Func<T> action)
{
try
{
return action();
}
catch (LobbyException e)
{
throw new HubException(e.Message);
}
}
}
@@ -0,0 +1,79 @@
using ActionBlocks.Game;
using ActionBlocks.Server.Dtos;
namespace ActionBlocks.Server.Lobbies;
public sealed record LobbyPlayer(string Id, string ConnectionId, string Name);
/// <summary>A group of players sharing a lobby code. All access goes through <see cref="Sync"/>.</summary>
public sealed class Lobby(string code)
{
public const int MaxPlayers = 8;
private readonly List<LobbyPlayer> _players = [];
public string Code { get; } = code;
public object Sync { get; } = new();
public IReadOnlyList<LobbyPlayer> Players => _players;
public string HostId { get; private set; } = "";
public Match? Match { get; private set; }
public DateTimeOffset? MatchFinishedAt { get; set; }
public IReadOnlyList<ResultDto>? LastResults { get; private set; }
/// <summary>Set when a snapshot should be sent on the next tick regardless of the broadcast rate.</summary>
public bool Dirty { get; set; }
public bool IsPlaying => Match is not null;
public LobbyPlayer? FindByConnection(string connectionId) =>
_players.FirstOrDefault(p => p.ConnectionId == connectionId);
public void Add(LobbyPlayer player)
{
_players.Add(player);
if (_players.Count == 1)
{
HostId = player.Id;
}
}
public void Remove(LobbyPlayer player)
{
_players.Remove(player);
Match?.Forfeit(player.Id);
if (HostId == player.Id)
{
HostId = _players.FirstOrDefault()?.Id ?? "";
}
}
public void StartMatch(GameSettings settings)
{
Match = new Match(_players.Select(p => new MatchPlayer(p.Id, p.Name)), settings);
MatchFinishedAt = null;
Dirty = true;
}
public void EndMatch()
{
if (Match is null)
{
return;
}
LastResults = Match.Players
.Select(p => new ResultDto(p.PlayerId, p.Name, Match.Places.GetValueOrDefault(p.PlayerId, 1), p.Score, p.Lines))
.OrderBy(r => r.Place)
.ToList();
Match = null;
MatchFinishedAt = null;
}
public LobbyDto ToDto() => new(
Code,
HostId,
IsPlaying ? "playing" : "waiting",
MaxPlayers,
_players.Select(p => new LobbyPlayerDto(p.Id, p.Name, p.Id == HostId)).ToList(),
LastResults);
}
@@ -0,0 +1,104 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
namespace ActionBlocks.Server.Lobbies;
public sealed class LobbyException(string message) : Exception(message);
/// <summary>Keeps all lobbies in memory and tracks which connection belongs to which lobby.</summary>
public sealed class LobbyManager
{
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private const int CodeLength = 6;
private const int MaxNameLength = 16;
private readonly ConcurrentDictionary<string, Lobby> _lobbies = new();
private readonly ConcurrentDictionary<string, string> _connectionLobby = new();
public IEnumerable<Lobby> All => _lobbies.Values;
public (Lobby Lobby, LobbyPlayer Player) Create(string connectionId, string name)
{
while (true)
{
var lobby = new Lobby(GenerateCode());
if (_lobbies.TryAdd(lobby.Code, lobby))
{
return Join(connectionId, lobby.Code, name);
}
}
}
public (Lobby Lobby, LobbyPlayer Player) Join(string connectionId, string code, string name)
{
if (_connectionLobby.ContainsKey(connectionId))
{
throw new LobbyException("You are already in a lobby.");
}
var normalized = (code ?? "").Trim().ToUpperInvariant();
if (!_lobbies.TryGetValue(normalized, out var lobby))
{
throw new LobbyException("Lobby not found.");
}
lock (lobby.Sync)
{
if (!_lobbies.ContainsKey(normalized))
{
throw new LobbyException("Lobby not found.");
}
if (lobby.Players.Count >= Lobby.MaxPlayers)
{
throw new LobbyException("Lobby is full.");
}
var player = new LobbyPlayer(Guid.NewGuid().ToString("N")[..12], connectionId, CleanName(name, lobby.Players.Count + 1));
lobby.Add(player);
_connectionLobby[connectionId] = lobby.Code;
return (lobby, player);
}
}
public Lobby? FindByConnection(string connectionId) =>
_connectionLobby.TryGetValue(connectionId, out var code) ? _lobbies.GetValueOrDefault(code) : null;
/// <summary>Removes the connection from its lobby. Returns the lobby if it still has players.</summary>
public Lobby? Leave(string connectionId)
{
if (!_connectionLobby.TryRemove(connectionId, out var code) || !_lobbies.TryGetValue(code, out var lobby))
{
return null;
}
lock (lobby.Sync)
{
if (lobby.FindByConnection(connectionId) is { } player)
{
lobby.Remove(player);
}
if (lobby.Players.Count == 0)
{
_lobbies.TryRemove(code, out _);
return null;
}
return lobby;
}
}
private static string GenerateCode() =>
string.Create(CodeLength, 0, (span, _) =>
{
for (var i = 0; i < span.Length; i++)
{
span[i] = CodeAlphabet[RandomNumberGenerator.GetInt32(CodeAlphabet.Length)];
}
});
private static string CleanName(string? name, int fallbackNumber)
{
var cleaned = new string((name ?? "").Where(c => !char.IsControl(c)).ToArray()).Trim();
if (cleaned.Length > MaxNameLength)
{
cleaned = cleaned[..MaxNameLength];
}
return cleaned.Length == 0 ? $"Player {fallbackNumber}" : cleaned;
}
}
@@ -0,0 +1,89 @@
using System.Diagnostics;
using ActionBlocks.Game;
using ActionBlocks.Server.Dtos;
using ActionBlocks.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
namespace ActionBlocks.Server.Lobbies;
/// <summary>Advances every running match at a fixed rate and pushes snapshots to the lobby's clients.</summary>
public sealed class MatchRunner(LobbyManager lobbies, IHubContext<GameHub> hub, ILogger<MatchRunner> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(1000.0 / 60);
private static readonly TimeSpan ResultsDelay = TimeSpan.FromSeconds(4);
/// <summary>Snapshots are sent every n-th tick unless input made a lobby dirty.</summary>
private const int BroadcastEveryTicks = 2;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TickInterval);
var clock = Stopwatch.StartNew();
var last = clock.Elapsed;
long tick = 0;
while (await timer.WaitForNextTickAsync(stoppingToken))
{
var now = clock.Elapsed;
// Clamp to avoid huge jumps after a stall.
var delta = Math.Min((now - last).TotalSeconds, 0.1);
last = now;
tick++;
foreach (var lobby in lobbies.All)
{
try
{
Step(lobby, delta, tick % BroadcastEveryTicks == 0);
}
catch (Exception e)
{
logger.LogError(e, "Failed to update lobby {Code}", lobby.Code);
}
}
}
}
private void Step(Lobby lobby, double delta, bool broadcastTick)
{
MatchStateDto? snapshot = null;
LobbyDto? lobbyUpdate = null;
lock (lobby.Sync)
{
var match = lobby.Match;
if (match is null)
{
return;
}
match.Update(delta);
var events = match.DrainEvents();
if (broadcastTick || lobby.Dirty || events.Count > 0)
{
snapshot = SnapshotMapper.ToDto(match, events);
lobby.Dirty = false;
}
if (match.Phase == MatchPhase.Finished)
{
lobby.MatchFinishedAt ??= DateTimeOffset.UtcNow;
if (DateTimeOffset.UtcNow - lobby.MatchFinishedAt >= ResultsDelay)
{
lobby.EndMatch();
lobbyUpdate = lobby.ToDto();
}
}
}
if (snapshot is not null)
{
_ = hub.Clients.Group(lobby.Code).SendAsync(GameHub.MatchState, snapshot);
}
if (lobbyUpdate is not null)
{
_ = hub.Clients.Group(lobby.Code).SendAsync(GameHub.LobbyUpdated, lobbyUpdate);
}
}
}
@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
using ActionBlocks.Game;
using ActionBlocks.Server.Hubs;
using ActionBlocks.Server.Lobbies;
var builder = WebApplication.CreateBuilder(args);
// Comma separated list of origins allowed to connect, e.g. "https://blocks.example.com".
// Empty = allow any origin (useful for local development).
var allowedOrigins = (builder.Configuration["ALLOWED_ORIGINS"] ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var hubPath = builder.Configuration["HUB_PATH"] ?? "/hub";
builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
{
if (allowedOrigins.Length > 0)
{
policy.WithOrigins(allowedOrigins);
}
else
{
policy.SetIsOriginAllowed(_ => true);
}
policy.AllowAnyHeader().AllowAnyMethod().AllowCredentials();
}));
builder.Services
.AddSignalR()
.AddJsonProtocol(o => o.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// Rules can be tuned via configuration, e.g. env Game__ItemSpawnChance=0.2
builder.Services.AddSingleton(builder.Configuration.GetSection("Game").Get<GameSettings>() ?? new GameSettings());
builder.Services.AddSingleton<LobbyManager>();
builder.Services.AddHostedService<MatchRunner>();
var app = builder.Build();
app.UseCors();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapHub<GameHub>(hubPath);
app.Run();
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ActionBlocks.Game\ActionBlocks.Game.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,87 @@
using ActionBlocks.Game.Core;
using static ActionBlocks.Game.Tests.TestHelpers;
namespace ActionBlocks.Game.Tests;
public class CoreTests
{
[Fact]
public void T_piece_rotates_clockwise_to_point_right()
{
var cells = PieceDefinitions.Minos(PieceType.T, 1).ToHashSet();
Assert.Equal(new HashSet<Point> { new(1, 0), new(1, 1), new(2, 1), new(1, 2) }, cells);
}
[Fact]
public void I_piece_vertical_state_uses_third_column()
{
var cells = PieceDefinitions.Minos(PieceType.I, 1);
Assert.All(cells, c => Assert.Equal(2, c.X));
}
[Fact]
public void Four_rotations_return_to_spawn_shape()
{
foreach (var type in PieceDefinitions.All)
{
Assert.Equal(PieceDefinitions.Minos(type, 0), PieceDefinitions.Minos(type, 4));
}
}
[Fact]
public void Seven_bag_deals_every_shape_once_per_bag()
{
var randomizer = new SevenBagRandomizer(new Random(42));
for (var bag = 0; bag < 3; bag++)
{
var pieces = Enumerable.Range(0, 7).Select(_ => randomizer.Next()).ToHashSet();
Assert.Equal(7, pieces.Count);
}
}
[Fact]
public void Rotation_against_wall_uses_kick()
{
var board = new Board(10, 20, 20);
var srs = new SuperRotationSystem();
// Vertical I hugging the left wall (column 0 in state L uses box column 1).
var piece = new ActivePiece(new PieceSpec(PieceType.I), -1, 30, 3);
Assert.True(board.Fits(piece));
var result = srs.TryRotate(board, piece, 1);
Assert.NotNull(result);
Assert.True(result.Value.KickIndex > 0);
Assert.True(board.Fits(result.Value.Piece));
}
[Fact]
public void Clearing_rows_returns_contained_items_and_drops_rows_above()
{
var board = new Board(10, 20, 20);
var bottom = Bottom(board);
FillRow(board, bottom);
board[3, bottom] = new Cell(CellColor.Garbage, "nuke");
board[0, bottom - 1] = new Cell(1);
var items = board.ClearRows(board.FullRows().ToList());
Assert.Equal(["nuke"], items);
Assert.False(board[0, bottom].IsEmpty);
Assert.Equal(1, FilledCount(board));
}
[Fact]
public void Garbage_pushes_rows_up_with_a_hole()
{
var board = new Board(10, 20, 20);
board[5, Bottom(board)] = new Cell(1);
var overflow = board.AddGarbage(2, 4);
Assert.False(overflow);
Assert.True(board[4, Bottom(board)].IsEmpty);
Assert.False(board[5, Bottom(board) - 2].IsEmpty);
Assert.Equal(19, FilledCount(board));
}
}
@@ -0,0 +1,172 @@
using ActionBlocks.Game.Core;
using ActionBlocks.Game.Items;
using ActionBlocks.Game.Targeting;
using static ActionBlocks.Game.Tests.TestHelpers;
namespace ActionBlocks.Game.Tests;
public class ItemTests
{
private static ItemContext Context(PlayerGame user, PlayerGame target, double now = 0) =>
new(user, target, new Random(3), now);
[Fact]
public void Add_line_inserts_one_garbage_row()
{
var user = CreatePlayer("a");
var target = CreatePlayer("b");
new AddGarbageLineItem().Apply(Context(user, target));
Assert.Equal(9, FilledCount(target.Board));
Assert.Equal(0, FilledCount(user.Board));
}
[Fact]
public void Clear_line_removes_bottom_row()
{
var target = CreatePlayer("b");
FillRow(target.Board, Bottom(target.Board), 0);
target.Board[0, Bottom(target.Board) - 1] = new Cell(1);
new ClearBottomLineItem().Apply(Context(target, target));
Assert.Equal(1, FilledCount(target.Board));
Assert.False(target.Board[0, Bottom(target.Board)].IsEmpty);
}
[Fact]
public void Wipe_out_clears_the_board()
{
var target = CreatePlayer("b");
FillRow(target.Board, Bottom(target.Board), 0);
new ClearBoardItem().Apply(Context(target, target));
Assert.True(target.Board.IsEmpty);
}
[Fact]
public void Switch_exchanges_boards()
{
var user = CreatePlayer("a");
var target = CreatePlayer("b");
FillRow(target.Board, Bottom(target.Board), 0);
new SwapBoardItem().Apply(Context(user, target));
Assert.Equal(9, FilledCount(user.Board));
Assert.True(target.Board.IsEmpty);
}
[Fact]
public void Switch_pushes_active_piece_up_when_it_would_overlap()
{
var user = CreatePlayer("a");
var target = CreatePlayer("b");
user.Start();
for (var y = 20; y < target.Board.Height; y++)
{
FillRow(target.Board, y, 0);
}
new SwapBoardItem().Apply(Context(user, target));
Assert.True(user.IsAlive);
Assert.True(user.Board.Fits(user.Active!));
}
[Fact]
public void Blackout_hides_board_for_four_seconds()
{
var target = CreatePlayer("b");
target.Start();
new InvisibilityItem().Apply(Context(target, target, now: 10));
target.Update(0.01, 13.9);
Assert.True(target.IsInvisible);
target.Update(0.01, 14.1);
Assert.False(target.IsInvisible);
}
[Fact]
public void Registry_contains_five_unique_items()
{
Assert.Equal(5, ItemRegistry.Default.All.Select(i => i.Id).Distinct().Count());
}
}
public class MatchTests
{
private static Match CreateMatch(params string[] ids) =>
new(ids.Select(id => new MatchPlayer(id, id)), new GameSettings { CountdownSeconds = 0 }, seed: 1);
[Fact]
public void Using_an_item_hits_selected_target_and_emits_event()
{
var match = CreateMatch("a", "b");
var a = match.Find("a")!;
var b = match.Find("b")!;
a.AddItem("add_line");
match.SelectTarget("a", "b");
match.HandleInput("a", InputAction.UseItem);
Assert.Empty(a.Inventory);
Assert.Equal(9, FilledCount(b.Board));
var used = Assert.Single(match.DrainEvents().OfType<ItemUsedEvent>());
Assert.Equal("b", used.TargetId);
}
[Fact]
public void Last_player_alive_wins()
{
var match = CreateMatch("a", "b", "c");
match.Forfeit("b");
Assert.Equal(MatchPhase.Running, match.Phase);
match.Forfeit("c");
Assert.Equal(MatchPhase.Finished, match.Phase);
Assert.Equal("a", match.WinnerId);
Assert.Equal(3, match.Places["b"]);
Assert.Equal(2, match.Places["c"]);
}
[Fact]
public void Target_falls_back_to_self_when_target_is_eliminated()
{
var match = CreateMatch("a", "b", "c");
match.SelectTarget("a", "b");
match.Forfeit("b");
Assert.Equal("a", match.Find("a")!.TargetId);
}
[Fact]
public void Garbage_targets_cycle_through_living_opponents()
{
var match = CreateMatch("a", "b", "c");
var targeter = new CyclingGarbageTargeter();
var a = match.Find("a")!;
var targets = Enumerable.Range(0, 4).Select(_ => targeter.NextTarget(a, match.Players)!.PlayerId).ToList();
Assert.Equal(["b", "c", "b", "c"], targets);
}
[Fact]
public void Countdown_delays_the_start()
{
var match = new Match([new MatchPlayer("a", "a")], new GameSettings { CountdownSeconds = 1 });
Assert.Equal(MatchPhase.Countdown, match.Phase);
Assert.Null(match.Find("a")!.Active);
match.Update(1.01);
Assert.Equal(MatchPhase.Running, match.Phase);
Assert.NotNull(match.Find("a")!.Active);
}
}
@@ -0,0 +1,129 @@
using ActionBlocks.Game.Core;
using static ActionBlocks.Game.Tests.TestHelpers;
namespace ActionBlocks.Game.Tests;
public class PlayerGameTests
{
[Fact]
public void Piece_spawns_centered_and_falls_with_gravity()
{
var player = CreatePlayer(pieces: PieceType.T);
player.Start();
var startY = player.Active!.Y;
Assert.Equal(3, player.Active.X);
player.Update(1.1, 1.1);
Assert.Equal(startY + 1, player.Active!.Y);
}
[Fact]
public void Hard_drop_clears_line_and_collects_item_in_row()
{
var player = CreatePlayer(pieces: PieceType.I);
var bottom = Bottom(player.Board);
FillRow(player.Board, bottom, 3, 4, 5, 6);
player.Board[0, bottom] = new Cell(CellColor.Garbage, "swap");
player.Start();
player.HandleInput(InputAction.HardDrop);
Assert.Equal(1, player.Lines);
Assert.Equal(["swap"], player.Inventory);
Assert.Equal(0, FilledCount(player.Board));
}
[Fact]
public void Item_inside_piece_is_collected_when_its_row_clears()
{
var player = CreatePlayer(itemChance: 1, pieces: PieceType.I);
FillRow(player.Board, Bottom(player.Board), 3, 4, 5, 6);
player.Start();
var itemId = player.Active!.Spec.ItemId;
Assert.NotNull(itemId);
player.HandleInput(InputAction.HardDrop);
Assert.Equal([itemId!], player.Inventory);
}
[Fact]
public void Item_stays_on_board_when_row_is_not_cleared()
{
var player = CreatePlayer(itemChance: 1, pieces: PieceType.I);
player.Start();
player.HandleInput(InputAction.HardDrop);
Assert.Empty(player.Inventory);
var bottom = Bottom(player.Board);
Assert.Contains(Enumerable.Range(0, 10), x => player.Board[x, bottom].ItemId is not null);
}
[Fact]
public void Hold_swaps_piece_once_per_lock()
{
var player = CreatePlayer(pieces: [PieceType.T, PieceType.O, PieceType.S]);
player.Start();
player.HandleInput(InputAction.Hold);
Assert.Equal(PieceType.T, player.Hold!.Type);
Assert.Equal(PieceType.O, player.Active!.Type);
player.HandleInput(InputAction.Hold);
Assert.Equal(PieceType.O, player.Active!.Type);
}
[Fact]
public void Pending_garbage_rises_after_a_lock_without_clear()
{
var player = CreatePlayer(pieces: PieceType.O);
player.Start();
player.QueueGarbage(2);
player.HandleInput(InputAction.HardDrop);
var bottom = Bottom(player.Board);
Assert.Equal(9, Enumerable.Range(0, 10).Count(x => player.Board[x, bottom].Color == CellColor.Garbage));
Assert.Equal(0, player.PendingGarbage);
}
[Fact]
public void Double_clear_sends_one_line()
{
var player = CreatePlayer(pieces: PieceType.O);
var bottom = Bottom(player.Board);
FillRow(player.Board, bottom, 4, 5);
FillRow(player.Board, bottom - 1, 4, 5);
player.Board[0, bottom - 2] = new Cell(1); // avoid a perfect clear
player.Start();
player.HandleInput(InputAction.HardDrop);
Assert.Equal([1], player.DrainOutgoingAttacks());
}
[Fact]
public void Stacking_to_the_top_ends_the_game()
{
var player = CreatePlayer(pieces: PieceType.O);
player.Start();
for (var i = 0; i < 30 && player.IsAlive; i++)
{
player.HandleInput(InputAction.HardDrop);
}
Assert.False(player.IsAlive);
}
[Fact]
public void Inventory_is_capped()
{
var player = CreatePlayer();
for (var i = 0; i < 20; i++)
{
player.AddItem("nuke");
}
Assert.Equal(new GameSettings().MaxInventory, player.Inventory.Count);
}
}
@@ -0,0 +1,41 @@
using ActionBlocks.Game.Core;
using ActionBlocks.Game.Garbage;
using ActionBlocks.Game.Items;
namespace ActionBlocks.Game.Tests;
internal sealed class FixedRandomizer(params PieceType[] sequence) : IRandomizer
{
private int _index;
public PieceType Next() => sequence[_index++ % sequence.Length];
}
internal static class TestHelpers
{
public static PlayerGame CreatePlayer(string id = "p1", double itemChance = 0, params PieceType[] pieces) =>
new(
id,
id,
new GameSettings { ItemSpawnChance = itemChance },
new FixedRandomizer(pieces.Length == 0 ? [PieceType.I] : pieces),
new ItemSpawner(ItemRegistry.Default, itemChance, new Random(1)),
new SuperRotationSystem(),
new StandardGarbageCalculator(),
new StandardScoreCalculator(),
new Random(1));
public static int Bottom(Board board) => board.Height - 1;
/// <summary>Fills a row except the given columns.</summary>
public static void FillRow(Board board, int y, params int[] holes)
{
for (var x = 0; x < board.Width; x++)
{
board[x, y] = holes.Contains(x) ? Cell.Empty : new Cell(CellColor.Garbage);
}
}
public static int FilledCount(Board board) =>
Enumerable.Range(0, board.Height).Sum(y => Enumerable.Range(0, board.Width).Count(x => !board[x, y].IsEmpty));
}
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
Dockerfile
.dockerignore
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY docker/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
COPY --from=build /app/dist /usr/share/nginx/html
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
# Full URL of the game hub as seen by the browser, e.g. https://blocks.example.com/hub.
# Empty = same origin + /hub (reverse proxy routes /hub to the server).
ENV SERVER_URL=""
EXPOSE 80
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Writes the runtime configuration consumed by the website (see src/config/runtimeConfig.ts).
set -eu
# Escape backslashes and double quotes for the JS string literal.
server_url=$(printf '%s' "${SERVER_URL:-}" | sed 's/\\/\\\\/g; s/"/\\"/g')
cat > /usr/share/nginx/html/config.js <<CONFIG
window.__ACTIONBLOCKS_CONFIG__ = { serverUrl: "${server_url}" };
CONFIG
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# Fingerprinted build output can be cached forever.
location /static/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Exchangeable graphics (public/assets) are not fingerprinted; revalidate them
# so replaced files show up immediately.
location /assets/ {
try_files $uri =404;
add_header Cache-Control "no-cache";
}
location = /config.js {
add_header Cache-Control "no-store";
}
location = /index.html {
add_header Cache-Control "no-cache";
}
# Single page app fallback.
location / {
try_files $uri $uri/ /index.html;
}
}
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
<title>Action Blocks</title>
<!-- Runtime configuration, rewritten by the container on startup. -->
<script src="/config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "actionblocks-website",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"typecheck": "tsc",
"preview": "vite preview"
},
"dependencies": {
"@microsoft/signalr": "10.0.11",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@types/node": "^22.20.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "5.2.0",
"typescript": "5.9.3",
"vite": "7.3.7"
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4" width="64" height="64" shape-rendering="crispEdges"><rect width="4" height="4" fill="#000"/><rect x="0" y="0" width="2" height="2" fill="#ff4256"/><rect x="2" y="0" width="2" height="2" fill="#ffe23f"/><rect x="0" y="2" width="2" height="2" fill="#3a66ff"/><rect x="2" y="2" width="2" height="2" fill="#4fe35f"/></svg>

After

Width:  |  Height:  |  Size: 377 B

@@ -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="0" width="1" height="1" fill="#ffffff"/><rect x="4" y="0" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="3" y="1" width="1" height="1" fill="#ffffff"/><rect x="4" y="1" width="1" height="1" fill="#ffffff"/><rect x="5" y="1" width="1" height="1" fill="#ffffff"/><rect x="1" y="2" width="1" height="1" fill="#ffffff"/><rect x="2" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="6" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="4" y="3" width="1" height="1" fill="#ffffff"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="4" y="4" width="1" height="1" fill="#ffffff"/><rect x="0" y="6" width="1" height="1" fill="#ff4256"/><rect x="1" y="6" width="1" height="1" fill="#ff4256"/><rect x="2" y="6" width="1" height="1" fill="#ff4256"/><rect x="3" y="6" width="1" height="1" fill="#ff4256"/><rect x="5" y="6" width="1" height="1" fill="#ff4256"/><rect x="6" y="6" width="1" height="1" fill="#ff4256"/><rect x="7" y="6" width="1" height="1" fill="#ff4256"/><rect x="0" y="7" width="1" height="1" fill="#ff4256"/><rect x="1" y="7" width="1" height="1" fill="#ff4256"/><rect x="2" y="7" width="1" height="1" fill="#ff4256"/><rect x="3" y="7" width="1" height="1" fill="#ff4256"/><rect x="5" y="7" width="1" height="1" fill="#ff4256"/><rect x="6" y="7" width="1" height="1" fill="#ff4256"/><rect x="7" y="7" width="1" height="1" fill="#ff4256"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -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="0" y="2" width="1" height="1" fill="#c452ff"/><rect x="7" y="2" width="1" height="1" fill="#c452ff"/><rect x="1" y="3" width="1" height="1" fill="#c452ff"/><rect x="2" y="3" width="1" height="1" fill="#c452ff"/><rect x="3" y="3" width="1" height="1" fill="#c452ff"/><rect x="4" y="3" width="1" height="1" fill="#c452ff"/><rect x="5" y="3" width="1" height="1" fill="#c452ff"/><rect x="6" y="3" width="1" height="1" fill="#c452ff"/><rect x="0" y="4" width="1" height="1" fill="#c452ff"/><rect x="2" y="4" width="1" height="1" fill="#c452ff"/><rect x="5" y="4" width="1" height="1" fill="#c452ff"/><rect x="7" y="4" width="1" height="1" fill="#c452ff"/><rect x="2" y="5" width="1" height="1" fill="#c452ff"/><rect x="5" y="5" width="1" height="1" fill="#c452ff"/></svg>

After

Width:  |  Height:  |  Size: 930 B

@@ -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="1" y="0" width="1" height="1" fill="#ffffff"/><rect x="6" y="0" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="5" y="1" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="4" y="3" width="1" height="1" fill="#ffffff"/><rect x="2" y="4" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#ffffff"/><rect x="1" y="5" width="1" height="1" fill="#ffffff"/><rect x="6" y="5" width="1" height="1" fill="#ffffff"/><rect x="0" y="6" width="1" height="1" fill="#4fe35f"/><rect x="1" y="6" width="1" height="1" fill="#4fe35f"/><rect x="2" y="6" width="1" height="1" fill="#4fe35f"/><rect x="3" y="6" width="1" height="1" fill="#4fe35f"/><rect x="4" y="6" width="1" height="1" fill="#4fe35f"/><rect x="5" y="6" width="1" height="1" fill="#4fe35f"/><rect x="6" y="6" width="1" height="1" fill="#4fe35f"/><rect x="7" y="6" width="1" height="1" fill="#4fe35f"/><rect x="0" y="7" width="1" height="1" fill="#4fe35f"/><rect x="1" y="7" width="1" height="1" fill="#4fe35f"/><rect x="2" y="7" width="1" height="1" fill="#4fe35f"/><rect x="3" y="7" width="1" height="1" fill="#4fe35f"/><rect x="4" y="7" width="1" height="1" fill="#4fe35f"/><rect x="5" y="7" width="1" height="1" fill="#4fe35f"/><rect x="6" y="7" width="1" height="1" fill="#4fe35f"/><rect x="7" y="7" width="1" height="1" fill="#4fe35f"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -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="0" y="0" width="1" height="1" fill="#ffe23f"/><rect x="3" y="0" width="1" height="1" fill="#ffe23f"/><rect x="7" y="0" width="1" height="1" fill="#ffe23f"/><rect x="1" y="1" width="1" height="1" fill="#ffe23f"/><rect x="3" y="1" width="1" height="1" fill="#ffe23f"/><rect x="6" y="1" width="1" height="1" fill="#ffe23f"/><rect x="2" y="2" width="1" height="1" fill="#ffe23f"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffe23f"/><rect x="0" y="3" width="1" height="1" fill="#ffe23f"/><rect x="1" y="3" width="1" height="1" fill="#ffe23f"/><rect x="2" y="3" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="4" y="3" width="1" height="1" fill="#ffffff"/><rect x="5" y="3" width="1" height="1" fill="#ffffff"/><rect x="6" y="3" width="1" height="1" fill="#ffe23f"/><rect x="7" y="3" width="1" height="1" fill="#ffe23f"/><rect x="2" y="4" width="1" height="1" fill="#ffe23f"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="4" y="4" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#ffe23f"/><rect x="1" y="5" width="1" height="1" fill="#ffe23f"/><rect x="4" y="5" width="1" height="1" fill="#ffe23f"/><rect x="6" y="5" width="1" height="1" fill="#ffe23f"/><rect x="0" y="6" width="1" height="1" fill="#ffe23f"/><rect x="4" y="6" width="1" height="1" fill="#ffe23f"/><rect x="7" y="6" width="1" height="1" fill="#ffe23f"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -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="2" y="0" width="1" height="1" fill="#ffffff"/><rect x="1" y="1" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="0" y="2" width="1" height="1" fill="#ffffff"/><rect x="1" y="2" width="1" height="1" fill="#ffffff"/><rect x="2" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="6" y="2" width="1" height="1" fill="#ffffff"/><rect x="1" y="3" width="1" height="1" fill="#ffffff"/><rect x="2" y="3" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#3fe3f2"/><rect x="6" y="4" width="1" height="1" fill="#3fe3f2"/><rect x="1" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="2" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="3" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="4" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="5" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="6" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="7" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="5" y="6" width="1" height="1" fill="#3fe3f2"/><rect x="6" y="6" width="1" height="1" fill="#3fe3f2"/><rect x="6" y="7" width="1" height="1" fill="#3fe3f2"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -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="2" y="0" width="1" height="1" fill="#ffffff"/><rect x="3" y="0" width="1" height="1" fill="#ffffff"/><rect x="4" y="0" width="1" height="1" fill="#ffffff"/><rect x="5" y="0" width="1" height="1" fill="#ffffff"/><rect x="1" y="1" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="5" y="1" width="1" height="1" fill="#ffffff"/><rect x="6" y="1" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="6" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="3" width="1" height="1" fill="#ffffff"/><rect x="5" y="3" width="1" height="1" fill="#ffffff"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="4" y="4" width="1" height="1" fill="#ffffff"/><rect x="3" y="5" width="1" height="1" fill="#ffffff"/><rect x="4" y="5" width="1" height="1" fill="#ffffff"/><rect x="3" y="7" width="1" height="1" fill="#ffffff"/><rect x="4" y="7" width="1" height="1" fill="#ffffff"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
// Runtime configuration. In the Docker image this file is generated from environment variables.
// serverUrl: full URL of the game hub, e.g. "https://blocks.example.com/hub". Empty = "<this origin>/hub".
window.__ACTIONBLOCKS_CONFIG__ = { serverUrl: "" };
+13
View File
@@ -0,0 +1,13 @@
.status {
padding: 8px 16px;
background: var(--color-danger);
color: var(--color-bg);
text-align: center;
font-size: 10px;
}
.loading {
padding: 64px 16px;
text-align: center;
color: var(--color-muted);
}
+34
View File
@@ -0,0 +1,34 @@
import { useGameConnection } from './net/useGameConnection';
import { GamePage } from './pages/GamePage';
import { HomePage } from './pages/HomePage';
import { LobbyPage } from './pages/LobbyPage';
import styles from './App.module.css';
const statusText = {
connecting: 'Connecting to server...',
reconnecting: 'Connection lost, reconnecting...',
disconnected: 'Server unreachable, retrying...',
connected: '',
};
export function App() {
const { status, playerId, lobby, match, flashes, actions } = useGameConnection();
let content;
if (!lobby || !playerId) {
content = <HomePage connected={status === 'connected'} actions={actions} />;
} else if (match) {
content = <GamePage match={match} lobby={lobby} playerId={playerId} flashes={flashes} actions={actions} />;
} else if (lobby.phase === 'playing') {
content = <div className={styles.loading}>Starting...</div>;
} else {
content = <LobbyPage lobby={lobby} playerId={playerId} actions={actions} />;
}
return (
<>
{status !== 'connected' && <div className={styles.status}>{statusText[status]}</div>}
{content}
</>
);
}
@@ -0,0 +1,35 @@
.frame {
position: relative;
display: inline-block;
border: var(--border);
line-height: 0;
background: var(--color-bg);
}
.highlighted {
border-color: var(--color-target);
box-shadow: 0 0 0 3px var(--color-target);
}
.canvas {
display: block;
image-rendering: pixelated;
}
.dead .canvas {
opacity: 0.35;
filter: grayscale(1);
}
.out {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
font-size: 1.6em;
color: var(--color-danger);
text-shadow: 3px 3px 0 #000;
pointer-events: none;
}
@@ -0,0 +1,34 @@
import { useEffect, useRef, type ReactNode } from 'react';
import type { PlayerState } from '../net/types';
import { drawBoard, setupCanvas } from '../render/draw';
import styles from './BoardCanvas.module.css';
interface Props {
state: PlayerState;
width: number;
height: number;
cell: number;
hideStack?: boolean;
highlighted?: boolean;
/** Rendered on top of the board (flashes, countdown, results). */
children?: ReactNode;
}
export function BoardCanvas({ state, width, height, cell, hideStack, highlighted, children }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = setupCanvas(canvas, width * cell, height * cell);
drawBoard(ctx, state, width, height, { cell, hideStack, showGrid: cell >= 14 });
}, [state, width, height, cell, hideStack]);
return (
<div className={[styles.frame, highlighted ? styles.highlighted : '', state.alive ? '' : styles.dead].join(' ')}>
<canvas ref={canvasRef} className={styles.canvas} />
{!state.alive && <div className={styles.out}>{state.place ? `#${state.place}` : 'OUT'}</div>}
{children}
</div>
);
}
@@ -0,0 +1,22 @@
.bar {
display: flex;
flex-wrap: wrap;
gap: 3px;
align-items: center;
padding: 3px;
}
.icon {
display: block;
image-rendering: pixelated;
border: 1px solid var(--color-dim);
}
.first {
border-color: var(--color-fg);
}
.empty {
display: block;
border: 1px dashed var(--color-dim);
}
@@ -0,0 +1,34 @@
import { itemVisual } from '../theme/assets';
import styles from './ItemBar.module.css';
interface Props {
items: string[];
/** Icon size in px. */
size: number;
slots?: number;
}
/** A row of item icons; the leftmost item is used next. */
export function ItemBar({ items, size, slots }: Props) {
const count = Math.max(slots ?? 0, items.length);
return (
<div className={styles.bar} style={{ minHeight: size + 6 }}>
{Array.from({ length: count }, (_, i) => {
const id = items[i];
if (!id) return <span key={i} className={styles.empty} style={{ width: size, height: size }} />;
const visual = itemVisual(id);
return (
<img
key={i}
className={[styles.icon, i === 0 ? styles.first : ''].join(' ')}
src={visual.icon}
alt={visual.name}
title={visual.name}
width={size}
height={size}
/>
);
})}
</div>
);
}
@@ -0,0 +1,51 @@
.overlay {
position: absolute;
top: 12%;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
pointer-events: none;
line-height: 1.3;
z-index: 2;
}
.flash {
padding: 8px 10px;
border: 3px solid;
background: #000;
font-size: 14px;
text-transform: uppercase;
text-align: center;
animation: flash 1.4s steps(7) forwards;
}
.small .flash {
padding: 4px 5px;
border-width: 2px;
font-size: 8px;
}
@keyframes flash {
0% {
opacity: 1;
transform: scale(1.3);
}
15% {
transform: scale(1);
}
20%,
40%,
60% {
opacity: 1;
}
30%,
50% {
opacity: 0.3;
}
100% {
opacity: 0;
}
}
@@ -0,0 +1,16 @@
import type { ItemFlash } from '../net/useGameConnection';
import styles from './ItemFlashOverlay.module.css';
/** Briefly shows the names of items that were used on a player, on top of their board. */
export function ItemFlashOverlay({ flashes, small = false }: { flashes: ItemFlash[]; small?: boolean }) {
if (flashes.length === 0) return null;
return (
<div className={[styles.overlay, small ? styles.small : ''].join(' ')}>
{flashes.map((f) => (
<div key={f.key} className={styles.flash} style={{ color: f.color, borderColor: f.color }}>
{f.text}
</div>
))}
</div>
);
}
@@ -0,0 +1,33 @@
.logo,
.small {
margin: 0;
font-weight: normal;
letter-spacing: 2px;
text-shadow: 4px 4px 0 var(--color-dim);
white-space: nowrap;
}
.logo {
font-size: clamp(20px, 5vw, 40px);
}
.small {
font-size: 14px;
text-shadow: 2px 2px 0 var(--color-dim);
}
.space {
display: inline-block;
width: 0.6em;
}
.image {
max-width: 100%;
height: 80px;
image-rendering: pixelated;
}
.imageSmall {
height: 28px;
image-rendering: pixelated;
}
@@ -0,0 +1,25 @@
import { uiAssets } from '../theme/assets';
import { blockColors } from '../theme/colors';
import styles from './Logo.module.css';
const TEXT = 'ACTION BLOCKS';
export function Logo({ small = false }: { small?: boolean }) {
if (uiAssets.logo) {
return <img className={small ? styles.imageSmall : styles.image} src={uiAssets.logo} alt="Action Blocks" />;
}
let colorIndex = 0;
return (
<h1 className={small ? styles.small : styles.logo} aria-label="Action Blocks">
{TEXT.split('').map((ch, i) =>
ch === ' ' ? (
<span key={i} className={styles.space} />
) : (
<span key={i} style={{ color: blockColors[(colorIndex++ % 7) + 1] }}>
{ch}
</span>
),
)}
</h1>
);
}
@@ -0,0 +1,39 @@
.mini {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.header {
display: flex;
align-items: center;
gap: 6px;
max-width: 100%;
font-size: 9px;
}
.slot {
display: inline-block;
min-width: 18px;
padding: 2px 4px;
border: 2px solid var(--color-fg);
text-align: center;
line-height: 1.2;
}
.selected .slot {
background: var(--color-target);
border-color: var(--color-target);
color: var(--color-bg);
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.items {
border: 2px solid var(--color-dim);
}
@@ -0,0 +1,38 @@
import { memo } from 'react';
import type { PlayerState } from '../net/types';
import type { ItemFlash } from '../net/useGameConnection';
import { BoardCanvas } from './BoardCanvas';
import { ItemBar } from './ItemBar';
import { ItemFlashOverlay } from './ItemFlashOverlay';
import styles from './MiniBoard.module.css';
interface Props {
state: PlayerState;
width: number;
height: number;
/** Number key that targets this player, if any. */
slot?: number;
selected: boolean;
flashes: ItemFlash[];
cell: number;
}
/** Small view of an opponent: board, name, target key and their current items. */
export const MiniBoard = memo(function MiniBoard({ state, width, height, slot, selected, flashes, cell }: Props) {
return (
<div className={[styles.mini, selected ? styles.selected : ''].join(' ')}>
<div className={styles.header}>
{slot !== undefined && <span className={styles.slot}>{slot}</span>}
<span className={styles.name} title={state.name}>
{state.name}
</span>
</div>
<BoardCanvas state={state} width={width} height={height} cell={cell} highlighted={selected}>
<ItemFlashOverlay flashes={flashes} small />
</BoardCanvas>
<div className={styles.items} style={{ width: width * cell + 6 }}>
<ItemBar items={state.inventory} size={Math.max(10, cell)} />
</div>
</div>
);
});
@@ -0,0 +1,17 @@
.preview {
border: var(--border);
padding: 8px;
background: var(--color-bg);
}
.title {
margin-bottom: 8px;
font-size: 10px;
color: var(--color-muted);
text-align: center;
}
.canvas {
display: block;
image-rendering: pixelated;
}
@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react';
import type { PieceInfo } from '../net/types';
import { drawPreviewPiece, setupCanvas } from '../render/draw';
import styles from './PiecePreview.module.css';
interface Props {
title: string;
pieces: (PieceInfo | null)[];
cell: number;
dimmed?: boolean;
}
const BOX_CELLS_W = 4;
const BOX_CELLS_H = 2.6;
/** Shows the hold slot or the next queue. */
export function PiecePreview({ title, pieces, cell, dimmed = false }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const boxW = BOX_CELLS_W * cell + cell;
const boxH = BOX_CELLS_H * cell;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = setupCanvas(canvas, boxW, boxH * Math.max(1, pieces.length));
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, boxW, boxH * Math.max(1, pieces.length));
pieces.forEach((piece, i) => {
if (piece) drawPreviewPiece(ctx, piece, 0, i * boxH, boxW, boxH, i === 0 ? cell : Math.round(cell * 0.8), dimmed);
});
}, [pieces, cell, boxW, boxH, dimmed]);
return (
<div className={styles.preview}>
<div className={styles.title}>{title}</div>
<canvas ref={canvasRef} className={styles.canvas} />
</div>
);
}
@@ -0,0 +1,41 @@
.button {
border: var(--border);
padding: 12px 18px;
cursor: pointer;
text-transform: uppercase;
box-shadow: 4px 4px 0 var(--color-dim);
}
.button:active:not(:disabled) {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 var(--color-dim);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.button:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 3px;
}
.primary {
background: var(--color-fg);
color: var(--color-bg);
}
.primary:hover:not(:disabled) {
background: var(--color-accent);
}
.secondary {
background: var(--color-bg);
color: var(--color-fg);
}
.secondary:hover:not(:disabled) {
color: var(--color-accent);
border-color: var(--color-accent);
}
@@ -0,0 +1,10 @@
import type { ButtonHTMLAttributes } from 'react';
import styles from './Button.module.css';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
}
export function Button({ variant = 'primary', className, ...rest }: Props) {
return <button className={[styles.button, styles[variant], className].filter(Boolean).join(' ')} {...rest} />;
}
@@ -0,0 +1,13 @@
.panel {
border: var(--border);
padding: 16px;
background: var(--color-bg);
}
.title {
margin: 0 0 14px;
font-size: 12px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--color-muted);
}
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react';
import styles from './Panel.module.css';
export function Panel({ title, children, className }: { title?: string; children: ReactNode; className?: string }) {
return (
<section className={[styles.panel, className].filter(Boolean).join(' ')}>
{title && <h2 className={styles.title}>{title}</h2>}
{children}
</section>
);
}
@@ -0,0 +1,46 @@
import type { InputAction } from '../net/types';
/** Actions bound to keys. Movement actions auto-repeat while held (see useKeyboardInput). */
export type BoundAction = 'MoveLeft' | 'MoveRight' | 'SoftDrop' | 'RotateCw' | 'RotateCcw' | 'HardDrop' | 'Hold' | 'UseItem';
/**
* Key bindings by KeyboardEvent.code (physical key position, independent of layout).
* KeyY is bound too so that "Z" works on QWERTZ keyboards.
*/
export const keyBindings: Record<BoundAction, string[]> = {
MoveLeft: ['ArrowLeft', 'KeyA'],
MoveRight: ['ArrowRight', 'KeyD'],
SoftDrop: ['ArrowDown', 'KeyS'],
RotateCw: ['ArrowUp', 'KeyW', 'KeyX', 'KeyE'],
RotateCcw: ['KeyZ', 'KeyY', 'KeyQ', 'ControlLeft', 'ControlRight'],
HardDrop: ['Space'],
Hold: ['KeyC', 'ShiftLeft', 'ShiftRight'],
UseItem: ['KeyF'],
};
/** Number row keys select the item target: 1 = yourself, 2..9 = opponents in display order. */
export const targetKeys = ['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9'];
/** Delayed auto shift and auto repeat rate in milliseconds. */
export const DAS_MS = 167;
export const ARR_MS = 33;
/** Human readable list for the help panel (plain ASCII, the pixel font has no arrow glyphs). */
export const controlsHelp: { keys: string; action: string }[] = [
{ keys: 'LEFT RIGHT / A D', action: 'Move' },
{ keys: 'DOWN / S', action: 'Soft drop' },
{ keys: 'SPACE', action: 'Hard drop' },
{ keys: 'UP / W / X', action: 'Rotate right' },
{ keys: 'Z / Q / CTRL', action: 'Rotate left' },
{ keys: 'C / SHIFT', action: 'Hold' },
{ keys: '1 - 9', action: 'Select target (1 = you)' },
{ keys: 'F', action: 'Use item on target' },
];
export const simpleActions: Partial<Record<BoundAction, InputAction>> = {
RotateCw: 'RotateCw',
RotateCcw: 'RotateCcw',
HardDrop: 'HardDrop',
Hold: 'Hold',
UseItem: 'UseItem',
};
@@ -0,0 +1,18 @@
interface RuntimeConfig {
serverUrl?: string;
}
declare global {
interface Window {
__ACTIONBLOCKS_CONFIG__?: RuntimeConfig;
}
}
/** URL of the game hub. Priority: runtime config.js, then VITE_SERVER_URL, then "<origin>/hub". */
export function getServerUrl(): string {
const runtime = window.__ACTIONBLOCKS_CONFIG__?.serverUrl?.trim();
if (runtime) return runtime;
const buildTime = (import.meta.env.VITE_SERVER_URL as string | undefined)?.trim();
if (buildTime) return buildTime;
return `${window.location.origin}/hub`;
}
@@ -0,0 +1,117 @@
import { useEffect, useRef } from 'react';
import { ARR_MS, DAS_MS, keyBindings, simpleActions, targetKeys, type BoundAction } from '../config/keybindings';
import type { InputAction } from '../net/types';
interface Options {
enabled: boolean;
onAction: (action: InputAction) => void;
/** Called with 1-based target slot (1 = self). */
onTarget: (slot: number) => void;
}
const codeToAction = new Map<string, BoundAction>(
(Object.entries(keyBindings) as [BoundAction, string[]][]).flatMap(([action, codes]) =>
codes.map((code) => [code, action] as const),
),
);
type Direction = 'MoveLeft' | 'MoveRight';
/**
* Translates keyboard events into game inputs. Left/right use delayed auto shift (DAS) with
* auto repeat (ARR); when both are held, the most recently pressed direction wins.
*/
export function useKeyboardInput({ enabled, onAction, onTarget }: Options) {
const handlers = useRef({ onAction, onTarget });
handlers.current = { onAction, onTarget };
useEffect(() => {
if (!enabled) return;
const heldDirections: Direction[] = [];
const heldCodes = new Set<string>();
let dasTimer: number | undefined;
let arrTimer: number | undefined;
let softDropKeys = 0;
const stopRepeat = () => {
window.clearTimeout(dasTimer);
window.clearInterval(arrTimer);
dasTimer = arrTimer = undefined;
};
const startRepeat = (direction: Direction) => {
stopRepeat();
handlers.current.onAction(direction);
dasTimer = window.setTimeout(() => {
arrTimer = window.setInterval(() => handlers.current.onAction(direction), ARR_MS);
}, DAS_MS);
};
const isTyping = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
return target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA';
};
const onKeyDown = (e: KeyboardEvent) => {
if (isTyping(e)) return;
const slot = targetKeys.indexOf(e.code);
if (slot >= 0) {
e.preventDefault();
if (!e.repeat) handlers.current.onTarget(slot + 1);
return;
}
const action = codeToAction.get(e.code);
if (!action) return;
e.preventDefault();
if (e.repeat || heldCodes.has(e.code)) return;
heldCodes.add(e.code);
if (action === 'MoveLeft' || action === 'MoveRight') {
heldDirections.push(action);
startRepeat(action);
} else if (action === 'SoftDrop') {
if (softDropKeys++ === 0) handlers.current.onAction('SoftDropStart');
} else {
const simple = simpleActions[action];
if (simple) handlers.current.onAction(simple);
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (!heldCodes.delete(e.code)) return;
const action = codeToAction.get(e.code);
if (action === 'MoveLeft' || action === 'MoveRight') {
const index = heldDirections.indexOf(action);
if (index >= 0) heldDirections.splice(index, 1);
const remaining = heldDirections[heldDirections.length - 1];
if (remaining) startRepeat(remaining);
else stopRepeat();
} else if (action === 'SoftDrop') {
softDropKeys = Math.max(0, softDropKeys - 1);
if (softDropKeys === 0) handlers.current.onAction('SoftDropStop');
}
};
const releaseAll = () => {
stopRepeat();
heldDirections.length = 0;
heldCodes.clear();
if (softDropKeys > 0) handlers.current.onAction('SoftDropStop');
softDropKeys = 0;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', releaseAll);
return () => {
releaseAll();
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', releaseAll);
};
}, [enabled]);
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import { preloadAssets } from './theme/assets';
import './theme/global.css';
preloadAssets();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+100
View File
@@ -0,0 +1,100 @@
import * as signalR from '@microsoft/signalr';
import { getServerUrl } from '../config/runtimeConfig';
import type { InputAction, JoinResult, Lobby, MatchState } from './types';
export interface HubHandlers {
onLobbyUpdated: (lobby: Lobby) => void;
onMatchState: (state: MatchState) => void;
onStatus: (status: ConnectionStatus) => void;
}
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
/** Thin typed wrapper around the SignalR connection to the game hub. */
export class HubClient {
private readonly connection: signalR.HubConnection;
private stopped = false;
constructor(private readonly handlers: HubHandlers) {
this.connection = new signalR.HubConnectionBuilder()
.withUrl(getServerUrl())
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Warning)
.build();
this.connection.on('LobbyUpdated', handlers.onLobbyUpdated);
this.connection.on('MatchState', handlers.onMatchState);
this.connection.onreconnecting(() => handlers.onStatus('reconnecting'));
this.connection.onreconnected(() => handlers.onStatus('connected'));
this.connection.onclose(() => {
handlers.onStatus('disconnected');
if (!this.stopped) this.scheduleStart();
});
}
async start(): Promise<void> {
this.handlers.onStatus('connecting');
try {
await this.connection.start();
this.handlers.onStatus('connected');
} catch {
this.handlers.onStatus('disconnected');
this.scheduleStart();
}
}
stop(): void {
this.stopped = true;
void this.connection.stop();
}
createLobby(name: string) {
return this.invoke<JoinResult>('CreateLobby', name);
}
joinLobby(code: string, name: string) {
return this.invoke<JoinResult>('JoinLobby', code, name);
}
leaveLobby() {
return this.invoke<void>('LeaveLobby');
}
startGame() {
return this.invoke<void>('StartGame');
}
/** Fire-and-forget input; ordering is preserved by the connection. */
input(action: InputAction) {
if (this.connection.state === signalR.HubConnectionState.Connected) {
void this.connection.send('Input', action);
}
}
selectTarget(targetId: string) {
if (this.connection.state === signalR.HubConnectionState.Connected) {
void this.connection.send('SelectTarget', targetId);
}
}
private scheduleStart() {
window.setTimeout(() => {
if (!this.stopped && this.connection.state === signalR.HubConnectionState.Disconnected) void this.start();
}, 2000);
}
private async invoke<T>(method: string, ...args: unknown[]): Promise<T> {
try {
return await this.connection.invoke<T>(method, ...args);
} catch (e) {
throw new Error(cleanHubError(e));
}
}
}
function cleanHubError(e: unknown): string {
const message = e instanceof Error ? e.message : String(e);
const marker = 'HubException: ';
const index = message.indexOf(marker);
return index >= 0 ? message.slice(index + marker.length) : 'Connection problem, please try again.';
}
+100
View File
@@ -0,0 +1,100 @@
// Mirrors the server DTOs in actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs
export interface LobbyPlayer {
id: string;
name: string;
isHost: boolean;
}
export interface MatchResult {
id: string;
name: string;
place: number;
score: number;
lines: number;
}
export interface Lobby {
code: string;
hostId: string;
phase: 'waiting' | 'playing';
maxPlayers: number;
players: LobbyPlayer[];
lastResults: MatchResult[] | null;
}
export interface JoinResult {
playerId: string;
lobby: Lobby;
}
export interface PieceInfo {
type: number;
itemId: string | null;
itemIndex: number;
}
export interface ItemCell {
x: number;
y: number;
itemId: string;
}
export interface ActivePieceState {
type: number;
cells: [number, number][];
itemIndex: number;
itemId: string | null;
}
export interface PlayerState {
id: string;
name: string;
alive: boolean;
place: number | null;
/** One character per visible cell, row by row: '0' empty, '1'-'7' piece colors, '8' garbage. */
board: string;
items: ItemCell[];
active: ActivePieceState | null;
ghost: [number, number][] | null;
hold: PieceInfo | null;
holdUsed: boolean;
next: PieceInfo[];
inventory: string[];
score: number;
lines: number;
level: number;
pendingGarbage: number;
invisibleRemaining: number;
}
export interface MatchEvent {
type: 'itemUsed' | 'eliminated' | string;
targetId?: string | null;
itemId?: string | null;
itemName?: string | null;
playerId?: string | null;
place?: number | null;
}
export interface MatchState {
phase: 'countdown' | 'running' | 'finished';
countdown: number;
winnerId: string | null;
width: number;
height: number;
players: PlayerState[];
events: MatchEvent[];
}
/** Must match ActionBlocks.Game.InputAction on the server. */
export type InputAction =
| 'MoveLeft'
| 'MoveRight'
| 'RotateCw'
| 'RotateCcw'
| 'SoftDropStart'
| 'SoftDropStop'
| 'HardDrop'
| 'Hold'
| 'UseItem';
@@ -0,0 +1,93 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { itemVisual } from '../theme/assets';
import { HubClient, type ConnectionStatus } from './hubClient';
import type { InputAction, Lobby, MatchState } from './types';
/** A short message shown on top of a player's board when an item hits them. */
export interface ItemFlash {
key: number;
targetId: string;
text: string;
color: string;
}
const FLASH_MS = 1400;
export function useGameConnection() {
const [status, setStatus] = useState<ConnectionStatus>('connecting');
const [playerId, setPlayerId] = useState<string | null>(null);
const [lobby, setLobby] = useState<Lobby | null>(null);
const [match, setMatch] = useState<MatchState | null>(null);
const [flashes, setFlashes] = useState<ItemFlash[]>([]);
const clientRef = useRef<HubClient | null>(null);
const flashKey = useRef(0);
useEffect(() => {
const client = new HubClient({
onLobbyUpdated: (next) => {
setLobby(next);
if (next.phase === 'waiting') setMatch(null);
},
onMatchState: (state) => {
setMatch(state);
const newFlashes = state.events
.filter((e) => e.type === 'itemUsed' && e.targetId)
.map<ItemFlash>((e) => ({
key: ++flashKey.current,
targetId: e.targetId!,
text: e.itemName ?? itemVisual(e.itemId ?? '').name,
color: itemVisual(e.itemId ?? '').color,
}));
if (newFlashes.length > 0) {
setFlashes((current) => [...current, ...newFlashes]);
const keys = new Set(newFlashes.map((f) => f.key));
window.setTimeout(() => setFlashes((current) => current.filter((f) => !keys.has(f.key))), FLASH_MS);
}
},
onStatus: (s) => {
setStatus(s);
// The server forgets players whose connection drops, so local lobby state is stale.
if (s !== 'connected') {
setLobby(null);
setMatch(null);
setPlayerId(null);
}
},
});
clientRef.current = client;
void client.start();
return () => client.stop();
}, []);
const createLobby = useCallback(async (name: string) => {
const result = await clientRef.current!.createLobby(name);
setPlayerId(result.playerId);
setLobby(result.lobby);
}, []);
const joinLobby = useCallback(async (code: string, name: string) => {
const result = await clientRef.current!.joinLobby(code, name);
setPlayerId(result.playerId);
setLobby(result.lobby);
}, []);
const leaveLobby = useCallback(async () => {
await clientRef.current?.leaveLobby();
setLobby(null);
setMatch(null);
setPlayerId(null);
}, []);
const startGame = useCallback(() => clientRef.current!.startGame(), []);
const sendInput = useCallback((action: InputAction) => clientRef.current?.input(action), []);
const selectTarget = useCallback((targetId: string) => clientRef.current?.selectTarget(targetId), []);
const actions = useMemo(
() => ({ createLobby, joinLobby, leaveLobby, startGame, sendInput, selectTarget }),
[createLobby, joinLobby, leaveLobby, startGame, sendInput, selectTarget],
);
return { status, playerId, lobby, match, flashes, actions };
}
export type GameActions = ReturnType<typeof useGameConnection>['actions'];
@@ -0,0 +1,157 @@
.page {
min-height: 100vh;
padding: 12px 16px 24px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.lobbyCode {
color: var(--color-muted);
font-size: 10px;
}
.arena {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 32px;
}
.own {
display: flex;
align-items: flex-start;
gap: 12px;
}
.side {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 110px;
}
.stats {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
border: var(--border);
}
.statLabel {
color: var(--color-muted);
font-size: 8px;
}
.statValue {
font-size: 12px;
}
.slotHint {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
}
.slot {
display: inline-block;
min-width: 20px;
padding: 2px 5px;
border: 2px solid var(--color-fg);
text-align: center;
}
.slotSelected {
background: var(--color-target);
border-color: var(--color-target);
color: var(--color-bg);
}
.boardColumn {
display: flex;
flex-direction: column;
gap: 8px;
}
.boardRow {
display: flex;
align-items: flex-end;
gap: 4px;
}
.garbageMeter {
position: relative;
width: 8px;
border: 2px solid var(--color-dim);
box-sizing: content-box;
}
.garbageFill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--color-danger);
}
.inventory {
align-self: flex-end;
border: var(--border);
padding: 4px;
}
.useHint {
padding: 4px 3px 0;
font-size: 8px;
color: var(--color-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
line-height: 1.4;
text-align: center;
z-index: 3;
}
.countdown {
font-size: 64px;
color: var(--color-accent);
text-shadow: 5px 5px 0 var(--color-dim);
}
.result {
padding: 0 12px;
font-size: 20px;
color: var(--color-accent);
text-shadow: 3px 3px 0 var(--color-dim);
}
.opponents {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 18px;
max-width: 640px;
}
.spectator {
padding: 20px;
border: var(--border);
color: var(--color-muted);
}
+167
View File
@@ -0,0 +1,167 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { BoardCanvas } from '../components/BoardCanvas';
import { ItemBar } from '../components/ItemBar';
import { ItemFlashOverlay } from '../components/ItemFlashOverlay';
import { Logo } from '../components/Logo';
import { MiniBoard } from '../components/MiniBoard';
import { PiecePreview } from '../components/PiecePreview';
import { useKeyboardInput } from '../input/useKeyboardInput';
import type { Lobby, MatchState } from '../net/types';
import type { GameActions, ItemFlash } from '../net/useGameConnection';
import { itemVisual } from '../theme/assets';
import styles from './GamePage.module.css';
interface Props {
match: MatchState;
lobby: Lobby;
playerId: string;
flashes: ItemFlash[];
actions: GameActions;
}
const INVENTORY_SLOTS = 10;
function useWindowHeight() {
const [height, setHeight] = useState(window.innerHeight);
useEffect(() => {
const onResize = () => setHeight(window.innerHeight);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return height;
}
export function GamePage({ match, lobby, playerId, flashes, actions }: Props) {
const me = match.players.find((p) => p.id === playerId);
const opponents = useMemo(() => match.players.filter((p) => p.id !== playerId), [match.players, playerId]);
const [targetId, setTargetId] = useState(playerId);
const windowHeight = useWindowHeight();
const cell = Math.max(16, Math.min(32, Math.floor((windowHeight - 170) / match.height)));
const miniCell = opponents.length <= 2 ? 14 : opponents.length <= 4 ? 12 : 9;
// Fall back to self when the selected opponent is gone or eliminated (the server does the same).
const target = match.players.find((p) => p.id === targetId);
useEffect(() => {
if (targetId !== playerId && (!target || !target.alive)) setTargetId(playerId);
}, [target, targetId, playerId]);
const onTarget = useCallback(
(slot: number) => {
const next = slot === 1 ? me : opponents[slot - 2];
if (!next || !next.alive) return;
setTargetId(next.id);
actions.selectTarget(next.id);
},
[me, opponents, actions],
);
useKeyboardInput({
enabled: !!me?.alive && match.phase !== 'finished',
onAction: actions.sendInput,
onTarget,
});
const flashesFor = (id: string) => flashes.filter((f) => f.targetId === id);
const winner = match.players.find((p) => p.id === match.winnerId);
const nextItem = me?.inventory[0];
return (
<main className={styles.page}>
<header className={styles.header}>
<Logo small />
<span className={styles.lobbyCode}>LOBBY {lobby.code}</span>
</header>
<div className={styles.arena}>
{me ? (
<section className={styles.own}>
<div className={styles.side}>
<PiecePreview title="HOLD" pieces={[me.hold]} cell={Math.round(cell * 0.7)} dimmed={me.holdUsed} />
<div className={styles.stats}>
<Stat label="SCORE" value={me.score} />
<Stat label="LINES" value={me.lines} />
<Stat label="LEVEL" value={me.level} />
</div>
<div className={styles.slotHint}>
<span className={[styles.slot, targetId === playerId ? styles.slotSelected : ''].join(' ')}>1</span>
YOU
</div>
</div>
<div className={styles.boardColumn}>
<div className={styles.boardRow}>
<div className={styles.garbageMeter} style={{ height: match.height * cell }}>
<div
className={styles.garbageFill}
style={{ height: Math.min(me.pendingGarbage, match.height) * cell }}
/>
</div>
<BoardCanvas
state={me}
width={match.width}
height={match.height}
cell={cell}
hideStack={me.invisibleRemaining > 0}
highlighted={targetId === playerId}
>
<ItemFlashOverlay flashes={flashesFor(me.id)} />
{match.phase === 'countdown' && (
<div className={styles.overlay}>
<span className={styles.countdown}>{Math.ceil(match.countdown)}</span>
</div>
)}
{match.phase === 'finished' && (
<div className={styles.overlay}>
<span className={styles.result}>
{match.winnerId === playerId ? 'YOU WIN!' : `${winner?.name ?? '?'} WINS`}
</span>
</div>
)}
</BoardCanvas>
</div>
<div className={styles.inventory} style={{ width: match.width * cell + 6 }}>
<ItemBar items={me.inventory} size={Math.max(16, Math.round(cell * 0.85))} slots={INVENTORY_SLOTS} />
<div className={styles.useHint}>
F: {nextItem ? itemVisual(nextItem).name : '-'} {'>'} {target && target.id !== playerId ? target.name : 'YOU'}
</div>
</div>
</div>
<div className={styles.side}>
<PiecePreview title="NEXT" pieces={me.next} cell={Math.round(cell * 0.7)} />
</div>
</section>
) : (
<div className={styles.spectator}>SPECTATING</div>
)}
{opponents.length > 0 && (
<section className={styles.opponents}>
{opponents.map((p, i) => (
<MiniBoard
key={p.id}
state={p}
width={match.width}
height={match.height}
cell={me ? miniCell : 14}
slot={me && i < 8 ? i + 2 : undefined}
selected={p.id === targetId}
flashes={flashesFor(p.id)}
/>
))}
</section>
)}
</div>
</main>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className={styles.stat}>
<div className={styles.statLabel}>{label}</div>
<div className={styles.statValue}>{value}</div>
</div>
);
}
@@ -0,0 +1,65 @@
.page {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 720px;
margin: 0 auto;
padding: 48px 16px;
}
.tagline {
margin: 0 0 12px;
color: var(--color-muted);
font-size: 10px;
}
.panel {
width: 100%;
}
.columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
width: 100%;
}
.hint {
margin: 0 0 16px;
color: var(--color-muted);
font-size: 10px;
}
.input {
width: 100%;
padding: 12px;
border: var(--border);
background: var(--color-bg);
color: var(--color-fg);
font-size: 14px;
text-transform: uppercase;
}
.input:focus {
outline: none;
border-color: var(--color-accent);
}
.joinForm {
display: flex;
gap: 12px;
}
.codeInput {
flex: 1;
min-width: 0;
letter-spacing: 4px;
}
.error {
margin: 0;
padding: 10px 14px;
border: 3px solid var(--color-danger);
color: var(--color-danger);
}
+101
View File
@@ -0,0 +1,101 @@
import { useState, type FormEvent } from 'react';
import { Logo } from '../components/Logo';
import { Button } from '../components/ui/Button';
import { Panel } from '../components/ui/Panel';
import type { GameActions } from '../net/useGameConnection';
import styles from './HomePage.module.css';
const NAME_KEY = 'actionblocks.name';
function loadName(): string {
try {
return localStorage.getItem(NAME_KEY) ?? '';
} catch {
return '';
}
}
function saveName(name: string) {
try {
localStorage.setItem(NAME_KEY, name);
} catch {
// Storage may be unavailable (private mode); the name is just not remembered.
}
}
interface Props {
connected: boolean;
actions: GameActions;
}
export function HomePage({ connected, actions }: Props) {
const [name, setName] = useState(loadName);
const [code, setCode] = useState(() => new URLSearchParams(window.location.search).get('code')?.toUpperCase() ?? '');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const run = async (action: () => Promise<void>) => {
setError(null);
setBusy(true);
saveName(name.trim());
try {
await action();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const onCreate = () => run(() => actions.createLobby(name));
const onJoin = (e: FormEvent) => {
e.preventDefault();
if (code.trim()) void run(() => actions.joinLobby(code, name));
};
const disabled = !connected || busy;
return (
<main className={styles.page}>
<Logo />
<p className={styles.tagline}>Stack. Clear. Collect. Strike.</p>
<Panel title="Your name" className={styles.panel}>
<input
className={styles.input}
value={name}
maxLength={16}
placeholder="PLAYER"
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</Panel>
<div className={styles.columns}>
<Panel title="New game" className={styles.panel}>
<p className={styles.hint}>Open a lobby and share its code.</p>
<Button onClick={onCreate} disabled={disabled}>
Create lobby
</Button>
</Panel>
<Panel title="Join game" className={styles.panel}>
<form onSubmit={onJoin} className={styles.joinForm}>
<input
className={[styles.input, styles.codeInput].join(' ')}
value={code}
maxLength={6}
placeholder="CODE"
onChange={(e) => setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))}
/>
<Button type="submit" variant="secondary" disabled={disabled || code.length === 0}>
Join
</Button>
</form>
</Panel>
</div>
{error && <p className={styles.error}>{error}</p>}
</main>
);
}
@@ -0,0 +1,133 @@
.page {
max-width: 1000px;
margin: 0 auto;
padding: 24px 16px 48px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.codePanel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
margin-bottom: 24px;
text-align: center;
}
.code {
font-size: clamp(28px, 8vw, 56px);
letter-spacing: 10px;
color: var(--color-accent);
text-shadow: 4px 4px 0 var(--color-dim);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.players {
margin: 0 0 20px;
padding-left: 28px;
}
.players li {
padding: 4px 0;
}
.players li span + span {
margin-left: 8px;
}
.me {
color: var(--color-target);
}
.badge {
padding: 1px 4px;
border: 2px solid currentColor;
font-size: 8px;
}
.start {
width: 100%;
}
.waiting,
.hint {
color: var(--color-muted);
font-size: 10px;
}
.error {
color: var(--color-danger);
word-break: break-all;
}
.results {
width: 100%;
border-collapse: collapse;
}
.results td {
padding: 4px 6px;
}
.num {
text-align: right;
}
.winner {
color: var(--color-accent);
}
.controls {
margin: 0;
}
.controls div {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 3px 0;
font-size: 10px;
}
.controls dt {
color: var(--color-accent);
}
.controls dd {
margin: 0;
text-align: right;
}
.items {
margin: 0;
padding: 0;
list-style: none;
}
.items li {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 4px 0;
}
.items img {
image-rendering: pixelated;
}
.itemDescription {
color: var(--color-muted);
font-size: 8px;
}
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { Logo } from '../components/Logo';
import { Button } from '../components/ui/Button';
import { Panel } from '../components/ui/Panel';
import { controlsHelp } from '../config/keybindings';
import type { Lobby } from '../net/types';
import type { GameActions } from '../net/useGameConnection';
import { itemVisuals } from '../theme/assets';
import styles from './LobbyPage.module.css';
interface Props {
lobby: Lobby;
playerId: string;
actions: GameActions;
}
export function LobbyPage({ lobby, playerId, actions }: Props) {
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const isHost = lobby.hostId === playerId;
const copyLink = async () => {
const url = `${window.location.origin}${window.location.pathname}?code=${lobby.code}`;
try {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
setError(url);
}
};
const start = async () => {
setError(null);
try {
await actions.startGame();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<main className={styles.page}>
<header className={styles.header}>
<Logo small />
<Button variant="secondary" onClick={() => void actions.leaveLobby()}>
Leave
</Button>
</header>
<Panel title="Lobby code" className={styles.codePanel}>
<div className={styles.code}>{lobby.code}</div>
<Button variant="secondary" onClick={copyLink}>
{copied ? 'Copied!' : 'Copy invite link'}
</Button>
</Panel>
<div className={styles.grid}>
<Panel title={`Players ${lobby.players.length}/${lobby.maxPlayers}`}>
<ol className={styles.players}>
{lobby.players.map((p) => (
<li key={p.id} className={p.id === playerId ? styles.me : ''}>
<span>{p.name}</span>
{p.isHost && <span className={styles.badge}>HOST</span>}
{p.id === playerId && <span className={styles.badge}>YOU</span>}
</li>
))}
</ol>
{isHost ? (
<Button onClick={start} className={styles.start}>
Start game
</Button>
) : (
<p className={styles.waiting}>Waiting for the host to start...</p>
)}
{error && <p className={styles.error}>{error}</p>}
</Panel>
{lobby.lastResults && (
<Panel title="Last round">
<table className={styles.results}>
<tbody>
{lobby.lastResults.map((r) => (
<tr key={r.id}>
<td className={r.place === 1 ? styles.winner : ''}>#{r.place}</td>
<td>{r.name}</td>
<td className={styles.num}>{r.score}</td>
<td className={styles.num}>{r.lines}L</td>
</tr>
))}
</tbody>
</table>
</Panel>
)}
<Panel title="Controls">
<dl className={styles.controls}>
{controlsHelp.map((c) => (
<div key={c.action}>
<dt>{c.keys}</dt>
<dd>{c.action}</dd>
</div>
))}
</dl>
</Panel>
<Panel title="Items">
<p className={styles.hint}>Items hide inside blocks. Clear their line to collect them.</p>
<ul className={styles.items}>
{Object.entries(itemVisuals).map(([id, v]) => (
<li key={id}>
<img src={v.icon} alt="" width={20} height={20} />
<div>
<div style={{ color: v.color }}>{v.name}</div>
<div className={styles.itemDescription}>{v.description}</div>
</div>
</li>
))}
</ul>
</Panel>
</div>
</main>
);
}
+146
View File
@@ -0,0 +1,146 @@
import type { PieceInfo, PlayerState } from '../net/types';
import { getImage, itemVisual } from '../theme/assets';
import { blockColors, boardColors } from '../theme/colors';
import { pieceShapes } from './shapes';
/** Prepares a canvas for crisp pixel drawing at the given CSS size. */
export function setupCanvas(canvas: HTMLCanvasElement, cssWidth: number, cssHeight: number): CanvasRenderingContext2D {
const dpr = window.devicePixelRatio || 1;
const w = Math.round(cssWidth * dpr);
const h = Math.round(cssHeight * dpr);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
}
const ctx = canvas.getContext('2d')!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.imageSmoothingEnabled = false;
return ctx;
}
export function shade(hex: string, amount: number): string {
const n = parseInt(hex.slice(1), 16);
const adjust = (c: number) => Math.max(0, Math.min(255, Math.round(amount > 0 ? c + (255 - c) * amount : c * (1 + amount))));
const r = adjust((n >> 16) & 255);
const g = adjust((n >> 8) & 255);
const b = adjust(n & 255);
return `rgb(${r}, ${g}, ${b})`;
}
/** A single block in a simple retro bevel style. */
export function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, color: string) {
const edge = Math.max(1, Math.floor(size / 7));
ctx.fillStyle = color;
ctx.fillRect(x, y, size, size);
ctx.fillStyle = shade(color, 0.45);
ctx.fillRect(x, y, size, edge);
ctx.fillRect(x, y, edge, size);
ctx.fillStyle = shade(color, -0.45);
ctx.fillRect(x, y + size - edge, size, edge);
ctx.fillRect(x + size - edge, y, edge, size);
}
export function drawItemIcon(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, itemId: string) {
const visual = itemVisual(itemId);
const image = getImage(visual.icon);
const inset = Math.max(1, Math.floor(size / 8));
if (image.complete && image.naturalWidth > 0) {
ctx.drawImage(image, x + inset, y + inset, size - inset * 2, size - inset * 2);
} else {
ctx.fillStyle = '#000';
ctx.fillRect(x + inset, y + inset, size - inset * 2, size - inset * 2);
ctx.fillStyle = visual.color;
ctx.fillRect(x + size / 3, y + size / 3, size / 3, size / 3);
}
}
function drawGhostCell(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, color: string) {
ctx.globalAlpha = boardColors.ghostAlpha;
ctx.fillStyle = color;
ctx.fillRect(x, y, size, size);
ctx.globalAlpha = 1;
const edge = Math.max(1, Math.floor(size / 10));
ctx.strokeStyle = color;
ctx.lineWidth = edge;
ctx.strokeRect(x + edge / 2, y + edge / 2, size - edge, size - edge);
}
export interface BoardDrawOptions {
cell: number;
/** Hide locked cells and the ghost piece (blackout item). */
hideStack?: boolean;
showGhost?: boolean;
showGrid?: boolean;
}
export function drawBoard(
ctx: CanvasRenderingContext2D,
state: PlayerState,
width: number,
height: number,
{ cell, hideStack = false, showGhost = true, showGrid = true }: BoardDrawOptions,
) {
ctx.fillStyle = boardColors.background;
ctx.fillRect(0, 0, width * cell, height * cell);
if (showGrid) {
ctx.fillStyle = boardColors.grid;
for (let x = 1; x < width; x++) ctx.fillRect(x * cell, 0, 1, height * cell);
for (let y = 1; y < height; y++) ctx.fillRect(0, y * cell, width * cell, 1);
}
if (!hideStack) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const value = state.board.charCodeAt(y * width + x) - 48;
if (value > 0) drawBlock(ctx, x * cell, y * cell, cell, blockColors[value] ?? '#fff');
}
}
for (const item of state.items) drawItemIcon(ctx, item.x * cell, item.y * cell, cell, item.itemId);
}
const active = state.active;
if (!active) return;
const color = blockColors[active.type] ?? '#fff';
if (showGhost && !hideStack && state.ghost) {
const activeKeys = new Set(active.cells.map(([x, y]) => `${x},${y}`));
for (const [x, y] of state.ghost) {
if (y >= 0 && !activeKeys.has(`${x},${y}`)) drawGhostCell(ctx, x * cell, y * cell, cell, color);
}
}
active.cells.forEach(([x, y], index) => {
if (y < 0) return;
drawBlock(ctx, x * cell, y * cell, cell, color);
if (active.itemId && index === active.itemIndex) drawItemIcon(ctx, x * cell, y * cell, cell, active.itemId);
});
}
/** Draws a queued/held piece centered in a box of boxCells x 2 cells. */
export function drawPreviewPiece(
ctx: CanvasRenderingContext2D,
piece: PieceInfo,
offsetX: number,
offsetY: number,
boxWidth: number,
boxHeight: number,
cell: number,
dimmed = false,
) {
const shape = pieceShapes[piece.type];
if (!shape) return;
const w = Math.max(...shape.map(([x]) => x)) + 1;
const h = Math.max(...shape.map(([, y]) => y)) + 1;
const ox = offsetX + (boxWidth - w * cell) / 2;
const oy = offsetY + (boxHeight - h * cell) / 2;
const color = dimmed ? '#555555' : (blockColors[piece.type] ?? '#fff');
shape.forEach(([x, y], index) => {
const px = Math.round(ox + x * cell);
const py = Math.round(oy + y * cell);
drawBlock(ctx, px, py, cell, color);
if (piece.itemId && index === piece.itemIndex) drawItemIcon(ctx, px, py, cell, piece.itemId);
});
}
+10
View File
@@ -0,0 +1,10 @@
/** Spawn-orientation shapes by piece type (matches PieceDefinitions on the server), used for previews. */
export const pieceShapes: Record<number, [number, number][]> = {
1: [[0, 0], [1, 0], [2, 0], [3, 0]],
2: [[0, 0], [0, 1], [1, 1], [2, 1]],
3: [[2, 0], [0, 1], [1, 1], [2, 1]],
4: [[0, 0], [1, 0], [0, 1], [1, 1]],
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]],
};
+84
View File
@@ -0,0 +1,84 @@
/**
* Central registry of every graphic the game uses. Replace the files in /public/assets
* (or point these paths elsewhere) to change the look without touching components.
*/
export interface ItemVisual {
/** Icon drawn on board cells, in inventories and item bars. */
icon: string;
/** Fallback name, the server sends the display name with each use. */
name: string;
/** Accent color used for flashes and as icon fallback. */
color: string;
/** Short explanation for the lobby help panel. */
description: string;
}
/** Keyed by the server item id (IItem.Id). Add an entry here when adding an item on the server. */
export const itemVisuals: Record<string, ItemVisual> = {
add_line: {
icon: '/assets/items/add_line.svg',
name: 'Add Line',
color: '#ff4256',
description: 'Pushes a garbage line into the target field.',
},
clear_line: {
icon: '/assets/items/clear_line.svg',
name: 'Clear Line',
color: '#4fe35f',
description: 'Removes the bottom line of the target.',
},
nuke: {
icon: '/assets/items/nuke.svg',
name: 'Wipe Out',
color: '#ffe23f',
description: 'Clears every placed block of the target.',
},
swap: {
icon: '/assets/items/swap.svg',
name: 'Switch',
color: '#3fe3f2',
description: 'Trades your whole field with the target.',
},
blind: {
icon: '/assets/items/blind.svg',
name: 'Blackout',
color: '#c452ff',
description: 'Hides placed blocks and the drop shadow for 4 seconds.',
},
};
export const unknownItem: ItemVisual = {
icon: '/assets/items/unknown.svg',
name: 'Item',
color: '#ffffff',
description: '',
};
export const uiAssets = {
/** Optional logo image. When null the text logo is rendered. */
logo: null as string | null,
favicon: '/assets/favicon.svg',
};
export function itemVisual(id: string): ItemVisual {
return itemVisuals[id] ?? unknownItem;
}
const imageCache = new Map<string, HTMLImageElement>();
/** Returns a (possibly still loading) image for canvas rendering. */
export function getImage(src: string): HTMLImageElement {
let image = imageCache.get(src);
if (!image) {
image = new Image();
image.src = src;
imageCache.set(src, image);
}
return image;
}
export function preloadAssets(): void {
Object.values(itemVisuals).forEach((v) => getImage(v.icon));
getImage(unknownItem.icon);
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Block colors by cell value (see PlayerState.board). Swap these to restyle the blocks.
* Index 0 is the empty cell, 8 is garbage.
*/
export const blockColors: Record<number, string> = {
1: '#3fe3f2',
2: '#3a66ff',
3: '#ff9b26',
4: '#ffe23f',
5: '#4fe35f',
6: '#ff4256',
7: '#c452ff',
8: '#8c8c8c',
};
export const boardColors = {
background: '#000000',
grid: '#141414',
border: '#ffffff',
ghostAlpha: 0.28,
};
+46
View File
@@ -0,0 +1,46 @@
:root {
--color-bg: #000000;
--color-fg: #ffffff;
--color-muted: #9a9a9a;
--color-dim: #3a3a3a;
--color-accent: #ffe23f;
--color-danger: #ff4256;
--color-ok: #4fe35f;
--color-target: #3fe3f2;
--font-pixel: 'Press Start 2P', ui-monospace, 'Courier New', monospace;
--border: 3px solid var(--color-fg);
color-scheme: dark;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
background: var(--color-bg);
color: var(--color-fg);
font-family: var(--font-pixel);
font-size: 12px;
line-height: 1.6;
image-rendering: pixelated;
-webkit-font-smoothing: none;
}
button,
input {
font: inherit;
}
::selection {
background: var(--color-fg);
color: var(--color-bg);
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client", "node"]
},
"include": ["src", "vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Dev server forwards /hub to a locally running game server (dotnet run).
const devServer = process.env.DEV_SERVER_URL ?? 'http://localhost:5080';
export default defineConfig({
plugins: [react()],
build: {
// Hashed bundles go to /static; /assets holds the exchangeable graphics from public/.
assetsDir: 'static',
},
css: {
modules: {
localsConvention: 'camelCaseOnly',
},
},
server: {
proxy: {
'/hub': { target: devServer, ws: true, changeOrigin: true },
},
},
});
+36
View File
@@ -0,0 +1,36 @@
# Both networks must already exist and be attached to the reverse proxy:
# docker network create actionBlocks_website
# docker network create actionBlocks_server
#
# Reverse proxy routing:
# website -> actionblocks-website:80
# game hub -> actionblocks-server:8080, path /hub (needs WebSocket upgrade support:
# "Upgrade" and "Connection" headers must be forwarded).
# If the hub is served from a different host, set SERVER_URL accordingly and list the
# website origin in ALLOWED_ORIGINS.
services:
actionblocks-website:
build: ./actionBlocks_website
container_name: actionblocks-website
restart: unless-stopped
environment:
SERVER_URL: ${SERVER_URL:-}
networks:
- actionBlocks_website
actionblocks-server:
build: ./actionBlocks_server
container_name: actionblocks-server
restart: unless-stopped
environment:
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
HUB_PATH: /hub
networks:
- actionBlocks_server
networks:
actionBlocks_website:
external: true
actionBlocks_server:
external: true