Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
498df86962 | ||
|
|
0913adaca5 | ||
|
|
19428550ca |
@@ -0,0 +1,17 @@
|
||||
# Copy to .env (next to docker-compose.yml) and adjust. Docker Compose reads .env automatically.
|
||||
|
||||
# Full URL the browser uses to reach the game hub through the reverse proxy.
|
||||
# Empty = "<website origin>/hub".
|
||||
SERVER_URL=https://game.example.com/server/hub
|
||||
|
||||
# Prefix the game server should accept when the proxy forwards its sub-path without
|
||||
# stripping it (e.g. /server). Leave empty if the proxy strips the prefix.
|
||||
PATH_BASE=
|
||||
|
||||
# Comma separated origins allowed to connect to the hub. Empty = allow any origin.
|
||||
ALLOWED_ORIGINS=https://game.example.com
|
||||
|
||||
# Optional gameplay tuning, passed to the server as Game__<Setting> (see GameSettings.cs).
|
||||
# Add these under the server's "environment:" in docker-compose.yml to use them.
|
||||
# Game__ItemSpawnChance=0.1
|
||||
# Game__CountdownSeconds=3
|
||||
@@ -0,0 +1 @@
|
||||
.env
|
||||
@@ -0,0 +1,76 @@
|
||||
# Action Blocks
|
||||
|
||||
Online player-vs-player falling-block game with items.
|
||||
|
||||
**Developer documentation:** see [`doc/`](doc/README.md) for architecture, game rules, protocol, frontend, deployment and how-to guides.
|
||||
|
||||
| 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 website uses relative URLs. It works under a sub-path such as `/website/`, whether or not the proxy strips that prefix.
|
||||
|
||||
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`). |
|
||||
| `PATH_BASE` | server | Prefix to accept when the proxy forwards a sub-path unstripped, e.g. `/server`. |
|
||||
| `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`.
|
||||
@@ -0,0 +1,6 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/.vs/
|
||||
**/*.user
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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
|
||||
@@ -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,50 @@
|
||||
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";
|
||||
// Optional prefix when the reverse proxy forwards a sub-path without stripping it, e.g. "/server".
|
||||
// Requests without the prefix keep working.
|
||||
var pathBase = builder.Configuration["PATH_BASE"] ?? "";
|
||||
|
||||
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();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pathBase))
|
||||
{
|
||||
app.UsePathBase(pathBase);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,41 @@
|
||||
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";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ @prefixed;
|
||||
}
|
||||
|
||||
# The reverse proxy may forward a sub-path without stripping it (e.g. /website/static/x.js).
|
||||
# Drop the leading path segment and try again; fall back to the app's index.html.
|
||||
location @prefixed {
|
||||
rewrite ^/[^/]+(/.*)$ $1 last;
|
||||
rewrite ^ /index.html last;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- Relative asset URLs need a trailing slash when served under a sub-path (/website -> /website/). -->
|
||||
<script>
|
||||
if (!/\/$|\.[a-z0-9]+$/i.test(location.pathname)) location.replace(location.pathname + '/' + location.search + location.hash);
|
||||
</script>
|
||||
<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>
|
||||
@@ -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 |
@@ -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: "" };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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.';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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]],
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** Resolves a file in /public relative to where the site is served (works under sub-paths). */
|
||||
const asset = (path: string) => `${import.meta.env.BASE_URL}${path}`;
|
||||
|
||||
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: asset('assets/items/add_line.svg'),
|
||||
name: 'Add Line',
|
||||
color: '#ff4256',
|
||||
description: 'Pushes a garbage line into the target field.',
|
||||
},
|
||||
clear_line: {
|
||||
icon: asset('assets/items/clear_line.svg'),
|
||||
name: 'Clear Line',
|
||||
color: '#4fe35f',
|
||||
description: 'Removes the bottom line of the target.',
|
||||
},
|
||||
nuke: {
|
||||
icon: asset('assets/items/nuke.svg'),
|
||||
name: 'Wipe Out',
|
||||
color: '#ffe23f',
|
||||
description: 'Clears every placed block of the target.',
|
||||
},
|
||||
swap: {
|
||||
icon: asset('assets/items/swap.svg'),
|
||||
name: 'Switch',
|
||||
color: '#3fe3f2',
|
||||
description: 'Trades your whole field with the target.',
|
||||
},
|
||||
blind: {
|
||||
icon: asset('assets/items/blind.svg'),
|
||||
name: 'Blackout',
|
||||
color: '#c452ff',
|
||||
description: 'Hides placed blocks and the drop shadow for 4 seconds.',
|
||||
},
|
||||
};
|
||||
|
||||
export const unknownItem: ItemVisual = {
|
||||
icon: asset('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: asset('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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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()],
|
||||
// Relative URLs so the site works under any sub-path (e.g. https://example.com/website/).
|
||||
base: './',
|
||||
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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
# Action Blocks – Developer Documentation
|
||||
|
||||
Action Blocks is an online player-vs-player falling-block game. Players clear lines to send garbage to each other. They also collect **items** hidden inside pieces and use them on a player of their choice.
|
||||
|
||||
The project consists of two deployable components:
|
||||
|
||||
| Component | Folder | Technology | Responsibility |
|
||||
|---|---|---|---|
|
||||
| Game server | `actionBlocks_server/` | C# / ASP.NET Core 10, SignalR | Runs all game logic, manages lobbies and players, pushes game state to clients |
|
||||
| Website | `actionBlocks_website/` | React 19, TypeScript, Vite, CSS Modules, nginx | Renders the game, captures keyboard input, sends it to the server |
|
||||
|
||||
## Reading order
|
||||
|
||||
New to the project? Read these in order:
|
||||
|
||||
1. [Architecture](architecture.md): how the pieces fit together, and why the server is authoritative.
|
||||
2. [Game rules](game-rules.md): what the game actually does, with all numbers.
|
||||
3. [Server](server.md): a walkthrough of the C# code.
|
||||
4. [Protocol](protocol.md): the SignalR API between website and server.
|
||||
5. [Frontend](frontend.md): a walkthrough of the React code.
|
||||
6. [Development](development.md): running, testing and debugging locally.
|
||||
7. [Deployment](deployment.md): Docker, compose, reverse proxy and environment variables.
|
||||
8. [Extending](extending.md): step-by-step guides for common changes (new item, new rule, new graphics, …).
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
.
|
||||
├── actionBlocks_server/ C# solution (game logic + web host + tests)
|
||||
│ ├── src/ActionBlocks.Game/ Pure game logic library (no ASP.NET dependency)
|
||||
│ ├── src/ActionBlocks.Server/ ASP.NET Core host: SignalR hub, lobbies, tick loop
|
||||
│ ├── tests/ActionBlocks.Game.Tests/ xUnit tests for the game logic
|
||||
│ └── Dockerfile
|
||||
├── actionBlocks_website/ React frontend
|
||||
│ ├── src/ Application code
|
||||
│ ├── public/ Static files copied as-is (graphics, config.js)
|
||||
│ ├── docker/ nginx config + container start script
|
||||
│ └── Dockerfile
|
||||
├── doc/ This documentation
|
||||
├── docker-compose.yml Runs both containers
|
||||
├── .env.example Template for deployment variables
|
||||
├── instructions.md The original project brief
|
||||
└── tetris_instructions/ Reference material for the base rules (saved web page)
|
||||
```
|
||||
|
||||
## Glossary
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **Board / field** | The 10×20 grid of a player. Internally it has 20 extra hidden rows above (the *buffer*). |
|
||||
| **Piece** | One of the 7 four-cell shapes (internally named I, J, L, O, S, Z, T). |
|
||||
| **Mino** | One of the four cells of a piece. |
|
||||
| **Active piece** | The piece currently falling and controlled by the player. |
|
||||
| **Ghost** | Preview of where the active piece would land. In the UI it is called the "drop shadow". |
|
||||
| **Hold** | Slot where a player can park one piece. It can be used once per piece. |
|
||||
| **Garbage** | Gray lines with one hole that rise from the bottom. They are sent by opponents' line clears. |
|
||||
| **Item** | A special ability attached to one mino of a piece. You collect it by clearing that mino's row. |
|
||||
| **Target** | The player your items are used on. It is selected with the number keys. |
|
||||
| **Snapshot** | The full match state the server pushes to all clients many times per second. |
|
||||
| **Lobby** | A group of players sharing a 6-character code. It hosts consecutive matches. |
|
||||
| **Match** | One round inside a lobby, from countdown to winner. |
|
||||
|
||||
> Naming note: the project intentionally avoids the trademarked name of the original game in all user-facing text and graphics. Internally, the standard single-letter piece names (I, J, L, …) are used because they are the common technical vocabulary.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Browser
|
||||
UI[React app<br/>pages + canvas rendering]
|
||||
KB[Keyboard input<br/>DAS / ARR]
|
||||
HC[HubClient<br/>@microsoft/signalr]
|
||||
KB --> HC
|
||||
HC --> UI
|
||||
end
|
||||
|
||||
subgraph Proxy[Reverse proxy - existing]
|
||||
end
|
||||
|
||||
subgraph WebsiteContainer[actionblocks-website - nginx]
|
||||
Static[index.html, JS, CSS,<br/>assets, config.js]
|
||||
end
|
||||
|
||||
subgraph ServerContainer[actionblocks-server - ASP.NET Core]
|
||||
Hub[GameHub]
|
||||
LM[LobbyManager]
|
||||
MR[MatchRunner<br/>60 Hz loop]
|
||||
GL[ActionBlocks.Game<br/>Match / PlayerGame]
|
||||
Hub --> LM
|
||||
MR --> LM
|
||||
LM --> GL
|
||||
MR --> GL
|
||||
end
|
||||
|
||||
Browser -- HTTPS --> Proxy
|
||||
Proxy -- static files --> WebsiteContainer
|
||||
Proxy -- WebSocket /hub --> ServerContainer
|
||||
```
|
||||
|
||||
The **website container** only serves static files. All game communication goes from the browser straight to the **game server**, through the reverse proxy, over a SignalR WebSocket connection. The two containers never talk to each other. Each one sits in its own Docker network (`actionBlocks_website` and `actionBlocks_server`), and both networks are attached to the reverse proxy.
|
||||
|
||||
## Server-authoritative design
|
||||
|
||||
The server owns the complete game state. The browser never simulates the game. It only:
|
||||
|
||||
1. sends **input commands** (`MoveLeft`, `HardDrop`, `UseItem`, …), and
|
||||
2. **renders** the snapshots it receives.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant H as GameHub
|
||||
participant M as Match (under lobby lock)
|
||||
participant R as MatchRunner (60 Hz)
|
||||
|
||||
B->>H: Input("MoveLeft")
|
||||
H->>M: HandleInput(playerId, MoveLeft)
|
||||
Note over H: lobby.Dirty = true
|
||||
loop every ~16.7 ms
|
||||
R->>M: Update(delta)
|
||||
R->>M: DrainEvents()
|
||||
alt every 2nd tick, or Dirty, or events present
|
||||
R-->>B: MatchState(snapshot)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Why this approach:**
|
||||
|
||||
- **One source of truth.** Interactions between players (garbage, item effects, board swaps) are resolved in one place. Nothing ever needs to be reconciled.
|
||||
- **Cheating is mostly impossible.** Clients cannot fake line clears, items or piece positions.
|
||||
- **Game logic is easy to change.** All rules live in one C# library with unit tests.
|
||||
|
||||
**Trade-off:** input feels as responsive as the network round trip allows. There is no client-side prediction. On a LAN or a good connection (< 50 ms) this is fine. For high-latency play, the natural next step would be local prediction of the player's own active piece. See [Known limitations](#known-limitations).
|
||||
|
||||
To keep the feel responsive anyway:
|
||||
|
||||
- **Auto-repeat on the client.** Delayed auto shift (DAS) and the auto-repeat rate (ARR) for held left/right keys run in the browser. The browser sends discrete `MoveLeft` / `MoveRight` commands at the repeat rate.
|
||||
- **Extra snapshot after input.** Any input marks the lobby *dirty*. The next tick (≤ 16.7 ms later) then always sends a snapshot, even when it isn't a regular broadcast tick.
|
||||
|
||||
## Server internals at a glance
|
||||
|
||||
The C# solution separates **game rules** from **hosting**:
|
||||
|
||||
| Project | Depends on | Contains |
|
||||
|---|---|---|
|
||||
| `ActionBlocks.Game` | nothing (plain .NET) | Board, pieces, rotation system, randomizer, `PlayerGame`, `Match`, garbage, items, targeting |
|
||||
| `ActionBlocks.Server` | `ActionBlocks.Game`, ASP.NET Core | SignalR hub, lobbies, tick loop, network DTOs |
|
||||
| `ActionBlocks.Game.Tests` | `ActionBlocks.Game`, xUnit | Unit tests |
|
||||
|
||||
Because `ActionBlocks.Game` knows nothing about networking, it can be unit tested in isolation and reused, for example for bots or a different transport.
|
||||
|
||||
Rules are modular through interfaces. Swap an implementation to change a rule without touching the rest:
|
||||
|
||||
| Interface | Default implementation | Decides |
|
||||
|---|---|---|
|
||||
| `IRotationSystem` | `SuperRotationSystem` | How pieces rotate and kick off walls/floor |
|
||||
| `IRandomizer` | `SevenBagRandomizer` | The piece sequence |
|
||||
| `IGarbageCalculator` | `StandardGarbageCalculator` | How many lines an attack sends |
|
||||
| `IScoreCalculator` | `StandardScoreCalculator` | Points per clear |
|
||||
| `IGarbageTargeter` | `CyclingGarbageTargeter` | Who receives garbage |
|
||||
| `IItem` | 5 item classes | What an item does |
|
||||
|
||||
Numeric rules (board size, lock delay, item chance, …) live in the `GameSettings` record. They can be overridden through configuration without recompiling.
|
||||
|
||||
## Concurrency model
|
||||
|
||||
- `LobbyManager` stores lobbies in a `ConcurrentDictionary`.
|
||||
- Each `Lobby` has a `Sync` object. **Every access to a lobby and its `Match` happens inside `lock (lobby.Sync)`**: hub calls (input, join, leave, start) and the tick loop.
|
||||
- `Match` and `PlayerGame` are deliberately **not thread-safe**. They rely on the lobby lock.
|
||||
- A single background service, `MatchRunner`, ticks all lobbies sequentially at 60 Hz. Network sends happen **outside** the lock and are fire-and-forget, so a slow client never blocks the game loop.
|
||||
|
||||
## State lifetime
|
||||
|
||||
All state is **in memory**: there is no database. If the server process restarts, every lobby and match is lost. If a player's connection drops, they are removed from their lobby and forfeit any running match. Reconnecting creates a new connection, so the player has to join again.
|
||||
|
||||
## Known limitations
|
||||
|
||||
These are conscious simplifications. They are good candidates for future work:
|
||||
|
||||
| Limitation | Impact | Possible improvement |
|
||||
|---|---|---|
|
||||
| No client-side prediction | Movement latency equals the network round trip | Predict the own active piece locally and reconcile with snapshots |
|
||||
| Full JSON snapshots of all players at ~30–60 Hz | About 1.5 KB per snapshot for 2 players. It grows linearly with players | Send deltas, or use the MessagePack protocol, or send opponents at a lower rate |
|
||||
| Blackout (invisibility) is hidden client-side | A modified client could still show the board | Strip the board from that player's personal snapshot on the server |
|
||||
| No reconnect into a running lobby | A network hiccup kicks the player | Issue a session token and re-bind to the player on reconnect |
|
||||
| In-memory lobbies, single server instance | No horizontal scaling | Use a SignalR backplane plus external state, or sticky sessions per lobby |
|
||||
| Max 8 players per lobby | Matches the number keys 1–9 (1 = self) | Raise `Lobby.MaxPlayers`; the UI would need paging for targets |
|
||||
@@ -0,0 +1,129 @@
|
||||
# Deployment
|
||||
|
||||
Both components ship as Docker images and run together through `docker-compose.yml`. An already existing **reverse proxy** handles TLS and routing. Compose therefore publishes no ports.
|
||||
|
||||
## Topology
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[Browser] -->|HTTPS| P[Reverse proxy]
|
||||
P -->|network actionBlocks_website| W[actionblocks-website :80<br/>nginx, static files]
|
||||
P -->|network actionBlocks_server<br/>WebSocket| S[actionblocks-server :8080<br/>ASP.NET Core]
|
||||
```
|
||||
|
||||
| Container | Image built from | Port | Docker network |
|
||||
|---|---|---|---|
|
||||
| `actionblocks-website` | `actionBlocks_website/Dockerfile` | 80 | `actionBlocks_website` (external) |
|
||||
| `actionblocks-server` | `actionBlocks_server/Dockerfile` | 8080 | `actionBlocks_server` (external) |
|
||||
|
||||
Both networks are declared `external: true`. They must exist beforehand and be attached to the proxy:
|
||||
|
||||
```bash
|
||||
docker network create actionBlocks_website
|
||||
docker network create actionBlocks_server
|
||||
```
|
||||
|
||||
## Images
|
||||
|
||||
### Server (`actionBlocks_server/Dockerfile`)
|
||||
|
||||
1. `mcr.microsoft.com/dotnet/sdk:10.0` restores the packages, **runs the unit tests** (a failing test fails the build), then publishes in Release mode.
|
||||
2. `mcr.microsoft.com/dotnet/aspnet:10.0` runs `dotnet ActionBlocks.Server.dll` as the non-root `$APP_UID` user on port 8080.
|
||||
|
||||
### Website (`actionBlocks_website/Dockerfile`)
|
||||
|
||||
1. `node:22-alpine` runs `npm ci && npm run build` (typecheck + Vite build into `dist/`).
|
||||
2. `nginx:1.27-alpine` serves `dist/` with `docker/nginx.conf`.
|
||||
3. `docker/40-runtime-config.sh` is placed in `/docker-entrypoint.d/`. The official nginx image runs that directory's scripts at **every container start**. The script writes `config.js` from the `SERVER_URL` env var, so the same image works in any environment without a rebuild.
|
||||
|
||||
nginx behaviour (`docker/nginx.conf`):
|
||||
|
||||
| Path | Behaviour |
|
||||
|---|---|
|
||||
| `/static/…` | Hashed bundles, cached for a year (`immutable`) |
|
||||
| `/assets/…` | Exchangeable graphics, `no-cache` (revalidated, so replaced files appear immediately) |
|
||||
| `/config.js`, `/index.html` | Never cached |
|
||||
| anything else | File if it exists. Otherwise the first path segment is stripped and the lookup retried (this supports proxies that do not strip a sub-path). Finally falls back to `index.html` |
|
||||
|
||||
## Configuration
|
||||
|
||||
Compose reads a `.env` file next to `docker-compose.yml`. Copy the template and edit it:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
| Variable | Used by | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `SERVER_URL` | website | empty → `<site origin>/hub` | Full hub URL **as the browser sees it**, e.g. `https://game.example.com/server/hub` |
|
||||
| `ALLOWED_ORIGINS` | server | empty → any origin | Comma-separated list of origins allowed to connect, e.g. `https://game.example.com` |
|
||||
| `PATH_BASE` | server | empty | Prefix to accept if the proxy forwards a sub-path **without stripping it**, e.g. `/server` |
|
||||
| `HUB_PATH` | server | `/hub` | Hub endpoint path. It is fixed to `/hub` in the compose file |
|
||||
| `Game__<Setting>` | server | see `GameSettings.cs` | Overrides gameplay values, e.g. `Game__ItemSpawnChance=0.2`. Add these under the server's `environment:` in the compose file |
|
||||
|
||||
Start or update:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker exec actionblocks-website cat /usr/share/nginx/html/config.js # shows SERVER_URL
|
||||
curl https://<your-host>/<server-path>/health # {"status":"ok"} (the server image has no curl/wget, so test through the proxy)
|
||||
```
|
||||
|
||||
## Reverse proxy requirements
|
||||
|
||||
1. Route the website to `actionblocks-website:80`.
|
||||
2. Route the hub to `actionblocks-server:8080`, with **WebSocket upgrade** support. The `Upgrade` and `Connection` headers must be forwarded, and read timeouts must be long because the connection stays open for the whole session.
|
||||
3. If the hub runs on a different origin than the site, list the site's origin in `ALLOWED_ORIGINS`.
|
||||
|
||||
### Sub-paths
|
||||
|
||||
Serving under paths such as `https://game.example.com/website/` and `/server/` is supported:
|
||||
|
||||
- **Website:** works whether the proxy strips `/website` or not. All URLs in the build are relative, and nginx retries unknown paths without their first segment.
|
||||
- **Server:**
|
||||
- If the proxy strips `/server`, nothing else is needed. The hub answers at `/hub`.
|
||||
- If it does not strip the prefix, set `PATH_BASE=/server`.
|
||||
- Set `SERVER_URL` to the public hub URL, e.g. `https://game.example.com/server/hub`.
|
||||
|
||||
### Example: nginx as the reverse proxy
|
||||
|
||||
```nginx
|
||||
# Website under /website/ (prefix stripped by the trailing slash in proxy_pass)
|
||||
location /website/ {
|
||||
proxy_pass http://actionblocks-website:80/;
|
||||
}
|
||||
|
||||
# Game server under /server/ (prefix stripped)
|
||||
location /server/ {
|
||||
proxy_pass http://actionblocks-server:8080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
```
|
||||
|
||||
Matching `.env`:
|
||||
|
||||
```env
|
||||
SERVER_URL=https://game.example.com/server/hub
|
||||
ALLOWED_ORIGINS=https://game.example.com
|
||||
PATH_BASE=
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Browser console: `Refused to apply style … MIME type ('')`, 404 for `config.js` / `index-*.js` | Assets requested from the wrong path, e.g. an old image built with absolute URLs, or the proxy doesn't route the sub-path | Rebuild the website image, check the proxy route |
|
||||
| Red banner "Server unreachable, retrying..." | `SERVER_URL` wrong, hub not routed, or WebSocket upgrade blocked | Open `<SERVER_URL>/negotiate?negotiateVersion=1` with a POST (e.g. `curl -X POST`); it must return JSON. Check the upgrade headers on the proxy |
|
||||
| CORS error in the console | Site origin not in `ALLOWED_ORIGINS` | Add the exact origin (scheme + host + port) |
|
||||
| Works, then players are kicked after ~60 s idle | Proxy read timeout too short | Raise the proxy's timeout for the hub route |
|
||||
| Changed `.env` has no effect | Containers not recreated | `docker compose up -d` (recreates containers with new env) |
|
||||
| All lobbies vanish | Server container restarted | Expected: state is in memory only |
|
||||
@@ -0,0 +1,88 @@
|
||||
# Development
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Used for |
|
||||
|---|---|---|
|
||||
| .NET SDK | 10.0 | Server and tests |
|
||||
| Node.js + npm | 22 | Website |
|
||||
| Docker + Compose | recent | Optional, for container builds |
|
||||
|
||||
## Running locally
|
||||
|
||||
Two terminals:
|
||||
|
||||
```bash
|
||||
# 1. Game server on http://localhost:5080
|
||||
cd actionBlocks_server
|
||||
dotnet run --project src/ActionBlocks.Server --urls http://localhost:5080
|
||||
|
||||
# 2. Website on http://localhost:5173
|
||||
cd actionBlocks_website
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The Vite dev server proxies `/hub` (including WebSockets) to `http://localhost:5080`, so the default hub URL `<origin>/hub` just works. To point it at another server, use `DEV_SERVER_URL=http://host:port npm run dev`.
|
||||
|
||||
To play against yourself, open the site in two browser windows. Create a lobby in one and join with the code in the other. Keyboard focus decides which window receives input.
|
||||
|
||||
### Handy server settings for testing
|
||||
|
||||
Any `GameSettings` value can be overridden with an environment variable:
|
||||
|
||||
```bash
|
||||
Game__ItemSpawnChance=0.9 Game__CountdownSeconds=1 \
|
||||
dotnet run --project src/ActionBlocks.Server --urls http://localhost:5080
|
||||
```
|
||||
|
||||
That gives items on almost every piece and a near-instant start, which is useful for testing items.
|
||||
|
||||
## Commands
|
||||
|
||||
| Where | Command | Does |
|
||||
|---|---|---|
|
||||
| `actionBlocks_server/` | `dotnet build` | Builds all projects |
|
||||
| | `dotnet test` | Runs the game logic tests |
|
||||
| `actionBlocks_website/` | `npm run dev` | Dev server with hot reload |
|
||||
| | `npm run typecheck` | TypeScript check only |
|
||||
| | `npm run build` | Typecheck + production build into `dist/` |
|
||||
| | `npm run preview` | Serves `dist/` locally |
|
||||
| repo root | `docker compose build` | Builds both images (server image runs the tests) |
|
||||
| | `docker compose config` | Validates the compose file and `.env` |
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Game rules** live in `ActionBlocks.Game` and are covered by unit tests. When you change or add a rule, add a test in `tests/ActionBlocks.Game.Tests`. Use `TestHelpers.CreatePlayer` for a deterministic player: it takes a fixed piece sequence and an item chance (0 or 1). Use `FillRow` to build board situations.
|
||||
- **Networking and lobby flow** has no automated tests yet. Test it manually, or with a small script client (see [Protocol → Writing a test client](protocol.md#writing-a-test-client)).
|
||||
- **Bots are an effective test tool.** A script client that joins a lobby and plays with a simple heuristic shows how garbage and items work under real conditions: it clears lines, collects items and uses them on you. One works like this:
|
||||
1. Read your board from the snapshot.
|
||||
2. For every rotation and x position of the active piece, simulate dropping it and score the result: lines cleared minus holes, height and bumpiness.
|
||||
3. Send the best move's `RotateCw` / `MoveLeft` / `MoveRight` / `HardDrop` inputs.
|
||||
4. Send `UseItem` whenever the inventory is not empty.
|
||||
- **Frontend** has no unit tests. `npm run build` (which includes the typecheck) catches type errors, and visual checks happen in the browser.
|
||||
|
||||
## Debugging tips
|
||||
|
||||
- **Server logs** go to stdout. Exceptions in the tick loop are logged per lobby as `Failed to update lobby {Code}`.
|
||||
- **Inspect live snapshots.** In the browser devtools, open Network → WS → the `/hub` connection → Messages. Each `MatchState` frame shows the full state, and its records are separated by `\u001e`.
|
||||
- `GET /health` on the server tells you whether the process is up.
|
||||
- **Is the hub reachable?** `curl -X POST "<hub-url>/negotiate?negotiateVersion=1"` must return JSON listing the available transports.
|
||||
|
||||
## Code conventions
|
||||
|
||||
**Server**
|
||||
|
||||
- File-scoped namespaces, nullable reference types enabled, one main type per file. Small closely related types may share a file, e.g. all five items in `Items.cs`.
|
||||
- `ActionBlocks.Game` must stay free of ASP.NET or serialization dependencies. Mapping to the wire format happens in `ActionBlocks.Server/Dtos`.
|
||||
- Game objects are not thread-safe. Only touch them inside `lock (lobby.Sync)`.
|
||||
- Prefer adding a value to `GameSettings`, or an implementation of an existing interface, over hard-coding numbers in logic.
|
||||
|
||||
**Website**
|
||||
|
||||
- Function components and hooks. No state library: server state is owned by `useGameConnection`.
|
||||
- Styling only through `*.module.css`, plus the CSS variables in `theme/global.css`. Don't use inline styles except for computed sizes and colors.
|
||||
- Reference graphics and colors only through `theme/assets.ts` and `theme/colors.ts`.
|
||||
- Keep `net/types.ts` in sync with the server DTOs.
|
||||
- UI text in the pixel font: ASCII only.
|
||||
- No trademarked names in user-facing text or assets.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Extending the Game
|
||||
|
||||
Step-by-step guides for the most common changes.
|
||||
|
||||
- [Add a new item](#add-a-new-item)
|
||||
- [Change a numeric rule](#change-a-numeric-rule)
|
||||
- [Replace a rule implementation](#replace-a-rule-implementation)
|
||||
- [Add new state to the snapshot](#add-new-state-to-the-snapshot)
|
||||
- [Add a new hub method](#add-a-new-hub-method)
|
||||
- [Add a new match event](#add-a-new-match-event)
|
||||
- [Change graphics and colors](#change-graphics-and-colors)
|
||||
- [Change key bindings](#change-key-bindings)
|
||||
|
||||
---
|
||||
|
||||
## Add a new item
|
||||
|
||||
Example: a **"Speed Up"** item that makes the target's pieces fall fast for 5 seconds.
|
||||
|
||||
### 1. Server: implement `IItem`
|
||||
|
||||
Add a class in `actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs`, or in a new file in that folder:
|
||||
|
||||
```csharp
|
||||
public sealed class SpeedUpItem : IItem
|
||||
{
|
||||
public string Id => "speed"; // stable id, also used by the frontend
|
||||
public string DisplayName => "Speed Up";
|
||||
public int SpawnWeight => 1; // optional, default 1 (higher = more common)
|
||||
|
||||
public void Apply(ItemContext context) =>
|
||||
context.Target.FastUntil = Math.Max(context.Target.FastUntil, context.Now + 5);
|
||||
}
|
||||
```
|
||||
|
||||
`ItemContext` gives you the `User`, the `Target`, a shared `Random` and the match time `Now`. Use `context.IsSelfTarget` to special-case self-use.
|
||||
|
||||
### 2. Server: add any new state the item needs
|
||||
|
||||
If existing `PlayerGame` methods (`InsertGarbageNow`, `RemoveBottomLine`, `ClearBoard`, `SwapBoards`, `InvisibleUntil`) aren't enough, add a property or method to `PlayerGame`. For the example:
|
||||
|
||||
```csharp
|
||||
public double FastUntil { get; set; }
|
||||
// in Update(): if (Now < FastUntil) rowsPerSecond *= 4;
|
||||
```
|
||||
|
||||
If the board changes under the active piece, call `ResolveActiveOverlap()` afterwards. That method is private, so add your board-changing method to `PlayerGame` itself, as the existing item hooks do.
|
||||
|
||||
### 3. Server: register it
|
||||
|
||||
In `Items/ItemRegistry.cs`:
|
||||
|
||||
```csharp
|
||||
public static ItemRegistry Default { get; } = new(
|
||||
[
|
||||
new AddGarbageLineItem(),
|
||||
...
|
||||
new SpeedUpItem(),
|
||||
]);
|
||||
```
|
||||
|
||||
### 4. Server: test it
|
||||
|
||||
Add a test to `tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs`, and update `Registry_contains_five_unique_items` to the new count.
|
||||
|
||||
### 5. Website: add the visuals
|
||||
|
||||
Add an icon at `actionBlocks_website/public/assets/items/speed.svg`. Any image format works; the existing icons are 8×8 pixel-art SVGs. Then add an entry to `itemVisuals` in `src/theme/assets.ts`:
|
||||
|
||||
```ts
|
||||
speed: {
|
||||
icon: asset('assets/items/speed.svg'),
|
||||
name: 'Speed Up',
|
||||
color: '#ff9b26',
|
||||
description: 'Target pieces fall very fast for 5 seconds.',
|
||||
},
|
||||
```
|
||||
|
||||
The id key must match `IItem.Id`. Unknown ids still work but show `unknown.svg`.
|
||||
|
||||
### 6. Website: show the effect (only if needed)
|
||||
|
||||
If the item needs a visual effect, as Blackout does, expose the state in the snapshot (see [below](#add-new-state-to-the-snapshot)) and use it in `GamePage` / `draw.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Change a numeric rule
|
||||
|
||||
All numbers are in `ActionBlocks.Game/GameSettings.cs`. There are two ways to change one:
|
||||
|
||||
- **Change the default** in `GameSettings.cs` and rebuild.
|
||||
- **Override it per deployment** without rebuilding: set `Game__<Property>` on the server container, e.g. in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
actionblocks-server:
|
||||
environment:
|
||||
Game__ItemSpawnChance: "0.15"
|
||||
Game__MaxInventory: "6"
|
||||
Game__LockDelaySeconds: "0.6"
|
||||
```
|
||||
|
||||
To add a **new** setting, add an `init` property with a default to `GameSettings` and read it where needed. Configuration binding picks it up automatically.
|
||||
|
||||
If you change the board width or height, the website adapts automatically, because `MatchState.width`/`height` drive the rendering.
|
||||
|
||||
---
|
||||
|
||||
## Replace a rule implementation
|
||||
|
||||
The rules sit behind interfaces (see [Architecture](architecture.md#server-internals-at-a-glance)). To swap one, implement the interface and pass it where `Match` builds the players. That happens in the `Match` constructor (`Match.cs`):
|
||||
|
||||
```csharp
|
||||
var rotation = new SuperRotationSystem(); // IRotationSystem
|
||||
var garbage = new StandardGarbageCalculator(); // IGarbageCalculator
|
||||
var score = new StandardScoreCalculator(); // IScoreCalculator
|
||||
... new SevenBagRandomizer(new Random(pieceSeed)) // IRandomizer
|
||||
```
|
||||
|
||||
The garbage targeter can already be passed to the `Match` constructor (`IGarbageTargeter? garbageTargeter`). For example, to send garbage to the player with the highest score, implement `IGarbageTargeter.NextTarget`. Then, in `Lobby.StartMatch`, pass `new Match(players, settings, garbageTargeter: new MyTargeter())`.
|
||||
|
||||
If you want rules selectable per lobby, add constructor parameters to `Match` in the same style and choose them in `Lobby.StartMatch`.
|
||||
|
||||
---
|
||||
|
||||
## Add new state to the snapshot
|
||||
|
||||
1. **Game:** expose the value as a public property on `PlayerGame` (or `Match`).
|
||||
2. **DTO:** add a parameter to `PlayerStateDto` (or `MatchStateDto`) in `ActionBlocks.Server/Dtos/Dtos.cs`.
|
||||
3. **Mapper:** fill it in `SnapshotMapper.ToDto`.
|
||||
4. **Website types:** add the camelCase field to `PlayerState` (or `MatchState`) in `src/net/types.ts`.
|
||||
5. **Website UI:** use it in `GamePage`, `MiniBoard` or `draw.ts`.
|
||||
|
||||
Keep snapshots small. They are sent ~30 times per second for every player to every client.
|
||||
|
||||
**Private information.** Snapshots go to everyone in the lobby. Don't put anything there that opponents must not see, such as the selected target. If you need per-player private data, send a separate message to `Clients.Client(connectionId)` from `MatchRunner`.
|
||||
|
||||
---
|
||||
|
||||
## Add a new hub method
|
||||
|
||||
1. Add a public method to `GameHub`. If it touches a lobby, use `WithPlayer(...)` or `lock (lobby.Sync)`. Throw `HubException` with a user-readable message on failure.
|
||||
2. Add a wrapper to `HubClient` in `src/net/hubClient.ts`. Use `invoke` when you need a result or an error, `send` for fire-and-forget.
|
||||
3. Expose it from `useGameConnection` in its `actions` object.
|
||||
4. Document it in [Protocol](protocol.md).
|
||||
|
||||
---
|
||||
|
||||
## Add a new match event
|
||||
|
||||
1. Add a record deriving from `MatchEvent` in `Match.cs` and add it to `_events` where it happens. If the source is inside `PlayerGame`, collect it there and drain it in `Match.AfterStateChange`, the same way outgoing attacks are drained.
|
||||
2. Map it in `SnapshotMapper.ToDto(MatchEvent)` to a `MatchEventDto` with a new `type` string, adding DTO fields if needed.
|
||||
3. Handle the `type` in `useGameConnection`'s `onMatchState`, e.g. to show a "DOUBLE!" popup.
|
||||
|
||||
---
|
||||
|
||||
## Change graphics and colors
|
||||
|
||||
| Change | Edit |
|
||||
|---|---|
|
||||
| Item icon | Replace the file in `public/assets/items/`, or point `itemVisuals[id].icon` elsewhere |
|
||||
| Logo | Put an image in `public/assets/`, set `uiAssets.logo: asset('assets/logo.png')` |
|
||||
| Block colors | `src/theme/colors.ts` (`blockColors[1..7]` pieces, `[8]` garbage) |
|
||||
| Block style (bevel, texture, …) | `drawBlock` in `src/render/draw.ts` |
|
||||
| Ghost look | `drawGhostCell` in `draw.ts`, `boardColors.ghostAlpha` |
|
||||
| UI colors, borders, font | CSS variables in `src/theme/global.css` |
|
||||
| A component's layout | Its `*.module.css` file |
|
||||
|
||||
In production, the files in `public/assets/` are served with `no-cache`, so replaced images show up right after a redeploy.
|
||||
|
||||
---
|
||||
|
||||
## Change key bindings
|
||||
|
||||
Edit `src/config/keybindings.ts`:
|
||||
|
||||
- `keyBindings` maps each action to a list of `KeyboardEvent.code` values. Use physical codes such as `KeyW` or `ArrowLeft`; a site like keycode.info shows them.
|
||||
- `targetKeys` lists the keys for target slots 1–9.
|
||||
- `DAS_MS` / `ARR_MS` set the auto-repeat timing.
|
||||
- `controlsHelp` holds the texts shown in the lobby's Controls panel. Keep them ASCII.
|
||||
|
||||
To add a new **action**, add it to `BoundAction` and `keyBindings`. If it maps 1:1 to a server action, add it to `simpleActions`. A new server action also needs an `InputAction` value on the server, handled in `PlayerGame.HandleInput` or `Match.HandleInput`, plus the matching string in `src/net/types.ts`.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Frontend (`actionBlocks_website/`)
|
||||
|
||||
React 19 + TypeScript, bundled with Vite. Styling uses **CSS Modules only**: every component has a `*.module.css` next to it. The only global stylesheet is `src/theme/global.css`, which holds design tokens and the base reset. The game boards are drawn on `<canvas>`.
|
||||
|
||||
```
|
||||
actionBlocks_website/
|
||||
├── index.html # trailing-slash redirect, loads config.js and the app
|
||||
├── vite.config.ts # base './', output dir 'static', dev proxy for /hub
|
||||
├── public/ # copied to the build output unchanged
|
||||
│ ├── config.js # runtime config (overwritten in the container)
|
||||
│ └── assets/ # exchangeable graphics
|
||||
│ ├── favicon.svg
|
||||
│ └── items/*.svg # one icon per item id (+ unknown.svg)
|
||||
├── docker/
|
||||
│ ├── nginx.conf
|
||||
│ └── 40-runtime-config.sh # writes config.js from env SERVER_URL
|
||||
└── src/
|
||||
├── main.tsx # entry: preload assets, render <App/>
|
||||
├── App.tsx # top-level screen switch
|
||||
├── config/
|
||||
│ ├── runtimeConfig.ts # resolves the hub URL
|
||||
│ └── keybindings.ts # all key bindings, DAS/ARR timings, help texts
|
||||
├── net/
|
||||
│ ├── types.ts # TypeScript mirror of the server DTOs
|
||||
│ ├── hubClient.ts # typed SignalR wrapper
|
||||
│ └── useGameConnection.ts # React hook: connection + all server state
|
||||
├── input/useKeyboardInput.ts # keyboard → game actions (with DAS/ARR)
|
||||
├── render/
|
||||
│ ├── draw.ts # canvas drawing: blocks, items, ghost, board, previews
|
||||
│ └── shapes.ts # piece shapes for previews
|
||||
├── theme/
|
||||
│ ├── global.css # CSS variables (colors, font), base styles
|
||||
│ ├── colors.ts # block colors used on canvas
|
||||
│ └── assets.ts # registry of every graphic + item visuals
|
||||
├── pages/ # HomePage, LobbyPage, GamePage (+ .module.css)
|
||||
└── components/ # BoardCanvas, MiniBoard, PiecePreview, ItemBar,
|
||||
# ItemFlashOverlay, Logo, ui/Button, ui/Panel
|
||||
```
|
||||
|
||||
## Data flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Hub[HubClient] -- LobbyUpdated / MatchState --> Hook[useGameConnection]
|
||||
Hook -- status, playerId, lobby, match, flashes --> App
|
||||
App -->|no lobby| Home[HomePage]
|
||||
App -->|lobby waiting| Lobby[LobbyPage]
|
||||
App -->|match present| Game[GamePage]
|
||||
Game --> KB[useKeyboardInput]
|
||||
KB -- sendInput / selectTarget --> Hook
|
||||
Hook --> Hub
|
||||
```
|
||||
|
||||
The app has **no router and no global store**. All server-derived state lives in a single hook, `useGameConnection`. `App` picks the screen from that state:
|
||||
|
||||
| State | Screen |
|
||||
|---|---|
|
||||
| no lobby or no player id | `HomePage`: enter a name, create a lobby or join one by code |
|
||||
| `match` present | `GamePage` |
|
||||
| lobby `phase === 'playing'` but no snapshot yet | "Starting..." placeholder |
|
||||
| otherwise | `LobbyPage`: code, invite link, players, Start (host), last results, controls and item help |
|
||||
|
||||
### `useGameConnection` (`src/net/useGameConnection.ts`)
|
||||
|
||||
- Creates one `HubClient` on mount and starts it. If the connection fails it retries every 2 s.
|
||||
- Keeps `status`, `playerId`, `lobby`, `match` and `flashes` in React state.
|
||||
- On `LobbyUpdated`, it stores the lobby. When the phase is `waiting`, it also clears `match`, which returns the UI to the lobby screen.
|
||||
- On `MatchState`, it stores the snapshot. Every `itemUsed` event becomes an **`ItemFlash`** `{ key, targetId, text, color }`, which is removed again after 1.4 s.
|
||||
- When the connection is lost it clears lobby, match and player id. The server has removed the player by then (see [Architecture](architecture.md#state-lifetime)).
|
||||
- It exposes stable action callbacks: `createLobby`, `joinLobby`, `leaveLobby`, `startGame`, `sendInput`, `selectTarget`.
|
||||
|
||||
### `HubClient` (`src/net/hubClient.ts`)
|
||||
|
||||
A thin wrapper around `@microsoft/signalr`:
|
||||
|
||||
- Request/response methods (`createLobby`, `joinLobby`, `leaveLobby`, `startGame`) use `invoke`. They turn server errors into a plain `Error` carrying the `HubException` message.
|
||||
- Streaming input (`input`, `selectTarget`) uses `send`, which is fire-and-forget. These calls are dropped while disconnected.
|
||||
|
||||
In development, React StrictMode mounts the hook twice. The first connection is stopped while negotiating, and the console logs `Failed to start the connection: … stopped during negotiation`. This is harmless and does not happen in production builds.
|
||||
|
||||
## The game screen (`src/pages/GamePage.tsx`)
|
||||
|
||||
Layout: **hold + stats + "1 YOU"**, then the **own board** with the garbage meter and the inventory bar below it, then the **next queue**. To the right is a wrapping grid of **opponent mini boards**.
|
||||
|
||||
- **Own entry.** `me = match.players.find(p => p.id === playerId)`. If it is missing, the player joined mid-match and the page shows a "SPECTATING" box plus all boards as minis.
|
||||
- **Sizes.** The own board's cell size comes from the window height: `clamp(16, (innerHeight - 170) / 20, 32)` px. Mini boards use 14/12/9 px for ≤ 2, ≤ 4 and more opponents.
|
||||
- **Target selection.**
|
||||
- The selected target lives in local state, `targetId`. Slot `1` is yourself and slot `n ≥ 2` is `opponents[n-2]`, in match order excluding yourself.
|
||||
- A key press updates the local state and calls `selectTarget` on the server.
|
||||
- If the target dies, an effect resets it to yourself, which mirrors the server rule. The selected board gets a cyan frame, and its slot number is filled cyan.
|
||||
- **Overlays on the own board:** `ItemFlashOverlay` (flashes whose `targetId` is you), the countdown number, and "YOU WIN!" or "<name> WINS".
|
||||
- **Blackout.** `hideStack={me.invisibleRemaining > 0}` makes `drawBoard` skip locked cells, item icons and the ghost.
|
||||
- **Garbage meter.** A red bar whose height is `pendingGarbage × cell`.
|
||||
- **Inventory bar.** 10 slots, with the oldest item outlined, plus the hint line `F: <next item> > <target>`.
|
||||
- **Mini boards.** `MiniBoard` is memoized. It shows the slot number, the name, the board, a flash overlay and the opponent's **item bar beneath the board**.
|
||||
|
||||
## Keyboard input (`src/input/useKeyboardInput.ts`)
|
||||
|
||||
- Bindings come from `config/keybindings.ts` and are matched on `KeyboardEvent.code` (physical keys).
|
||||
- Only active while `enabled` (the player is alive and the match is not finished). Key events coming from `<input>` and `<textarea>` are ignored.
|
||||
- **Left/right** sends one move immediately. After `DAS_MS` (167 ms) it repeats every `ARR_MS` (33 ms). Held directions form a stack, so releasing one resumes the other.
|
||||
- **Soft drop** counts held soft-drop keys. It sends `SoftDropStart` on the first press and `SoftDropStop` when the last one is released.
|
||||
- **Number keys** call `onTarget(slot)`.
|
||||
- Browser key-repeat events are ignored. `preventDefault` stops arrows and space from scrolling the page.
|
||||
- On window **blur** every held key is released, which also stops a running soft drop.
|
||||
|
||||
## Rendering (`src/render/draw.ts`)
|
||||
|
||||
All drawing is plain Canvas 2D in a pixel style:
|
||||
|
||||
- `setupCanvas` sizes the canvas for `devicePixelRatio` and disables image smoothing.
|
||||
- `drawBlock` draws one block: a base color with a lighter top/left edge and a darker bottom/right edge. **Change this function to change the block look everywhere.**
|
||||
- `drawItemIcon` draws the item's SVG (from `assets.ts`) over a cell. While the image is still loading it falls back to a colored square.
|
||||
- `drawBoard` draws, in order: background, grid lines (only when cells are ≥ 14 px), locked cells and item icons (skipped when `hideStack`), the ghost (semi-transparent outline), and the active piece with its item icon.
|
||||
- `drawPreviewPiece` draws hold and next pieces centered in a box, using the shapes from `render/shapes.ts`.
|
||||
|
||||
`BoardCanvas` redraws in an effect whenever its `state` prop changes, i.e. on every snapshot. It also renders the "OUT"/place overlay for eliminated players and any children as overlays.
|
||||
|
||||
## Theming and graphics
|
||||
|
||||
Everything visual can be swapped without touching component logic:
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| UI colors, font, border style | CSS variables in `src/theme/global.css` (`--color-*`, `--font-pixel`, `--border`) |
|
||||
| Block colors (1–7 pieces, 8 garbage), grid, ghost alpha | `src/theme/colors.ts` |
|
||||
| Item icons, names, flash colors, help texts | `itemVisuals` in `src/theme/assets.ts` |
|
||||
| Image files | `public/assets/…`, referenced only from `assets.ts` |
|
||||
| Logo | `uiAssets.logo` in `assets.ts`. `null` renders the colored text logo |
|
||||
| Block drawing style | `drawBlock` in `src/render/draw.ts` |
|
||||
| Font | Google Fonts link in `index.html` plus `--font-pixel` |
|
||||
|
||||
The pixel font (*Press Start 2P*) has **no arrow, ellipsis or dash glyphs**, so UI text uses ASCII only ("LEFT RIGHT", "...", "-", ">").
|
||||
|
||||
Asset paths go through the `asset()` helper in `assets.ts` (`import.meta.env.BASE_URL + path`), so they stay correct when the site is served under a sub-path.
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
`src/config/runtimeConfig.ts` resolves the hub URL in this order:
|
||||
|
||||
1. `window.__ACTIONBLOCKS_CONFIG__.serverUrl` from `config.js`. In Docker this file is generated from `SERVER_URL` at container start.
|
||||
2. `VITE_SERVER_URL`, read at build time.
|
||||
3. `<page origin>/hub`.
|
||||
|
||||
## Sub-path support
|
||||
|
||||
The site can live at `https://host/` or at `https://host/some/path/`:
|
||||
|
||||
- `vite.config.ts` sets `base: './'`, so all built URLs are relative (`./static/…`, `./config.js`, `./assets/…`).
|
||||
- `index.html` starts with a tiny script that redirects `/path` to `/path/`. Without the trailing slash, relative URLs would resolve against the parent directory.
|
||||
- Hashed bundles go to `static/` (`build.assetsDir`), which keeps them separate from the exchangeable graphics in `assets/`.
|
||||
@@ -0,0 +1,163 @@
|
||||
# Game Rules (as implemented)
|
||||
|
||||
This page describes the rules **exactly as the code implements them**, with every number. The code locations are given so you can verify or change each rule. Unless noted otherwise, files are in `actionBlocks_server/src/ActionBlocks.Game/`.
|
||||
|
||||
## Match flow
|
||||
|
||||
1. The host clicks **Start game** in the lobby. Every player currently in the lobby becomes part of the match.
|
||||
2. **Countdown** (`GameSettings.CountdownSeconds`, default 3 s). Boards are visible but no piece has spawned yet, and inputs are ignored. Target selection already works.
|
||||
3. **Running.** Everyone receives the **same piece sequence** (shared seed), so the game is fair. Item rolls are independent per player.
|
||||
4. A player is **eliminated** when they top out (see below) or disconnect.
|
||||
5. **Finished** when at most one player is alive. In a single-player practice round it ends when that player tops out.
|
||||
6. The result is shown for 4 seconds (`MatchRunner.ResultsDelay`). Then the lobby returns to the waiting state and shows a results table. The host can start the next round.
|
||||
|
||||
Players who join a lobby while a match is running watch as spectators. They take part from the next round on.
|
||||
|
||||
**Places:** a player eliminated while *n* players are still alive gets place *n + 1*. Players eliminated in the same tick share that place. The last survivor gets place 1 and is the winner.
|
||||
|
||||
## Playfield
|
||||
|
||||
| Rule | Value | Where |
|
||||
|---|---|---|
|
||||
| Width × visible height | 10 × 20 | `GameSettings.BoardWidth`, `VisibleRows` |
|
||||
| Hidden buffer above the field | 20 rows | `GameSettings.BufferRows` |
|
||||
| Next-piece preview | 5 | `GameSettings.PreviewCount` |
|
||||
|
||||
Pieces spawn in the two buffer rows directly above the visible field. They are horizontally centered, rounded to the left: columns 3–5 for 3-wide pieces, 3–6 for the long piece, 4–5 for the square. Every piece moves down one row immediately after spawning. See `PlayerGame.Spawn`.
|
||||
|
||||
## Pieces and randomizer
|
||||
|
||||
- There are 7 shapes. Each has 4 rotation states (0 = spawn, 1 = right, 2 = 180°, 3 = left). States are derived by rotating the spawn shape inside its bounding box (`Core/PieceDefinitions.cs`).
|
||||
- The **bag randomizer** shuffles all 7 shapes into a bag and deals them one by one, then refills (`Core/Randomizer.cs`). This guarantees every shape appears exactly once in every 7 pieces.
|
||||
|
||||
## Rotation (SRS)
|
||||
|
||||
Rotation uses the standard *Super Rotation System* kick tables (`Core/RotationSystem.cs`):
|
||||
|
||||
- When a rotation is blocked, up to 4 alternative offsets ("kicks") are tried in order.
|
||||
- The long piece has its own kick table. The square never kicks.
|
||||
- If no offset fits, the rotation does nothing.
|
||||
|
||||
## Movement, gravity and locking
|
||||
|
||||
| Rule | Value | Where |
|
||||
|---|---|---|
|
||||
| Gravity at level *L* | (0.8 − (L−1)·0.007)^(L−1) seconds per row. Level 1 = 1 s/row | `GameSettings.GravitySecondsPerRow` |
|
||||
| Soft drop | 20× gravity, at most 60 rows/s | `SoftDropFactor`, `MaxSoftDropRowsPerSecond` |
|
||||
| Lock delay | 0.5 s on the ground | `LockDelaySeconds` |
|
||||
| Lock resets | max 15 moves/rotations while grounded | `MaxLockResets` |
|
||||
| Level | 1 + lines ÷ 10, max 15 | `LinesPerLevel`, `MaxLevel` |
|
||||
|
||||
Lock-delay details (*move reset*, `PlayerGame.Update` / `RegisterLockReset`):
|
||||
|
||||
- While the piece rests on something, a timer runs. At 0.5 s the piece locks.
|
||||
- Every successful move or rotation while grounded resets the timer and uses up one of 15 resets. When the resets are exhausted, the piece locks the moment it is grounded.
|
||||
- When the piece reaches a row lower than ever before, both the timer and the reset counter start over.
|
||||
|
||||
**Hard drop** moves the piece straight down and locks it immediately.
|
||||
|
||||
**Hold** swaps the active piece with the hold slot. If the slot is empty, the next piece spawns instead. Hold can be used once per piece and becomes available again after the next lock. An item inside a held piece stays with it.
|
||||
|
||||
## Line clears, spins and scoring
|
||||
|
||||
When a piece locks (`PlayerGame.Lock`):
|
||||
|
||||
1. **Spin check.** The piece is a T, its last successful action was a rotation, and at least 3 of the 4 corners of its 3×3 box are blocked (walls count as blocked). It is a **full spin** if both corners on the pointing side are blocked, or if the last rotation used the 5th kick. Otherwise it is a **mini spin**.
|
||||
2. **Lock out.** If all four cells are inside the hidden buffer, the player tops out.
|
||||
3. Full rows are removed. **Items in those rows go to the player's inventory.**
|
||||
4. **Combo.** It counts consecutive locks that clear lines. It is −1 when there is no streak, 0 for the first clear, 1 for the second clear in a row, and so on.
|
||||
5. **Back-to-back.** It applies when the previous line clear was also "difficult" (4 lines, or a spin that clears lines) and this one is too.
|
||||
6. **Perfect clear** is when the board is completely empty after the clear.
|
||||
|
||||
**Score** (`Garbage/GarbageCalculator.cs`, `StandardScoreCalculator`) is multiplied by the current level:
|
||||
|
||||
| Clear | Points |
|
||||
|---|---|
|
||||
| 1 / 2 / 3 / 4 lines | 100 / 300 / 500 / 800 |
|
||||
| Spin with 0 / 1 / 2 / 3 lines | 400 / 800 / 1200 / 1600 |
|
||||
| Mini spin with 0 / 1 / 2 lines | 100 / 200 / 400 |
|
||||
| Back-to-back | ×1.5 on the above |
|
||||
| Combo | +50 × combo |
|
||||
| Soft drop | +1 per row (not multiplied) |
|
||||
| Hard drop | +2 per row (not multiplied) |
|
||||
|
||||
## Garbage (attacks)
|
||||
|
||||
**Attack size** (`StandardGarbageCalculator`):
|
||||
|
||||
| Clear | Lines sent |
|
||||
|---|---|
|
||||
| Single / Double / Triple / Quad | 0 / 1 / 2 / 4 |
|
||||
| Spin Single / Double / Triple | 2 / 4 / 6 |
|
||||
| Mini spin Single / Double | 0 / 1 |
|
||||
| Back-to-back bonus | +1 |
|
||||
| Combo bonus (by combo count 0, 1, 2, …) | 0, 0, 1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5 (capped) |
|
||||
| Perfect clear | +10 |
|
||||
|
||||
**Cancelling.** Outgoing lines first cancel the player's own pending incoming garbage. Only the rest is sent.
|
||||
|
||||
**Targeting.** Garbage is **not** sent to the selected item target. It goes round-robin through the living opponents, in match order: each sender has its own cursor, and dead players are skipped (`Targeting/GarbageTargeter.cs`).
|
||||
|
||||
**Arrival.** Incoming garbage waits in a queue (the red meter next to the board). It rises when the receiver next locks a piece **without** clearing a line. Each attack becomes a block of gray rows sharing one random hole column.
|
||||
|
||||
## Top out (elimination)
|
||||
|
||||
A player is eliminated when any of these happens:
|
||||
|
||||
- **Block out:** a new piece spawns overlapping existing blocks.
|
||||
- **Lock out:** a piece locks completely inside the hidden buffer.
|
||||
- **Garbage overflow:** garbage pushes a filled cell out of the top of the buffer.
|
||||
- **No room after an item:** an item changed the board under the active piece and the piece cannot be pushed up to a free spot.
|
||||
- **Disconnect:** the player leaves or closes the tab.
|
||||
|
||||
## Items
|
||||
|
||||
### Spawning and collecting
|
||||
|
||||
- Each **newly generated** piece has a 10% chance (`GameSettings.ItemSpawnChance`) to carry an item. The item sits in one random mino of the four. The roll happens when the piece enters the queue, so items are already visible in the preview and in hold (`Items/ItemRegistry.cs`, `ItemSpawner`).
|
||||
- When the piece locks, the item stays in that cell of the board. Item cells move with the board: they drop when lines below them clear, rise with garbage, and travel along when boards are swapped.
|
||||
- **Clearing the row that contains the item cell** moves the item into the player's inventory.
|
||||
- If the cell is destroyed another way (for example by *Clear Line* or *Wipe Out*), the item is lost.
|
||||
|
||||
### Inventory and use
|
||||
|
||||
- The inventory holds at most 10 items (`MaxInventory`). Items collected beyond that are discarded.
|
||||
- Items queue up first in, first out. **F** always uses the **oldest** item, the leftmost one in the bar.
|
||||
- **Number keys** select the target: `1` is yourself, `2`–`9` are the opponents in the order they are displayed.
|
||||
- Unknown or eliminated targets are ignored. If the current target gets eliminated, the selection falls back to yourself, on both server and client.
|
||||
- Using an item with an empty inventory does nothing.
|
||||
|
||||
### Visibility rules
|
||||
|
||||
- **Nobody sees who targets whom.** The selected target is never broadcast.
|
||||
- Everyone sees every opponent's inventory in the item bar beneath their mini board.
|
||||
- Every client is told when an item hits someone (`ItemUsedEvent`): which player was hit and which item, but not who used it. The item name flashes on top of the hit player's board. That is the big board for yourself, or the mini board for an opponent.
|
||||
|
||||
### The five items
|
||||
|
||||
| Id | Name | Effect | Class |
|
||||
|---|---|---|---|
|
||||
| `add_line` | Add Line | Immediately inserts 1 garbage row (random hole) at the bottom of the target's board. This can top the target out. | `AddGarbageLineItem` |
|
||||
| `clear_line` | Clear Line | Removes the bottom row of the target. Items in it are lost. | `ClearBottomLineItem` |
|
||||
| `nuke` | Wipe Out | Removes **all** locked blocks of the target. The active piece is unaffected. | `ClearBoardItem` |
|
||||
| `swap` | Switch | The user and the target exchange all locked blocks. Active pieces, hold, queues, inventories and pending garbage stay with their owners. If an active piece now overlaps, it is pushed up. Does nothing when used on yourself. | `SwapBoardItem` |
|
||||
| `blind` | Blackout | For 4 seconds, the target cannot see their locked blocks or their ghost piece. Only the falling piece stays visible. Re-applying extends the effect to *now + 4 s*. | `InvisibilityItem` |
|
||||
|
||||
Every item can also be used on yourself: target `1`. *Wipe Out* and *Clear Line* are useful defensively.
|
||||
|
||||
## Controls
|
||||
|
||||
Bindings use physical key positions (`KeyboardEvent.code`), so they work on any keyboard layout. They are defined in `actionBlocks_website/src/config/keybindings.ts`.
|
||||
|
||||
| Action | Keys |
|
||||
|---|---|
|
||||
| Move left / right | ← → or A D |
|
||||
| Soft drop (hold) | ↓ or S |
|
||||
| Hard drop | Space |
|
||||
| Rotate clockwise | ↑, W, X, E |
|
||||
| Rotate counter-clockwise | Z (also the key labeled Y on QWERTZ), Q, Ctrl |
|
||||
| Hold | C, Shift |
|
||||
| Select item target | 1–9 (1 = yourself) |
|
||||
| Use item | F |
|
||||
|
||||
Holding left/right repeats the move after 167 ms, then every 33 ms (DAS/ARR). When both directions are held, the most recently pressed one wins.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Client ↔ Server Protocol
|
||||
|
||||
Communication uses **ASP.NET Core SignalR** over WebSockets. SignalR falls back to Server-Sent Events or long polling if WebSockets are unavailable. The payload format is JSON with **camelCase** property names. Enums are sent as **strings**.
|
||||
|
||||
- Hub URL: `<server>/hub`. The path is configurable through `HUB_PATH`, and the website finds the URL through `SERVER_URL` (see [Deployment](deployment.md)).
|
||||
- Server implementation: `actionBlocks_server/src/ActionBlocks.Server/Hubs/GameHub.cs`
|
||||
- DTO definitions: `actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs`
|
||||
- Client implementation: `actionBlocks_website/src/net/hubClient.ts`
|
||||
- Client types (mirror of the DTOs): `actionBlocks_website/src/net/types.ts`
|
||||
|
||||
> When you change a DTO on the server, update `types.ts` in the website as well. Nothing generates it automatically.
|
||||
|
||||
## Client → server (hub methods)
|
||||
|
||||
| Method | Arguments | Returns | Notes |
|
||||
|---|---|---|---|
|
||||
| `CreateLobby` | `name: string` | `JoinResult` | Creates a lobby and joins it as host |
|
||||
| `JoinLobby` | `code: string, name: string` | `JoinResult` | The code is case-insensitive |
|
||||
| `LeaveLobby` | – | – | Also forfeits a running match |
|
||||
| `StartGame` | – | – | Host only; fails while a match is running |
|
||||
| `Input` | `action: InputAction` | – | Sent with `send` (fire-and-forget) |
|
||||
| `SelectTarget` | `targetId: string` | – | Player id of the item target, or your own id |
|
||||
| `GetItems` | – | `ItemInfo[]` | Lists all server items (id + name). Currently unused by the website |
|
||||
|
||||
`InputAction` is one of `MoveLeft`, `MoveRight`, `RotateCw`, `RotateCcw`, `SoftDropStart`, `SoftDropStop`, `HardDrop`, `Hold`, `UseItem`.
|
||||
|
||||
**Soft drop** is a state: the client sends `SoftDropStart` on key down and `SoftDropStop` on key up. Every other action is a single event. Left/right auto-repeat is generated by the client.
|
||||
|
||||
### Errors
|
||||
|
||||
Failed calls throw a `HubException`. The client receives its message, and `hubClient.ts` extracts the text after `HubException: `. Possible messages:
|
||||
|
||||
| Message | Cause |
|
||||
|---|---|
|
||||
| `You are already in a lobby.` | `CreateLobby` / `JoinLobby` while already joined |
|
||||
| `Lobby not found.` | Wrong code, or the lobby was deleted |
|
||||
| `Lobby is full.` | 8 players already |
|
||||
| `You are not in a lobby.` | `StartGame` without a lobby |
|
||||
| `Only the host can start the game.` | `StartGame` from a non-host |
|
||||
| `A game is already running.` | `StartGame` during a match |
|
||||
|
||||
`Input` and `SelectTarget` from a connection that is not in a lobby are silently ignored.
|
||||
|
||||
## Server → client messages
|
||||
|
||||
| Message | Payload | Sent when |
|
||||
|---|---|---|
|
||||
| `LobbyUpdated` | `Lobby` | Another player joined or left, the host changed, a match started (`phase: "playing"`) or a match ended and results are available (`phase: "waiting"`) |
|
||||
| `MatchState` | `MatchState` | During a match: ≈30 Hz, plus immediately after any input or event, and for 4 s after the match finished |
|
||||
|
||||
The player who calls `CreateLobby` / `JoinLobby` gets the lobby as the method's return value. Only the *other* players receive `LobbyUpdated` for that join.
|
||||
|
||||
## Lobby flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Host browser
|
||||
participant S as Server
|
||||
participant B as Guest browser
|
||||
|
||||
A->>S: CreateLobby("Alice")
|
||||
S-->>A: JoinResult { playerId, lobby{code:"MFHZVM"} }
|
||||
B->>S: JoinLobby("mfhzvm", "Bob")
|
||||
S-->>B: JoinResult
|
||||
S-->>A: LobbyUpdated (2 players)
|
||||
A->>S: StartGame()
|
||||
S-->>A: LobbyUpdated (phase: playing)
|
||||
S-->>B: LobbyUpdated (phase: playing)
|
||||
loop match running
|
||||
A->>S: Input / SelectTarget
|
||||
B->>S: Input / SelectTarget
|
||||
S-->>A: MatchState
|
||||
S-->>B: MatchState
|
||||
end
|
||||
Note over S: phase finished → 4 s results
|
||||
S-->>A: LobbyUpdated (phase: waiting, lastResults)
|
||||
S-->>B: LobbyUpdated (phase: waiting, lastResults)
|
||||
```
|
||||
|
||||
## Payloads
|
||||
|
||||
### `JoinResult`
|
||||
|
||||
```json
|
||||
{ "playerId": "ade86f694a81", "lobby": { /* Lobby */ } }
|
||||
```
|
||||
|
||||
`playerId` identifies *you* inside this lobby. The client needs it to find its own entry in `MatchState.players`.
|
||||
|
||||
### `Lobby`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "MFHZVM",
|
||||
"hostId": "ade86f694a81",
|
||||
"phase": "waiting", // "waiting" | "playing"
|
||||
"maxPlayers": 8,
|
||||
"players": [
|
||||
{ "id": "ade86f694a81", "name": "Alice", "isHost": true },
|
||||
{ "id": "3466ff69d433", "name": "Bob", "isHost": false }
|
||||
],
|
||||
"lastResults": [ // null before the first match
|
||||
{ "id": "ade86f694a81", "name": "Alice", "place": 1, "score": 1250, "lines": 12 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `MatchState`
|
||||
|
||||
```json
|
||||
{
|
||||
"phase": "running", // "countdown" | "running" | "finished"
|
||||
"countdown": 0, // seconds left in the countdown
|
||||
"winnerId": null, // set when finished
|
||||
"width": 10,
|
||||
"height": 20, // visible rows
|
||||
"players": [ /* PlayerState, in match order */ ],
|
||||
"events": [ /* MatchEvent, only those since the previous snapshot */ ]
|
||||
}
|
||||
```
|
||||
|
||||
### `PlayerState`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "ade86f694a81",
|
||||
"name": "Alice",
|
||||
"alive": true,
|
||||
"place": null, // set when eliminated, and for the winner at the end
|
||||
"board": "0000000000…3330000000", // width*height chars, row by row, top to bottom
|
||||
"items": [ { "x": 2, "y": 19, "itemId": "nuke" } ],
|
||||
"active": {
|
||||
"type": 7,
|
||||
"cells": [[4,-1],[3,0],[4,0],[5,0]],
|
||||
"itemIndex": 2, // which of the 4 cells carries the item, -1 if none
|
||||
"itemId": "swap"
|
||||
},
|
||||
"ghost": [[4,17],[3,18],[4,18],[5,18]],
|
||||
"hold": { "type": 3, "itemId": null, "itemIndex": -1 },
|
||||
"holdUsed": false,
|
||||
"next": [ { "type": 4, "itemId": null, "itemIndex": -1 }, … ],
|
||||
"inventory": ["add_line", "blind"], // oldest first; inventory[0] is used by F
|
||||
"score": 1250,
|
||||
"lines": 12,
|
||||
"level": 2,
|
||||
"pendingGarbage": 3, // incoming lines waiting to rise
|
||||
"invisibleRemaining": 0 // seconds of Blackout left (> 0 = hide stack + ghost)
|
||||
}
|
||||
```
|
||||
|
||||
**Board encoding.** One character per cell: `'0'` is empty, `'1'`–`'7'` are piece colors (I, J, L, O, S, Z, T), `'8'` is garbage. Index = `y * width + x`, where y = 0 is the **top visible row**.
|
||||
|
||||
**Coordinates.** All `x, y` values are in visible coordinates. Active-piece cells can have **negative y** while the piece is still above the visible area. Clients skip those cells when drawing.
|
||||
|
||||
**Piece type numbers** are `PieceType` values: `1=I, 2=J, 3=L, 4=O, 5=S, 6=Z, 7=T`. They double as the color index in `theme/colors.ts`.
|
||||
|
||||
For eliminated players, `active` and `ghost` are `null`.
|
||||
|
||||
### `MatchEvent`
|
||||
|
||||
All events use one flat shape. Fields that don't apply are `null`.
|
||||
|
||||
| `type` | Fields | Meaning |
|
||||
|---|---|---|
|
||||
| `itemUsed` | `targetId`, `itemId`, `itemName` | An item hit `targetId`. **The user is intentionally not included.** |
|
||||
| `eliminated` | `playerId`, `place` | A player topped out or left |
|
||||
|
||||
The selected item **target is never part of any broadcast**, so players cannot tell who is targeting them.
|
||||
|
||||
## Writing a test client
|
||||
|
||||
Any SignalR client can talk to the server. A minimal Node.js example:
|
||||
|
||||
```js
|
||||
const signalR = require('@microsoft/signalr');
|
||||
const c = new signalR.HubConnectionBuilder().withUrl('http://localhost:5080/hub').build();
|
||||
c.on('LobbyUpdated', (lobby) => { console.log('lobby', lobby.phase); }); // handlers must not return a value
|
||||
c.on('MatchState', (state) => { /* inspect state.players / state.events */ });
|
||||
await c.start();
|
||||
const { playerId, lobby } = await c.invoke('JoinLobby', 'ABC123', 'Bot');
|
||||
c.send('Input', 'HardDrop');
|
||||
```
|
||||
|
||||
> SignalR's JavaScript client treats a handler's **return value** as a "client result". Write handlers as blocks (`(x) => { … }`), not expressions (`(x) => state = x`). Otherwise the client logs `Result given for '…' method but server is not expecting a result`.
|
||||
@@ -0,0 +1,198 @@
|
||||
# Server (`actionBlocks_server/`)
|
||||
|
||||
ASP.NET Core 10 solution `ActionBlocks.Server.sln` with three projects:
|
||||
|
||||
```
|
||||
actionBlocks_server/
|
||||
├── src/ActionBlocks.Game/ # game rules – no web dependencies
|
||||
│ ├── Core/
|
||||
│ │ ├── PieceType.cs # enum I,J,L,O,S,Z,T (value = color 1-7) + CellColor
|
||||
│ │ ├── Cell.cs # Cell(Color, ItemId?) and Point(X, Y)
|
||||
│ │ ├── PieceDefinitions.cs # shapes + derived rotation states
|
||||
│ │ ├── Piece.cs # PieceSpec (shape + item) and ActivePiece (spec + position)
|
||||
│ │ ├── Board.cs # grid of locked cells, line clears, garbage, swap
|
||||
│ │ ├── RotationSystem.cs # IRotationSystem + SuperRotationSystem (kick tables)
|
||||
│ │ └── Randomizer.cs # IRandomizer + SevenBagRandomizer
|
||||
│ ├── Garbage/GarbageCalculator.cs # ClearInfo, IGarbageCalculator, IScoreCalculator
|
||||
│ ├── Targeting/GarbageTargeter.cs # IGarbageTargeter + CyclingGarbageTargeter
|
||||
│ ├── Items/
|
||||
│ │ ├── IItem.cs # IItem + ItemContext
|
||||
│ │ ├── Items.cs # the five item implementations
|
||||
│ │ └── ItemRegistry.cs # ItemRegistry (list of items) + ItemSpawner
|
||||
│ ├── GameSettings.cs # all tunable numbers
|
||||
│ ├── PlayerGame.cs # one player's simulation + InputAction enum
|
||||
│ └── Match.cs # one round: players, garbage/item routing, winner
|
||||
├── src/ActionBlocks.Server/ # web host
|
||||
│ ├── Program.cs # DI, CORS, SignalR, endpoints, config
|
||||
│ ├── Hubs/GameHub.cs # SignalR API for clients
|
||||
│ ├── Lobbies/Lobby.cs # one lobby (players, host, current match)
|
||||
│ ├── Lobbies/LobbyManager.cs # all lobbies, codes, connection→lobby mapping
|
||||
│ ├── Lobbies/MatchRunner.cs # BackgroundService: 60 Hz tick + broadcast
|
||||
│ ├── Dtos/Dtos.cs # records sent over the wire
|
||||
│ ├── Dtos/SnapshotMapper.cs # game objects → DTOs
|
||||
│ └── appsettings.json
|
||||
└── tests/ActionBlocks.Game.Tests/ # xUnit tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ActionBlocks.Game – the rules library
|
||||
|
||||
### Coordinates
|
||||
|
||||
- **x** grows to the right, **y** grows **downwards**.
|
||||
- `Board` has `Height = VisibleRows + BufferRows` rows (40 by default).
|
||||
- Row `0` is the top of the hidden buffer. The visible field is rows `BufferRows … Height-1`, which is 20–39 by default.
|
||||
- Piece shapes are offsets inside a square bounding box (2×2, 3×3 or 4×4). `ActivePiece.X/Y` is the board position of the box's top-left corner.
|
||||
- Network DTOs convert to **visible coordinates** (`y - BufferRows`), so clients see y = 0 at the top of the visible field. Active-piece cells can have negative y while they are still in the buffer.
|
||||
|
||||
### Cells and pieces
|
||||
|
||||
```csharp
|
||||
readonly record struct Cell(byte Color, string? ItemId = null) // Color 0 = empty, 1-7 pieces, 8 garbage
|
||||
sealed record PieceSpec(PieceType Type, string? ItemId, int ItemMinoIndex) // what sits in the queue
|
||||
sealed record ActivePiece(PieceSpec Spec, int X, int Y, int Rotation) // a spec placed on the board
|
||||
```
|
||||
|
||||
The item belongs to a **mino index** (0–3), not to a position. `PieceDefinitions` computes every rotation state by rotating the spawn shape mino by mino, so mino *i* is always the same physical cell of the piece. That keeps the item attached to the right block through every rotation. The website's preview shapes (`src/render/shapes.ts`) use the same mino order.
|
||||
|
||||
### `Board`
|
||||
|
||||
Holds the rows as `List<Cell[]>`, which makes shifting rows cheap. Main operations:
|
||||
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `Fits(piece)` / `IsBlocked(x, y)` | Collision checks. Outside the board counts as blocked. |
|
||||
| `Place(piece)` | Writes the piece's cells, including its item, into the grid |
|
||||
| `FullRows()` / `ClearRows(rows)` | Line clear. Returns the item ids of removed cells |
|
||||
| `AddGarbage(count, hole)` | Pushes rows in from the bottom. Returns `true` when blocks overflow the top (top out) |
|
||||
| `RemoveBottomRow()`, `Clear()` | Used by items |
|
||||
| `SwapContents(other)` | Exchanges the row lists of two boards (Switch item) |
|
||||
|
||||
### `PlayerGame` – one player's field
|
||||
|
||||
This is the heart of the simulation. Its lifecycle:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Created: new PlayerGame(...)
|
||||
Created --> Playing: Start() → fill queue, spawn
|
||||
Playing --> Playing: Update(dt, now) / HandleInput(action)
|
||||
Playing --> ToppedOut: TopOut() (block out, lock out, overflow, forfeit)
|
||||
ToppedOut --> [*]
|
||||
```
|
||||
|
||||
- **`Update(dt, now)`** is called every tick. It applies gravity (a fractional row counter `_gravityProgress`) and runs the lock-delay timer. Then it locks if needed.
|
||||
- **`HandleInput(action)`** handles movement, rotation, soft-drop on/off, hard drop and hold. `UseItem` is ignored here, because using an item needs a target. `Match` handles it.
|
||||
- **`Lock()`** is the central routine. It detects spins, places the piece, checks lock-out, clears rows and collects items. It also updates combo, back-to-back, score and level. Then it calculates the attack, cancels pending garbage and queues the remainder in `_outgoingAttacks`. If no line was cleared, it applies pending garbage. Finally it spawns the next piece.
|
||||
- **Garbage in:** `QueueGarbage(lines)` adds to the pending list. **Garbage out:** `DrainOutgoingAttacks()` is read by `Match`.
|
||||
- **Item hooks:** `InsertGarbageNow`, `RemoveBottomLine`, `ClearBoard`, `SwapBoards`, `InvisibleUntil`. After any board change under the active piece, `ResolveActiveOverlap()` pushes the piece up until it fits. If it can't fit anywhere, the player tops out.
|
||||
- **Inventory:** `AddItem` (capped), `TakeFirstItem` (FIFO).
|
||||
|
||||
Dependencies are injected through the constructor (`IRandomizer`, `ItemSpawner`, `IRotationSystem`, `IGarbageCalculator`, `IScoreCalculator`, `Random`), so tests can control every source of randomness. See `tests/.../TestHelpers.cs`.
|
||||
|
||||
### `Match` – one round
|
||||
|
||||
- **Constructor.** It creates one `PlayerGame` per player. All of them share one piece seed, so everyone gets the same sequence. Each player gets its own `Random` for items and garbage holes. The match starts in `Countdown`, or directly in `Running` when `CountdownSeconds == 0`, which tests use.
|
||||
- **`Update(dt)`** advances the countdown, or updates all players and then runs `AfterStateChange()`.
|
||||
- **`HandleInput(playerId, action)`** is ignored unless the match is running and the player is alive. `UseItem` is resolved here. Then `AfterStateChange()` runs.
|
||||
- **`SelectTarget(playerId, targetId)`** stores the target on the `PlayerGame`. An invalid or dead target becomes the player themself.
|
||||
- **`Forfeit(playerId)`** is used when a player disconnects.
|
||||
- **`AfterStateChange()`** routes every outgoing attack through the `IGarbageTargeter`, assigns places to newly eliminated players, resets targets that point at dead players, and detects the end of the match.
|
||||
- **Events.** `ItemUsedEvent(TargetId, ItemId, ItemName)` and `PlayerEliminatedEvent(PlayerId, Place)` are collected and drained once per tick by the host.
|
||||
|
||||
### Items
|
||||
|
||||
```csharp
|
||||
public interface IItem
|
||||
{
|
||||
string Id { get; } // stable id shared with the frontend ("add_line", ...)
|
||||
string DisplayName { get; } // shown in the flash
|
||||
int SpawnWeight => 1; // relative spawn probability
|
||||
void Apply(ItemContext context);
|
||||
}
|
||||
public sealed record ItemContext(PlayerGame User, PlayerGame Target, Random Random, double Now);
|
||||
```
|
||||
|
||||
- `ItemRegistry.Default` lists all active items. `ItemRegistry.PickRandom` chooses an item weighted by `SpawnWeight`.
|
||||
- `ItemSpawner.Decorate(type)` turns a raw piece type into a `PieceSpec`, sometimes with an item.
|
||||
|
||||
See [Extending → Add an item](extending.md#add-a-new-item).
|
||||
|
||||
### `GameSettings`
|
||||
|
||||
`GameSettings` is an immutable record with `init` properties, one per tunable number. See [Game rules](game-rules.md) for what each one does. The host binds it from the configuration section `Game`, so you can override any property with an environment variable named `Game__<PropertyName>`, e.g. `Game__ItemSpawnChance=0.25`.
|
||||
|
||||
---
|
||||
|
||||
## ActionBlocks.Server – the host
|
||||
|
||||
### `Program.cs`
|
||||
|
||||
- Reads the configuration: `ALLOWED_ORIGINS`, `HUB_PATH` (default `/hub`), `PATH_BASE` and the `Game` section.
|
||||
- **CORS.** It allows the listed origins, or any origin when the list is empty, and allows credentials, which SignalR needs.
|
||||
- **SignalR** uses JSON with `JsonStringEnumConverter`, so `InputAction` travels as a string like `"HardDrop"`.
|
||||
- **Singletons:** `GameSettings` and `LobbyManager`. Hosted service: `MatchRunner`.
|
||||
- **Endpoints:** `GET /health` returns `{"status":"ok"}`, and the hub is mapped at `HUB_PATH`.
|
||||
- `UsePathBase(PATH_BASE)` lets the app accept requests that still carry a proxy prefix. Requests without the prefix work too.
|
||||
|
||||
### `LobbyManager`
|
||||
|
||||
- `_lobbies` maps lobby codes to `Lobby` objects. `_connectionLobby` maps each SignalR connection id to a lobby code. One connection can be in at most one lobby.
|
||||
- **Codes** are 6 characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`. Ambiguous characters (0/O, 1/I) are excluded, and codes come from a cryptographically secure RNG. Joining is case-insensitive.
|
||||
- **Names** are trimmed, control characters are removed, and they are cut to 16 characters. An empty name becomes `Player N`.
|
||||
- **Player ids** are random 12-hex-character strings. They are deliberately **not** the connection id, so a connection id never leaks to other clients.
|
||||
- `Leave` removes the player, and deletes the lobby once it is empty.
|
||||
- Failures throw a `LobbyException`. The hub turns it into a `HubException` whose message reaches the client.
|
||||
|
||||
### `Lobby`
|
||||
|
||||
- `Players` and `HostId` are tracked; the first player is the host. If the host leaves, the next player becomes host.
|
||||
- `MaxPlayers = 8`.
|
||||
- `StartMatch(settings)` creates a `Match` from the current players. `EndMatch()` stores `LastResults` and clears `Match`.
|
||||
- `Dirty` is set by input so that the next tick broadcasts immediately.
|
||||
- `Remove(player)` also forfeits the player in a running match.
|
||||
- `ToDto()` builds the `LobbyDto`.
|
||||
|
||||
### `GameHub`
|
||||
|
||||
The client API. It is documented in detail in [Protocol](protocol.md). Every method that touches a lobby locks `lobby.Sync`. `OnDisconnectedAsync` treats a disconnect exactly like `LeaveLobby`.
|
||||
|
||||
### `MatchRunner`
|
||||
|
||||
A `BackgroundService` with a `PeriodicTimer` at 60 Hz. Every tick:
|
||||
|
||||
1. Measures the real elapsed time and clamps it to at most 0.1 s, so a stall doesn't cause huge jumps.
|
||||
2. For each lobby with a match, inside the lock: `match.Update(delta)`, then `DrainEvents()`.
|
||||
3. Builds a snapshot when any of these holds: it is an even tick (≈30 Hz baseline), the lobby is dirty (input happened), or events are present. Building a snapshot whenever events exist means no event is ever dropped.
|
||||
4. Once the match is finished, it waits `ResultsDelay` (4 s), then calls `lobby.EndMatch()` and broadcasts a `LobbyUpdated`.
|
||||
5. Sends happen outside the lock, fire-and-forget.
|
||||
|
||||
Exceptions inside one lobby are logged and do not affect other lobbies.
|
||||
|
||||
### DTO mapping
|
||||
|
||||
`SnapshotMapper` converts a `Match` into a `MatchStateDto`:
|
||||
|
||||
- Only the visible rows are sent.
|
||||
- The board becomes a compact string with one character per cell.
|
||||
- Item cells are listed separately.
|
||||
- The active piece and the ghost are omitted for eliminated players.
|
||||
|
||||
The format is in [Protocol](protocol.md).
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/ActionBlocks.Game.Tests` (xUnit) covers:
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `CoreTests.cs` | Rotation states, wall kicks, bag randomizer, row clearing with items, garbage insertion |
|
||||
| `PlayerGameTests.cs` | Spawn and gravity, line clears collecting items (from the board and from the piece), hold, pending garbage, attack size, top out, inventory cap |
|
||||
| `ItemAndMatchTests.cs` | Each item's effect, Switch overlap resolution, Blackout duration, item use routed through `Match` with events, winner and places, target fallback, garbage cycling, countdown |
|
||||
|
||||
`TestHelpers.CreatePlayer(...)` builds a `PlayerGame` with a fixed piece sequence (`FixedRandomizer`) and a configurable item chance. `FillRow(board, y, holes...)` prepares board states.
|
||||
|
||||
Run them with `dotnet test` in `actionBlocks_server/`. The Docker build also runs them, so a failing test fails the image build.
|
||||