init
@@ -0,0 +1,71 @@
|
|||||||
|
# Action Blocks
|
||||||
|
|
||||||
|
Online player-vs-player falling-block game with items.
|
||||||
|
|
||||||
|
| Component | Path | Tech |
|
||||||
|
|---|---|---|
|
||||||
|
| Game server (logic, lobbies, players) | `actionBlocks_server/` | ASP.NET Core 10, SignalR |
|
||||||
|
| Website | `actionBlocks_website/` | React 19 + Vite + TypeScript, CSS Modules, served by nginx |
|
||||||
|
|
||||||
|
## Gameplay
|
||||||
|
|
||||||
|
- 10x20 field, hold, 5-piece preview, ghost piece, bag randomizer, wall kicks, 0.5 s lock delay (15 move resets).
|
||||||
|
- Garbage from line clears (attack table incl. spins, back-to-back, combos) goes to opponents in a **round-robin cycle**.
|
||||||
|
- 1 in 10 pieces carries an **item** inside one of its blocks. Clear the line containing that block to collect it (max 10, FIFO).
|
||||||
|
- **1-9** selects the item target (1 = you, 2.. = opponents in the order shown). **F** uses the first item on the target.
|
||||||
|
- You never see who targets you, but every opponent's items are shown beneath their mini field.
|
||||||
|
- Item names flash on top of the field they hit.
|
||||||
|
|
||||||
|
Controls: arrows or WASD to move/rotate/soft drop, Space for hard drop, Z/Q/Ctrl to rotate left, C/Shift to hold.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# server on :5080
|
||||||
|
cd actionBlocks_server && dotnet run --project src/ActionBlocks.Server --urls http://localhost:5080
|
||||||
|
dotnet test # game logic tests
|
||||||
|
|
||||||
|
# website on :5173, proxies /hub to the server
|
||||||
|
cd actionBlocks_website && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create actionBlocks_website # if not already created
|
||||||
|
docker network create actionBlocks_server
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The reverse proxy (attached to both networks) should route:
|
||||||
|
|
||||||
|
- the site to `actionblocks-website:80`
|
||||||
|
- `/hub` to `actionblocks-server:8080`, with WebSocket upgrade headers forwarded
|
||||||
|
|
||||||
|
| Variable | Container | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `SERVER_URL` | website | Hub URL as seen by the browser. Empty means `<site origin>/hub`. Written into `config.js` at container start. |
|
||||||
|
| `ALLOWED_ORIGINS` | server | Comma-separated CORS origins. Empty allows any origin. |
|
||||||
|
| `HUB_PATH` | server | Hub endpoint path (default `/hub`). |
|
||||||
|
| `Game__*` | server | Overrides any `GameSettings` value, e.g. `Game__ItemSpawnChance=0.2`. |
|
||||||
|
|
||||||
|
## Extending
|
||||||
|
|
||||||
|
**Adding an item**
|
||||||
|
|
||||||
|
1. Create a class implementing `IItem` in `actionBlocks_server/src/ActionBlocks.Game/Items/`.
|
||||||
|
2. Register it in `ItemRegistry.Default`.
|
||||||
|
3. Add an entry with the same id to `itemVisuals` in `actionBlocks_website/src/theme/assets.ts`, and add an icon in `public/assets/items/`.
|
||||||
|
|
||||||
|
**Changing rules**
|
||||||
|
|
||||||
|
- Values: `GameSettings`.
|
||||||
|
- Behaviour behind interfaces: `IRotationSystem`, `IRandomizer`, `IGarbageCalculator`, `IScoreCalculator`, `IGarbageTargeter`.
|
||||||
|
- The match flow lives in `Match`, and the per-player simulation in `PlayerGame`.
|
||||||
|
|
||||||
|
**Changing graphics**
|
||||||
|
|
||||||
|
- Every image path is in `src/theme/assets.ts`. You can set an image logo there with `uiAssets.logo`.
|
||||||
|
- Block colors are in `src/theme/colors.ts`.
|
||||||
|
- Block drawing style is in `src/render/draw.ts`.
|
||||||
|
- Key bindings are in `src/config/keybindings.ts`.
|
||||||
@@ -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,42 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ActionBlocks.Game;
|
||||||
|
using ActionBlocks.Server.Hubs;
|
||||||
|
using ActionBlocks.Server.Lobbies;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Comma separated list of origins allowed to connect, e.g. "https://blocks.example.com".
|
||||||
|
// Empty = allow any origin (useful for local development).
|
||||||
|
var allowedOrigins = (builder.Configuration["ALLOWED_ORIGINS"] ?? "")
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
var hubPath = builder.Configuration["HUB_PATH"] ?? "/hub";
|
||||||
|
|
||||||
|
builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
|
||||||
|
{
|
||||||
|
if (allowedOrigins.Length > 0)
|
||||||
|
{
|
||||||
|
policy.WithOrigins(allowedOrigins);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
policy.SetIsOriginAllowed(_ => true);
|
||||||
|
}
|
||||||
|
policy.AllowAnyHeader().AllowAnyMethod().AllowCredentials();
|
||||||
|
}));
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddSignalR()
|
||||||
|
.AddJsonProtocol(o => o.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
|
// Rules can be tuned via configuration, e.g. env Game__ItemSpawnChance=0.2
|
||||||
|
builder.Services.AddSingleton(builder.Configuration.GetSection("Game").Get<GameSettings>() ?? new GameSettings());
|
||||||
|
builder.Services.AddSingleton<LobbyManager>();
|
||||||
|
builder.Services.AddHostedService<MatchRunner>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseCors();
|
||||||
|
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||||
|
app.MapHub<GameHub>(hubPath);
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ActionBlocks.Game\ActionBlocks.Game.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using ActionBlocks.Game.Core;
|
||||||
|
using static ActionBlocks.Game.Tests.TestHelpers;
|
||||||
|
|
||||||
|
namespace ActionBlocks.Game.Tests;
|
||||||
|
|
||||||
|
public class CoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void T_piece_rotates_clockwise_to_point_right()
|
||||||
|
{
|
||||||
|
var cells = PieceDefinitions.Minos(PieceType.T, 1).ToHashSet();
|
||||||
|
Assert.Equal(new HashSet<Point> { new(1, 0), new(1, 1), new(2, 1), new(1, 2) }, cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void I_piece_vertical_state_uses_third_column()
|
||||||
|
{
|
||||||
|
var cells = PieceDefinitions.Minos(PieceType.I, 1);
|
||||||
|
Assert.All(cells, c => Assert.Equal(2, c.X));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Four_rotations_return_to_spawn_shape()
|
||||||
|
{
|
||||||
|
foreach (var type in PieceDefinitions.All)
|
||||||
|
{
|
||||||
|
Assert.Equal(PieceDefinitions.Minos(type, 0), PieceDefinitions.Minos(type, 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Seven_bag_deals_every_shape_once_per_bag()
|
||||||
|
{
|
||||||
|
var randomizer = new SevenBagRandomizer(new Random(42));
|
||||||
|
for (var bag = 0; bag < 3; bag++)
|
||||||
|
{
|
||||||
|
var pieces = Enumerable.Range(0, 7).Select(_ => randomizer.Next()).ToHashSet();
|
||||||
|
Assert.Equal(7, pieces.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rotation_against_wall_uses_kick()
|
||||||
|
{
|
||||||
|
var board = new Board(10, 20, 20);
|
||||||
|
var srs = new SuperRotationSystem();
|
||||||
|
// Vertical I hugging the left wall (column 0 in state L uses box column 1).
|
||||||
|
var piece = new ActivePiece(new PieceSpec(PieceType.I), -1, 30, 3);
|
||||||
|
Assert.True(board.Fits(piece));
|
||||||
|
|
||||||
|
var result = srs.TryRotate(board, piece, 1);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.True(result.Value.KickIndex > 0);
|
||||||
|
Assert.True(board.Fits(result.Value.Piece));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clearing_rows_returns_contained_items_and_drops_rows_above()
|
||||||
|
{
|
||||||
|
var board = new Board(10, 20, 20);
|
||||||
|
var bottom = Bottom(board);
|
||||||
|
FillRow(board, bottom);
|
||||||
|
board[3, bottom] = new Cell(CellColor.Garbage, "nuke");
|
||||||
|
board[0, bottom - 1] = new Cell(1);
|
||||||
|
|
||||||
|
var items = board.ClearRows(board.FullRows().ToList());
|
||||||
|
|
||||||
|
Assert.Equal(["nuke"], items);
|
||||||
|
Assert.False(board[0, bottom].IsEmpty);
|
||||||
|
Assert.Equal(1, FilledCount(board));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Garbage_pushes_rows_up_with_a_hole()
|
||||||
|
{
|
||||||
|
var board = new Board(10, 20, 20);
|
||||||
|
board[5, Bottom(board)] = new Cell(1);
|
||||||
|
|
||||||
|
var overflow = board.AddGarbage(2, 4);
|
||||||
|
|
||||||
|
Assert.False(overflow);
|
||||||
|
Assert.True(board[4, Bottom(board)].IsEmpty);
|
||||||
|
Assert.False(board[5, Bottom(board) - 2].IsEmpty);
|
||||||
|
Assert.Equal(19, FilledCount(board));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using ActionBlocks.Game.Core;
|
||||||
|
using ActionBlocks.Game.Items;
|
||||||
|
using ActionBlocks.Game.Targeting;
|
||||||
|
using static ActionBlocks.Game.Tests.TestHelpers;
|
||||||
|
|
||||||
|
namespace ActionBlocks.Game.Tests;
|
||||||
|
|
||||||
|
public class ItemTests
|
||||||
|
{
|
||||||
|
private static ItemContext Context(PlayerGame user, PlayerGame target, double now = 0) =>
|
||||||
|
new(user, target, new Random(3), now);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Add_line_inserts_one_garbage_row()
|
||||||
|
{
|
||||||
|
var user = CreatePlayer("a");
|
||||||
|
var target = CreatePlayer("b");
|
||||||
|
|
||||||
|
new AddGarbageLineItem().Apply(Context(user, target));
|
||||||
|
|
||||||
|
Assert.Equal(9, FilledCount(target.Board));
|
||||||
|
Assert.Equal(0, FilledCount(user.Board));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clear_line_removes_bottom_row()
|
||||||
|
{
|
||||||
|
var target = CreatePlayer("b");
|
||||||
|
FillRow(target.Board, Bottom(target.Board), 0);
|
||||||
|
target.Board[0, Bottom(target.Board) - 1] = new Cell(1);
|
||||||
|
|
||||||
|
new ClearBottomLineItem().Apply(Context(target, target));
|
||||||
|
|
||||||
|
Assert.Equal(1, FilledCount(target.Board));
|
||||||
|
Assert.False(target.Board[0, Bottom(target.Board)].IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Wipe_out_clears_the_board()
|
||||||
|
{
|
||||||
|
var target = CreatePlayer("b");
|
||||||
|
FillRow(target.Board, Bottom(target.Board), 0);
|
||||||
|
|
||||||
|
new ClearBoardItem().Apply(Context(target, target));
|
||||||
|
|
||||||
|
Assert.True(target.Board.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Switch_exchanges_boards()
|
||||||
|
{
|
||||||
|
var user = CreatePlayer("a");
|
||||||
|
var target = CreatePlayer("b");
|
||||||
|
FillRow(target.Board, Bottom(target.Board), 0);
|
||||||
|
|
||||||
|
new SwapBoardItem().Apply(Context(user, target));
|
||||||
|
|
||||||
|
Assert.Equal(9, FilledCount(user.Board));
|
||||||
|
Assert.True(target.Board.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Switch_pushes_active_piece_up_when_it_would_overlap()
|
||||||
|
{
|
||||||
|
var user = CreatePlayer("a");
|
||||||
|
var target = CreatePlayer("b");
|
||||||
|
user.Start();
|
||||||
|
for (var y = 20; y < target.Board.Height; y++)
|
||||||
|
{
|
||||||
|
FillRow(target.Board, y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
new SwapBoardItem().Apply(Context(user, target));
|
||||||
|
|
||||||
|
Assert.True(user.IsAlive);
|
||||||
|
Assert.True(user.Board.Fits(user.Active!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Blackout_hides_board_for_four_seconds()
|
||||||
|
{
|
||||||
|
var target = CreatePlayer("b");
|
||||||
|
target.Start();
|
||||||
|
|
||||||
|
new InvisibilityItem().Apply(Context(target, target, now: 10));
|
||||||
|
|
||||||
|
target.Update(0.01, 13.9);
|
||||||
|
Assert.True(target.IsInvisible);
|
||||||
|
target.Update(0.01, 14.1);
|
||||||
|
Assert.False(target.IsInvisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_contains_five_unique_items()
|
||||||
|
{
|
||||||
|
Assert.Equal(5, ItemRegistry.Default.All.Select(i => i.Id).Distinct().Count());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MatchTests
|
||||||
|
{
|
||||||
|
private static Match CreateMatch(params string[] ids) =>
|
||||||
|
new(ids.Select(id => new MatchPlayer(id, id)), new GameSettings { CountdownSeconds = 0 }, seed: 1);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Using_an_item_hits_selected_target_and_emits_event()
|
||||||
|
{
|
||||||
|
var match = CreateMatch("a", "b");
|
||||||
|
var a = match.Find("a")!;
|
||||||
|
var b = match.Find("b")!;
|
||||||
|
a.AddItem("add_line");
|
||||||
|
match.SelectTarget("a", "b");
|
||||||
|
|
||||||
|
match.HandleInput("a", InputAction.UseItem);
|
||||||
|
|
||||||
|
Assert.Empty(a.Inventory);
|
||||||
|
Assert.Equal(9, FilledCount(b.Board));
|
||||||
|
var used = Assert.Single(match.DrainEvents().OfType<ItemUsedEvent>());
|
||||||
|
Assert.Equal("b", used.TargetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Last_player_alive_wins()
|
||||||
|
{
|
||||||
|
var match = CreateMatch("a", "b", "c");
|
||||||
|
|
||||||
|
match.Forfeit("b");
|
||||||
|
Assert.Equal(MatchPhase.Running, match.Phase);
|
||||||
|
match.Forfeit("c");
|
||||||
|
|
||||||
|
Assert.Equal(MatchPhase.Finished, match.Phase);
|
||||||
|
Assert.Equal("a", match.WinnerId);
|
||||||
|
Assert.Equal(3, match.Places["b"]);
|
||||||
|
Assert.Equal(2, match.Places["c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Target_falls_back_to_self_when_target_is_eliminated()
|
||||||
|
{
|
||||||
|
var match = CreateMatch("a", "b", "c");
|
||||||
|
match.SelectTarget("a", "b");
|
||||||
|
|
||||||
|
match.Forfeit("b");
|
||||||
|
|
||||||
|
Assert.Equal("a", match.Find("a")!.TargetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Garbage_targets_cycle_through_living_opponents()
|
||||||
|
{
|
||||||
|
var match = CreateMatch("a", "b", "c");
|
||||||
|
var targeter = new CyclingGarbageTargeter();
|
||||||
|
var a = match.Find("a")!;
|
||||||
|
|
||||||
|
var targets = Enumerable.Range(0, 4).Select(_ => targeter.NextTarget(a, match.Players)!.PlayerId).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(["b", "c", "b", "c"], targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Countdown_delays_the_start()
|
||||||
|
{
|
||||||
|
var match = new Match([new MatchPlayer("a", "a")], new GameSettings { CountdownSeconds = 1 });
|
||||||
|
Assert.Equal(MatchPhase.Countdown, match.Phase);
|
||||||
|
Assert.Null(match.Find("a")!.Active);
|
||||||
|
|
||||||
|
match.Update(1.01);
|
||||||
|
|
||||||
|
Assert.Equal(MatchPhase.Running, match.Phase);
|
||||||
|
Assert.NotNull(match.Find("a")!.Active);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using ActionBlocks.Game.Core;
|
||||||
|
using static ActionBlocks.Game.Tests.TestHelpers;
|
||||||
|
|
||||||
|
namespace ActionBlocks.Game.Tests;
|
||||||
|
|
||||||
|
public class PlayerGameTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Piece_spawns_centered_and_falls_with_gravity()
|
||||||
|
{
|
||||||
|
var player = CreatePlayer(pieces: PieceType.T);
|
||||||
|
player.Start();
|
||||||
|
var startY = player.Active!.Y;
|
||||||
|
Assert.Equal(3, player.Active.X);
|
||||||
|
|
||||||
|
player.Update(1.1, 1.1);
|
||||||
|
|
||||||
|
Assert.Equal(startY + 1, player.Active!.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hard_drop_clears_line_and_collects_item_in_row()
|
||||||
|
{
|
||||||
|
var player = CreatePlayer(pieces: PieceType.I);
|
||||||
|
var bottom = Bottom(player.Board);
|
||||||
|
FillRow(player.Board, bottom, 3, 4, 5, 6);
|
||||||
|
player.Board[0, bottom] = new Cell(CellColor.Garbage, "swap");
|
||||||
|
player.Start();
|
||||||
|
|
||||||
|
player.HandleInput(InputAction.HardDrop);
|
||||||
|
|
||||||
|
Assert.Equal(1, player.Lines);
|
||||||
|
Assert.Equal(["swap"], player.Inventory);
|
||||||
|
Assert.Equal(0, FilledCount(player.Board));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Item_inside_piece_is_collected_when_its_row_clears()
|
||||||
|
{
|
||||||
|
var player = CreatePlayer(itemChance: 1, pieces: PieceType.I);
|
||||||
|
FillRow(player.Board, Bottom(player.Board), 3, 4, 5, 6);
|
||||||
|
player.Start();
|
||||||
|
var itemId = player.Active!.Spec.ItemId;
|
||||||
|
Assert.NotNull(itemId);
|
||||||
|
|
||||||
|
player.HandleInput(InputAction.HardDrop);
|
||||||
|
|
||||||
|
Assert.Equal([itemId!], player.Inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Item_stays_on_board_when_row_is_not_cleared()
|
||||||
|
{
|
||||||
|
var player = CreatePlayer(itemChance: 1, pieces: PieceType.I);
|
||||||
|
player.Start();
|
||||||
|
|
||||||
|
player.HandleInput(InputAction.HardDrop);
|
||||||
|
|
||||||
|
Assert.Empty(player.Inventory);
|
||||||
|
var bottom = Bottom(player.Board);
|
||||||
|
Assert.Contains(Enumerable.Range(0, 10), x => player.Board[x, bottom].ItemId is not null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hold_swaps_piece_once_per_lock()
|
||||||
|
{
|
||||||
|
var player = CreatePlayer(pieces: [PieceType.T, PieceType.O, PieceType.S]);
|
||||||
|
player.Start();
|
||||||
|
|
||||||
|
player.HandleInput(InputAction.Hold);
|
||||||
|
Assert.Equal(PieceType.T, player.Hold!.Type);
|
||||||
|
Assert.Equal(PieceType.O, player.Active!.Type);
|
||||||
|
|
||||||
|
player.HandleInput(InputAction.Hold);
|
||||||
|
Assert.Equal(PieceType.O, player.Active!.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pending_garbage_rises_after_a_lock_without_clear()
|
||||||
|
{
|
||||||
|
var player = CreatePlayer(pieces: PieceType.O);
|
||||||
|
player.Start();
|
||||||
|
player.QueueGarbage(2);
|
||||||
|
|
||||||
|
player.HandleInput(InputAction.HardDrop);
|
||||||
|
|
||||||
|
var bottom = Bottom(player.Board);
|
||||||
|
Assert.Equal(9, Enumerable.Range(0, 10).Count(x => player.Board[x, bottom].Color == CellColor.Garbage));
|
||||||
|
Assert.Equal(0, player.PendingGarbage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Double_clear_sends_one_line()
|
||||||
|
{
|
||||||
|
var player = CreatePlayer(pieces: PieceType.O);
|
||||||
|
var bottom = Bottom(player.Board);
|
||||||
|
FillRow(player.Board, bottom, 4, 5);
|
||||||
|
FillRow(player.Board, bottom - 1, 4, 5);
|
||||||
|
player.Board[0, bottom - 2] = new Cell(1); // avoid a perfect clear
|
||||||
|
player.Start();
|
||||||
|
|
||||||
|
player.HandleInput(InputAction.HardDrop);
|
||||||
|
|
||||||
|
Assert.Equal([1], player.DrainOutgoingAttacks());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stacking_to_the_top_ends_the_game()
|
||||||
|
{
|
||||||
|
var player = CreatePlayer(pieces: PieceType.O);
|
||||||
|
player.Start();
|
||||||
|
for (var i = 0; i < 30 && player.IsAlive; i++)
|
||||||
|
{
|
||||||
|
player.HandleInput(InputAction.HardDrop);
|
||||||
|
}
|
||||||
|
Assert.False(player.IsAlive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inventory_is_capped()
|
||||||
|
{
|
||||||
|
var player = CreatePlayer();
|
||||||
|
for (var i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
player.AddItem("nuke");
|
||||||
|
}
|
||||||
|
Assert.Equal(new GameSettings().MaxInventory, player.Inventory.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using ActionBlocks.Game.Core;
|
||||||
|
using ActionBlocks.Game.Garbage;
|
||||||
|
using ActionBlocks.Game.Items;
|
||||||
|
|
||||||
|
namespace ActionBlocks.Game.Tests;
|
||||||
|
|
||||||
|
internal sealed class FixedRandomizer(params PieceType[] sequence) : IRandomizer
|
||||||
|
{
|
||||||
|
private int _index;
|
||||||
|
|
||||||
|
public PieceType Next() => sequence[_index++ % sequence.Length];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class TestHelpers
|
||||||
|
{
|
||||||
|
public static PlayerGame CreatePlayer(string id = "p1", double itemChance = 0, params PieceType[] pieces) =>
|
||||||
|
new(
|
||||||
|
id,
|
||||||
|
id,
|
||||||
|
new GameSettings { ItemSpawnChance = itemChance },
|
||||||
|
new FixedRandomizer(pieces.Length == 0 ? [PieceType.I] : pieces),
|
||||||
|
new ItemSpawner(ItemRegistry.Default, itemChance, new Random(1)),
|
||||||
|
new SuperRotationSystem(),
|
||||||
|
new StandardGarbageCalculator(),
|
||||||
|
new StandardScoreCalculator(),
|
||||||
|
new Random(1));
|
||||||
|
|
||||||
|
public static int Bottom(Board board) => board.Height - 1;
|
||||||
|
|
||||||
|
/// <summary>Fills a row except the given columns.</summary>
|
||||||
|
public static void FillRow(Board board, int y, params int[] holes)
|
||||||
|
{
|
||||||
|
for (var x = 0; x < board.Width; x++)
|
||||||
|
{
|
||||||
|
board[x, y] = holes.Contains(x) ? Cell.Empty : new Cell(CellColor.Garbage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int FilledCount(Board board) =>
|
||||||
|
Enumerable.Range(0, board.Height).Sum(y => Enumerable.Range(0, board.Width).Count(x => !board[x, y].IsEmpty));
|
||||||
|
}
|
||||||
@@ -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,35 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
|
||||||
|
# Fingerprinted build output can be cached forever.
|
||||||
|
location /static/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Exchangeable graphics (public/assets) are not fingerprinted; revalidate them
|
||||||
|
# so replaced files show up immediately.
|
||||||
|
location /assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /config.js {
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Single page app fallback.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
|
||||||
|
<title>Action Blocks</title>
|
||||||
|
<!-- Runtime configuration, rewritten by the container on startup. -->
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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,84 @@
|
|||||||
|
/**
|
||||||
|
* Central registry of every graphic the game uses. Replace the files in /public/assets
|
||||||
|
* (or point these paths elsewhere) to change the look without touching components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ItemVisual {
|
||||||
|
/** Icon drawn on board cells, in inventories and item bars. */
|
||||||
|
icon: string;
|
||||||
|
/** Fallback name, the server sends the display name with each use. */
|
||||||
|
name: string;
|
||||||
|
/** Accent color used for flashes and as icon fallback. */
|
||||||
|
color: string;
|
||||||
|
/** Short explanation for the lobby help panel. */
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keyed by the server item id (IItem.Id). Add an entry here when adding an item on the server. */
|
||||||
|
export const itemVisuals: Record<string, ItemVisual> = {
|
||||||
|
add_line: {
|
||||||
|
icon: '/assets/items/add_line.svg',
|
||||||
|
name: 'Add Line',
|
||||||
|
color: '#ff4256',
|
||||||
|
description: 'Pushes a garbage line into the target field.',
|
||||||
|
},
|
||||||
|
clear_line: {
|
||||||
|
icon: '/assets/items/clear_line.svg',
|
||||||
|
name: 'Clear Line',
|
||||||
|
color: '#4fe35f',
|
||||||
|
description: 'Removes the bottom line of the target.',
|
||||||
|
},
|
||||||
|
nuke: {
|
||||||
|
icon: '/assets/items/nuke.svg',
|
||||||
|
name: 'Wipe Out',
|
||||||
|
color: '#ffe23f',
|
||||||
|
description: 'Clears every placed block of the target.',
|
||||||
|
},
|
||||||
|
swap: {
|
||||||
|
icon: '/assets/items/swap.svg',
|
||||||
|
name: 'Switch',
|
||||||
|
color: '#3fe3f2',
|
||||||
|
description: 'Trades your whole field with the target.',
|
||||||
|
},
|
||||||
|
blind: {
|
||||||
|
icon: '/assets/items/blind.svg',
|
||||||
|
name: 'Blackout',
|
||||||
|
color: '#c452ff',
|
||||||
|
description: 'Hides placed blocks and the drop shadow for 4 seconds.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unknownItem: ItemVisual = {
|
||||||
|
icon: '/assets/items/unknown.svg',
|
||||||
|
name: 'Item',
|
||||||
|
color: '#ffffff',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uiAssets = {
|
||||||
|
/** Optional logo image. When null the text logo is rendered. */
|
||||||
|
logo: null as string | null,
|
||||||
|
favicon: '/assets/favicon.svg',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function itemVisual(id: string): ItemVisual {
|
||||||
|
return itemVisuals[id] ?? unknownItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageCache = new Map<string, HTMLImageElement>();
|
||||||
|
|
||||||
|
/** Returns a (possibly still loading) image for canvas rendering. */
|
||||||
|
export function getImage(src: string): HTMLImageElement {
|
||||||
|
let image = imageCache.get(src);
|
||||||
|
if (!image) {
|
||||||
|
image = new Image();
|
||||||
|
image.src = src;
|
||||||
|
imageCache.set(src, image);
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preloadAssets(): void {
|
||||||
|
Object.values(itemVisuals).forEach((v) => getImage(v.icon));
|
||||||
|
getImage(unknownItem.icon);
|
||||||
|
}
|
||||||
@@ -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,23 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// Dev server forwards /hub to a locally running game server (dotnet run).
|
||||||
|
const devServer = process.env.DEV_SERVER_URL ?? 'http://localhost:5080';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
// Hashed bundles go to /static; /assets holds the exchangeable graphics from public/.
|
||||||
|
assetsDir: 'static',
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
modules: {
|
||||||
|
localsConvention: 'camelCaseOnly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/hub': { target: devServer, ws: true, changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Both networks must already exist and be attached to the reverse proxy:
|
||||||
|
# docker network create actionBlocks_website
|
||||||
|
# docker network create actionBlocks_server
|
||||||
|
#
|
||||||
|
# Reverse proxy routing:
|
||||||
|
# website -> actionblocks-website:80
|
||||||
|
# game hub -> actionblocks-server:8080, path /hub (needs WebSocket upgrade support:
|
||||||
|
# "Upgrade" and "Connection" headers must be forwarded).
|
||||||
|
# If the hub is served from a different host, set SERVER_URL accordingly and list the
|
||||||
|
# website origin in ALLOWED_ORIGINS.
|
||||||
|
|
||||||
|
services:
|
||||||
|
actionblocks-website:
|
||||||
|
build: ./actionBlocks_website
|
||||||
|
container_name: actionblocks-website
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVER_URL: ${SERVER_URL:-}
|
||||||
|
networks:
|
||||||
|
- actionBlocks_website
|
||||||
|
|
||||||
|
actionblocks-server:
|
||||||
|
build: ./actionBlocks_server
|
||||||
|
container_name: actionblocks-server
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
||||||
|
HUB_PATH: /hub
|
||||||
|
networks:
|
||||||
|
- actionBlocks_server
|
||||||
|
|
||||||
|
networks:
|
||||||
|
actionBlocks_website:
|
||||||
|
external: true
|
||||||
|
actionBlocks_server:
|
||||||
|
external: true
|
||||||