Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9178202c57 | ||
|
|
6184cdba0a | ||
|
|
0fb223cdee | ||
|
|
471dc33f0e | ||
|
|
1996b9c83e | ||
|
|
94052573b3 | ||
|
|
498df86962 | ||
|
|
0913adaca5 | ||
|
|
19428550ca |
@@ -0,0 +1,17 @@
|
||||
# Copy to .env (next to docker-compose.yml) and adjust. Docker Compose reads .env automatically.
|
||||
|
||||
# Full URL the browser uses to reach the game hub through the reverse proxy.
|
||||
# Empty = "<website origin>/hub".
|
||||
SERVER_URL=https://game.example.com/server/hub
|
||||
|
||||
# Prefix the game server should accept when the proxy forwards its sub-path without
|
||||
# stripping it (e.g. /server). Leave empty if the proxy strips the prefix.
|
||||
PATH_BASE=
|
||||
|
||||
# Comma separated origins allowed to connect to the hub. Empty = allow any origin.
|
||||
ALLOWED_ORIGINS=https://game.example.com
|
||||
|
||||
# Gameplay tuning (item chance, how items split it up, item durations, other rules) lives in
|
||||
# config/game.json, which the server container mounts. Edits apply from the next match on.
|
||||
# Single values can still be overridden as Game__<Setting> env vars under the server's
|
||||
# "environment:" in docker-compose.yml, e.g. Game__CountdownSeconds=3.
|
||||
@@ -0,0 +1 @@
|
||||
.env
|
||||
@@ -0,0 +1,81 @@
|
||||
# Action Blocks
|
||||
|
||||
Online player-vs-player falling-block game with items.
|
||||
|
||||
**Developer documentation:** see [`doc/`](doc/README.md) for architecture, game rules, protocol, frontend, deployment and how-to guides.
|
||||
|
||||
| Component | Path | Tech |
|
||||
|---|---|---|
|
||||
| Game server (logic, lobbies, players) | `actionBlocks_server/` | ASP.NET Core 10, SignalR |
|
||||
| Website | `actionBlocks_website/` | React 19 + Vite + TypeScript, CSS Modules, served by nginx |
|
||||
|
||||
## Gameplay
|
||||
|
||||
- 10x20 field, hold, 5-piece preview, ghost piece, bag randomizer, wall kicks, 0.5 s lock delay (15 move resets).
|
||||
- Garbage from line clears (attack table incl. spins, back-to-back, combos) goes to opponents in a **round-robin cycle**.
|
||||
- 1 in 10 pieces carries an **item** inside one of its blocks. Clear the line containing that block to collect it (max 10, FIFO).
|
||||
- **1-9** selects the item target (1 = you, 2.. = opponents in the order shown). **F** uses the first item on the target.
|
||||
- You never see who targets you, but every opponent's items are shown beneath their mini field.
|
||||
- Item names flash on top of the field they hit.
|
||||
- Retro sound effects and a background music playlist, all synthesized in the browser. Any sound can be swapped for an audio file. The speaker button in the upper right corner opens volume sliders and mute buttons for effects and music. **M** mutes everything, **B** toggles the music, **N** skips the track.
|
||||
|
||||
Controls: arrows or WASD to move/rotate/soft drop, Space for hard drop, Z/Q/Ctrl to rotate left, C/Shift to hold.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
# server on :5080
|
||||
cd actionBlocks_server && dotnet run --project src/ActionBlocks.Server --urls http://localhost:5080
|
||||
dotnet test # game logic tests
|
||||
|
||||
# website on :5173, proxies /hub to the server
|
||||
cd actionBlocks_website && npm install && npm run dev
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
docker network create actionBlocks_website # if not already created
|
||||
docker network create actionBlocks_server
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The website uses relative URLs. It works under a sub-path such as `/website/`, whether or not the proxy strips that prefix.
|
||||
|
||||
The reverse proxy (attached to both networks) should route:
|
||||
|
||||
- the site to `actionblocks-website:80`
|
||||
- `/hub` to `actionblocks-server:8080`, with WebSocket upgrade headers forwarded
|
||||
|
||||
| Variable | Container | Purpose |
|
||||
|---|---|---|
|
||||
| `SERVER_URL` | website | Hub URL as seen by the browser. Empty means `<site origin>/hub`. Written into `config.js` at container start. |
|
||||
| `ALLOWED_ORIGINS` | server | Comma-separated CORS origins. Empty allows any origin. |
|
||||
| `HUB_PATH` | server | Hub endpoint path (default `/hub`). |
|
||||
| `PATH_BASE` | server | Prefix to accept when the proxy forwards a sub-path unstripped, e.g. `/server`. |
|
||||
| `GAME_CONFIG` | server | Path of the gameplay config file. Set to `/app/config/game.json` in the compose file, which mounts `./config`. |
|
||||
| `Game__*` | server | Overrides any `GameSettings` value from the config file, e.g. `Game__ItemSpawnChance=0.2`. |
|
||||
|
||||
Gameplay tuning lives in **`config/game.json`**: the item chance per block, each item's weight (its share of that chance) and the duration of the timed items. The server picks up edits for the next match without a restart. See [Deployment](doc/deployment.md#game-config).
|
||||
|
||||
## 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`.
|
||||
- Sound files and music tracks are registered in `src/theme/assets.ts` (`soundFiles`, `musicTracks`). All volume levels are in `src/config/sound.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 = piece.Spec.Items is { } items && i < items.Count ? items[i] : 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,47 @@
|
||||
namespace ActionBlocks.Game.Core;
|
||||
|
||||
/// <summary>
|
||||
/// A piece definition as it travels through the queue: its shape plus optional items
|
||||
/// hidden inside its minos. <see cref="Items"/>[i] is the item in mino i (null = none).
|
||||
/// <see cref="Original"/> is set on a transformed piece and holds the piece it was before.
|
||||
/// </summary>
|
||||
public sealed record PieceSpec(PieceType Type, IReadOnlyList<string?>? Items = null, PieceSpec? Original = null)
|
||||
{
|
||||
public bool HasItem => Items?.Any(i => i is not null) == true;
|
||||
|
||||
/// <summary>The same piece as <paramref name="type"/>. Items move to the new minos in order; extras are dropped.</summary>
|
||||
public PieceSpec Transform(PieceType type)
|
||||
{
|
||||
var original = Original ?? this;
|
||||
var carried = original.Items?.Where(i => i is not null).ToList() ?? [];
|
||||
string?[]? items = null;
|
||||
if (carried.Count > 0)
|
||||
{
|
||||
items = new string?[PieceDefinitions.Minos(type, 0).Count];
|
||||
for (var i = 0; i < Math.Min(items.Length, carried.Count); i++)
|
||||
{
|
||||
items[i] = carried[i];
|
||||
}
|
||||
}
|
||||
return new PieceSpec(type, items, original);
|
||||
}
|
||||
|
||||
/// <summary>Undoes <see cref="Transform"/>.</summary>
|
||||
public PieceSpec Restore() => Original ?? this;
|
||||
}
|
||||
|
||||
/// <summary>A piece placed somewhere on the board (position = top-left corner of its bounding box).</summary>
|
||||
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,61 @@
|
||||
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)]),
|
||||
[PieceType.Dot] = (1, [new(0, 0)]),
|
||||
[PieceType.SmallL] = (2, [new(0, 0), new(0, 1), new(1, 1)]),
|
||||
[PieceType.U] = (3, [new(0, 0), new(2, 0), new(0, 1), new(1, 1), new(2, 1)]),
|
||||
[PieceType.Plus] = (3, [new(1, 0), new(0, 1), new(1, 1), new(2, 1), new(1, 2)]),
|
||||
[PieceType.W] = (3, [new(0, 0), new(0, 1), new(1, 1), new(1, 2), new(2, 2)]),
|
||||
};
|
||||
|
||||
private static readonly Dictionary<PieceType, Point[][]> Rotations = BuildRotations();
|
||||
|
||||
public static IReadOnlyList<PieceType> All { get; } = Enum.GetValues<PieceType>();
|
||||
|
||||
/// <summary>The seven four-cell shapes dealt by the randomizer.</summary>
|
||||
public static IReadOnlyList<PieceType> Standard { get; } = All.Where(t => t <= PieceType.T).ToArray();
|
||||
|
||||
/// <summary>Oddball shapes that only appear while a player is transformed.</summary>
|
||||
public static IReadOnlyList<PieceType> Odd { get; } = All.Where(t => t > PieceType.T).ToArray();
|
||||
|
||||
public static int BoxSize(PieceType type) => SpawnShapes[type].BoxSize;
|
||||
|
||||
/// <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,27 @@
|
||||
namespace ActionBlocks.Game.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Piece shapes. Numeric values double as cell colors: 1-7 are the seven four-cell shapes,
|
||||
/// 9-13 the odd shapes dealt only during a Transformation (8 is garbage).
|
||||
/// </summary>
|
||||
public enum PieceType : byte
|
||||
{
|
||||
I = 1,
|
||||
J = 2,
|
||||
L = 3,
|
||||
O = 4,
|
||||
S = 5,
|
||||
Z = 6,
|
||||
T = 7,
|
||||
Dot = 9,
|
||||
SmallL = 10,
|
||||
U = 11,
|
||||
Plus = 12,
|
||||
W = 13,
|
||||
}
|
||||
|
||||
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.Standard.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,87 @@
|
||||
using ActionBlocks.Game.Items;
|
||||
|
||||
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 each mino of a newly generated piece carries an item.</summary>
|
||||
public double ItemSpawnChance { get; init; } = 0.1;
|
||||
public int MaxInventory { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Per-item tuning by item id (e.g. "blind"). Items missing here keep weight 1 and their default duration.
|
||||
/// Usually filled from config/game.json; see <see cref="ItemRegistry.FromSettings"/>. Ids are case-insensitive.
|
||||
/// </summary>
|
||||
public Dictionary<string, ItemSettings> Items { get; init; } = new();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Problems with the item settings, as readable messages. Empty = valid.</summary>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (double.IsNaN(ItemSpawnChance) || ItemSpawnChance is < 0 or > 1)
|
||||
{
|
||||
errors.Add($"ItemSpawnChance must be between 0 and 1 (is {ItemSpawnChance}).");
|
||||
}
|
||||
foreach (var (id, item) in Items)
|
||||
{
|
||||
var known = ItemRegistry.Default.Get(id);
|
||||
if (known is null)
|
||||
{
|
||||
var ids = string.Join(", ", ItemRegistry.Default.All.Select(i => i.Id));
|
||||
errors.Add($"Items.{id}: unknown item. Known items: {ids}.");
|
||||
continue;
|
||||
}
|
||||
if (item.Weight is { } weight && (double.IsNaN(weight) || weight < 0))
|
||||
{
|
||||
errors.Add($"Items.{id}.Weight must be 0 or more (is {weight}).");
|
||||
}
|
||||
if (item.DurationSeconds is { } duration)
|
||||
{
|
||||
if (known.DurationSeconds is null)
|
||||
{
|
||||
errors.Add($"Items.{id}: this item acts instantly and has no DurationSeconds.");
|
||||
}
|
||||
else if (double.IsNaN(duration) || duration <= 0)
|
||||
{
|
||||
errors.Add($"Items.{id}.DurationSeconds must be more than 0 (is {duration}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tuning for one item. Null = keep the default.</summary>
|
||||
public sealed record ItemSettings
|
||||
{
|
||||
/// <summary>Relative spawn weight: the item's share is weight / sum of all weights. 0 = never spawns.</summary>
|
||||
public double? Weight { get; init; }
|
||||
|
||||
/// <summary>Effect length for timed items (Blackout, Transformation).</summary>
|
||||
public double? DurationSeconds { get; init; }
|
||||
}
|
||||
@@ -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 add it to <see cref="ItemRegistry.FromSettings"/>.
|
||||
/// 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>How long the effect lasts, or null for items that act instantly.</summary>
|
||||
double? DurationSeconds => null;
|
||||
|
||||
void Apply(ItemContext context);
|
||||
}
|
||||
|
||||
public sealed record ItemContext(PlayerGame User, PlayerGame Target, Random Random, double Now)
|
||||
{
|
||||
public bool IsSelfTarget => User == Target;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
namespace ActionBlocks.Game.Items;
|
||||
|
||||
/// <summary>All items that exist in the game, each with its spawn weight. Register new items in <see cref="FromSettings"/>.</summary>
|
||||
public sealed class ItemRegistry
|
||||
{
|
||||
private readonly Dictionary<string, (IItem Item, double Weight)> _items;
|
||||
private readonly double _totalWeight;
|
||||
|
||||
public ItemRegistry(IEnumerable<IItem> items)
|
||||
: this(items.Select(i => (i, 1.0)))
|
||||
{
|
||||
}
|
||||
|
||||
public ItemRegistry(IEnumerable<(IItem Item, double Weight)> items)
|
||||
{
|
||||
_items = items.ToDictionary(e => e.Item.Id, e => (e.Item, Math.Max(0, e.Weight)), StringComparer.OrdinalIgnoreCase);
|
||||
_totalWeight = _items.Values.Sum(e => e.Weight);
|
||||
}
|
||||
|
||||
/// <summary>The built-in items with their default durations and equal weights.</summary>
|
||||
public static ItemRegistry Default { get; } = FromSettings(new GameSettings());
|
||||
|
||||
/// <summary>
|
||||
/// The built-in items with durations and weights from <see cref="GameSettings.Items"/>.
|
||||
/// Items missing there keep weight 1 and their default duration.
|
||||
/// </summary>
|
||||
public static ItemRegistry FromSettings(GameSettings settings)
|
||||
{
|
||||
// Item ids in the config are case-insensitive, like all configuration keys.
|
||||
var config = new Dictionary<string, ItemSettings>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (id, item) in settings.Items)
|
||||
{
|
||||
config[id] = item;
|
||||
}
|
||||
double Duration(string id, double fallback) => config.GetValueOrDefault(id)?.DurationSeconds ?? fallback;
|
||||
|
||||
IItem[] items =
|
||||
[
|
||||
new AddGarbageLineItem(),
|
||||
new ClearBottomLineItem(),
|
||||
new ClearBoardItem(),
|
||||
new SwapBoardItem(),
|
||||
new InvisibilityItem(Duration(InvisibilityItem.ItemId, InvisibilityItem.DefaultDurationSeconds)),
|
||||
new TransformationItem(Duration(TransformationItem.ItemId, TransformationItem.DefaultDurationSeconds)),
|
||||
];
|
||||
return new(items.Select(i => (i, config.GetValueOrDefault(i.Id)?.Weight ?? 1)));
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<IItem> All => _items.Values.Select(e => e.Item).ToList();
|
||||
|
||||
/// <summary>False when every weight is 0, i.e. no item can spawn.</summary>
|
||||
public bool CanSpawn => _totalWeight > 0;
|
||||
|
||||
public IItem? Get(string id) => _items.TryGetValue(id, out var e) ? e.Item : null;
|
||||
|
||||
/// <summary>Fraction (0..1) of spawned items that are <paramref name="id"/>.</summary>
|
||||
public double Share(string id) =>
|
||||
_totalWeight > 0 && _items.TryGetValue(id, out var e) ? e.Weight / _totalWeight : 0;
|
||||
|
||||
public IItem PickRandom(Random random)
|
||||
{
|
||||
var roll = random.NextDouble() * _totalWeight;
|
||||
IItem? last = null;
|
||||
foreach (var (item, weight) in _items.Values)
|
||||
{
|
||||
if (weight <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
last = item;
|
||||
roll -= weight;
|
||||
if (roll < 0)
|
||||
{
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return last ?? throw new InvalidOperationException("No item has a spawn weight above 0.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Decides which minos of a new piece carry an item. Each mino rolls <c>chance</c> (0..1) on its own.</summary>
|
||||
public sealed class ItemSpawner(ItemRegistry registry, double chance, Random random)
|
||||
{
|
||||
private readonly double _chance = double.IsNaN(chance) ? 0 : Math.Clamp(chance, 0, 1);
|
||||
|
||||
public Core.PieceSpec Decorate(Core.PieceType type)
|
||||
{
|
||||
if (!registry.CanSpawn)
|
||||
{
|
||||
return new Core.PieceSpec(type);
|
||||
}
|
||||
var items = new string?[Core.PieceDefinitions.Minos(type, 0).Count];
|
||||
var any = false;
|
||||
for (var i = 0; i < items.Length; i++)
|
||||
{
|
||||
if (random.NextDouble() < _chance)
|
||||
{
|
||||
items[i] = registry.PickRandom(random).Id;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
return any ? new Core.PieceSpec(type, items) : new Core.PieceSpec(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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(double durationSeconds = InvisibilityItem.DefaultDurationSeconds) : IItem
|
||||
{
|
||||
public const string ItemId = "blind";
|
||||
public const double DefaultDurationSeconds = 4;
|
||||
|
||||
public string Id => ItemId;
|
||||
public string DisplayName => "Blackout";
|
||||
public double? DurationSeconds => durationSeconds;
|
||||
|
||||
// Stacks: a second Blackout adds its full duration to whatever is left.
|
||||
public void Apply(ItemContext context) =>
|
||||
context.Target.InvisibleUntil = Math.Max(context.Target.InvisibleUntil, context.Now) + durationSeconds;
|
||||
}
|
||||
|
||||
public sealed class TransformationItem(double durationSeconds = TransformationItem.DefaultDurationSeconds) : IItem
|
||||
{
|
||||
public const string ItemId = "transform";
|
||||
public const double DefaultDurationSeconds = 8;
|
||||
|
||||
public string Id => ItemId;
|
||||
public string DisplayName => "Transformation";
|
||||
public double? DurationSeconds => durationSeconds;
|
||||
|
||||
// Stacks like Blackout.
|
||||
public void Apply(ItemContext context) => context.Target.Transform(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,598 @@
|
||||
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>Match time until which this player's upcoming pieces are odd shapes.</summary>
|
||||
public double TransformedUntil { get; private set; }
|
||||
public bool IsTransformed => Now < TransformedUntil;
|
||||
|
||||
/// <summary>The player selected as item target (own id = self).</summary>
|
||||
public string TargetId { get; set; }
|
||||
|
||||
public void Start()
|
||||
{
|
||||
FillQueue();
|
||||
SpawnNext();
|
||||
}
|
||||
|
||||
public void Update(double deltaSeconds, double now)
|
||||
{
|
||||
var wasTransformed = IsTransformed;
|
||||
Now = now;
|
||||
if (wasTransformed && !IsTransformed)
|
||||
{
|
||||
RestoreTransformedPieces();
|
||||
}
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// Turns the queue into odd shapes for <paramref name="seconds"/> (added to any time left).
|
||||
/// The falling piece stays as it is; pieces still queued or held change back when the time runs out.
|
||||
/// </summary>
|
||||
public void Transform(double now, double seconds)
|
||||
{
|
||||
TransformedUntil = Math.Max(TransformedUntil, now) + seconds;
|
||||
for (var i = 0; i < _next.Count; i++)
|
||||
{
|
||||
if (_next[i].Original is null)
|
||||
{
|
||||
_next[i] = TransformRandomly(_next[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PieceSpec TransformRandomly(PieceSpec spec) =>
|
||||
spec.Transform(PieceDefinitions.Odd[_random.Next(PieceDefinitions.Odd.Count)]);
|
||||
|
||||
private void RestoreTransformedPieces()
|
||||
{
|
||||
for (var i = 0; i < _next.Count; i++)
|
||||
{
|
||||
_next[i] = _next[i].Restore();
|
||||
}
|
||||
Hold = Hold?.Restore();
|
||||
}
|
||||
|
||||
private void FillQueue()
|
||||
{
|
||||
while (_next.Count < Math.Max(1, _settings.PreviewCount))
|
||||
{
|
||||
var spec = _itemSpawner.Decorate(_randomizer.Next());
|
||||
_next.Add(IsTransformed ? TransformRandomly(spec) : spec);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// A piece left over from an expired Transformation changes back once it is put away.
|
||||
var current = IsTransformed ? Active.Spec : Active.Spec.Restore();
|
||||
HoldUsed = true;
|
||||
if (Hold is null)
|
||||
{
|
||||
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,79 @@
|
||||
using Microsoft.Extensions.Configuration.EnvironmentVariables;
|
||||
using Microsoft.Extensions.Configuration.Json;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
|
||||
namespace ActionBlocks.Server.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Location of the game config file (config/game.json) and the last error from reading it.
|
||||
/// The file is added as a configuration source, so its "Game" section binds to <see cref="Game.GameSettings"/>.
|
||||
/// </summary>
|
||||
public sealed class GameConfigFile
|
||||
{
|
||||
private const string RelativePath = "config/game.json";
|
||||
|
||||
/// <summary>Full path of the file, or null when none was configured or found.</summary>
|
||||
public string? Path { get; private init; }
|
||||
|
||||
public bool Exists => Path is not null && File.Exists(Path);
|
||||
|
||||
/// <summary>Why the last read failed (e.g. a JSON syntax error), or null. Cleared by <see cref="TakeLoadError"/>.</summary>
|
||||
private string? _loadError;
|
||||
|
||||
public string? TakeLoadError() => Interlocked.Exchange(ref _loadError, null);
|
||||
|
||||
/// <summary>
|
||||
/// Adds the file as a reloading configuration source. <paramref name="configuredPath"/> comes from GAME_CONFIG;
|
||||
/// when empty, config/game.json is searched upwards from <paramref name="contentRoot"/> (finds the repo root in dev).
|
||||
/// </summary>
|
||||
public static GameConfigFile AddTo(IConfigurationBuilder configuration, string? configuredPath, string contentRoot)
|
||||
{
|
||||
var file = new GameConfigFile
|
||||
{
|
||||
Path = string.IsNullOrWhiteSpace(configuredPath)
|
||||
? FindUpwards(contentRoot)
|
||||
: System.IO.Path.GetFullPath(configuredPath, contentRoot),
|
||||
};
|
||||
if (file.Path is null)
|
||||
{
|
||||
return file;
|
||||
}
|
||||
var source = new JsonConfigurationSource
|
||||
{
|
||||
FileProvider = new PhysicalFileProvider(System.IO.Path.GetDirectoryName(file.Path)!),
|
||||
Path = System.IO.Path.GetFileName(file.Path),
|
||||
Optional = true,
|
||||
ReloadOnChange = true,
|
||||
// Don't crash on a broken edit; GameConfigProvider reports it and keeps the last good values.
|
||||
OnLoadException = context =>
|
||||
{
|
||||
file._loadError = context.Exception.InnerException?.Message ?? context.Exception.Message;
|
||||
context.Ignore = true;
|
||||
},
|
||||
};
|
||||
// Later sources win: insert before the environment variables so Game__* env vars still override the file.
|
||||
var envIndex = configuration.Sources.ToList().FindIndex(s => s is EnvironmentVariablesConfigurationSource);
|
||||
if (envIndex < 0)
|
||||
{
|
||||
configuration.Sources.Add(source);
|
||||
}
|
||||
else
|
||||
{
|
||||
configuration.Sources.Insert(envIndex, source);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private static string? FindUpwards(string start)
|
||||
{
|
||||
for (var dir = new DirectoryInfo(start); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = System.IO.Path.Combine(dir.FullName, RelativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using ActionBlocks.Game;
|
||||
using ActionBlocks.Game.Items;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace ActionBlocks.Server.Config;
|
||||
|
||||
/// <summary>Settings and items for new matches. A match keeps the instance it started with.</summary>
|
||||
public sealed record GameConfig(GameSettings Settings, ItemRegistry Items);
|
||||
|
||||
/// <summary>
|
||||
/// Binds the "Game" configuration section and follows changes to config/game.json.
|
||||
/// An invalid config at startup stops the server; an invalid edit later is logged and the last good config stays.
|
||||
/// </summary>
|
||||
public sealed class GameConfigProvider : IDisposable
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly GameConfigFile _file;
|
||||
private readonly ILogger<GameConfigProvider> _logger;
|
||||
private readonly IDisposable _subscription;
|
||||
private volatile GameConfig _current;
|
||||
|
||||
public GameConfigProvider(IConfiguration configuration, GameConfigFile file, ILogger<GameConfigProvider> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_file = file;
|
||||
_logger = logger;
|
||||
|
||||
var (config, errors) = Load();
|
||||
if (config is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid game config ({file.Path ?? "no file"}):\n {string.Join("\n ", errors)}");
|
||||
}
|
||||
_current = config;
|
||||
if (file.Path is null)
|
||||
{
|
||||
_logger.LogInformation("No {File} found, using built-in game settings", "config/game.json");
|
||||
}
|
||||
LogValues("Game config loaded", config);
|
||||
_subscription = ChangeToken.OnChange(configuration.GetReloadToken, Reload);
|
||||
}
|
||||
|
||||
public GameConfig Current => _current;
|
||||
|
||||
private void Reload()
|
||||
{
|
||||
if (_file.Path is not null && !_file.Exists)
|
||||
{
|
||||
_logger.LogWarning("Game config {Path} is missing, keeping the previous values", _file.Path);
|
||||
return;
|
||||
}
|
||||
var (config, errors) = Load();
|
||||
if (config is null)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Game config change ignored, keeping the previous values:\n {Errors}", string.Join("\n ", errors));
|
||||
return;
|
||||
}
|
||||
_current = config;
|
||||
LogValues("Game config reloaded, used from the next match", config);
|
||||
}
|
||||
|
||||
private (GameConfig? Config, IReadOnlyList<string> Errors) Load()
|
||||
{
|
||||
if (_file.TakeLoadError() is { } loadError)
|
||||
{
|
||||
return (null, [loadError]);
|
||||
}
|
||||
GameSettings settings;
|
||||
try
|
||||
{
|
||||
settings = _configuration.GetSection("Game").Get<GameSettings>() ?? new GameSettings();
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
// A value of the wrong type, e.g. "Weight": "high".
|
||||
return (null, [e.InnerException?.Message ?? e.Message]);
|
||||
}
|
||||
var errors = settings.Validate();
|
||||
return errors.Count > 0 ? (null, errors) : (new GameConfig(settings, ItemRegistry.FromSettings(settings)), errors);
|
||||
}
|
||||
|
||||
private void LogValues(string message, GameConfig config)
|
||||
{
|
||||
var items = config.Items.All.Select(i =>
|
||||
$"{i.Id} {config.Items.Share(i.Id):P0}" + (i.DurationSeconds is { } d ? $" {d}s" : ""));
|
||||
_logger.LogInformation(
|
||||
"{Message}: item chance {Chance} per block; {Items}",
|
||||
message,
|
||||
config.Settings.ItemSpawnChance,
|
||||
string.Join(", ", items));
|
||||
}
|
||||
|
||||
public void Dispose() => _subscription.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
|
||||
/// <summary><see cref="Items"/>[i] is the item in mino i, null if none.</summary>
|
||||
public sealed record PieceDto(int Type, string?[] Items);
|
||||
|
||||
public sealed record ItemCellDto(int X, int Y, string ItemId);
|
||||
|
||||
/// <summary><see cref="Items"/>[i] is the item in <see cref="Cells"/>[i], null if none.</summary>
|
||||
public sealed record ActivePieceDto(int Type, int[][] Cells, string?[] Items);
|
||||
|
||||
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,
|
||||
double TransformedRemaining);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <param name="Share">Fraction (0..1) of spawned items that are this item; 0 = disabled.</param>
|
||||
/// <param name="DurationSeconds">Effect length for timed items, null for instant ones.</param>
|
||||
public sealed record ItemInfoDto(string Id, string Name, double Share, double? DurationSeconds);
|
||||
@@ -0,0 +1,74 @@
|
||||
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),
|
||||
Math.Round(Math.Max(0, player.TransformedUntil - match.Now), 2));
|
||||
}
|
||||
|
||||
private static ActivePieceDto ToDto(ActivePiece piece, Board board) =>
|
||||
new((int)piece.Type, ToCells(piece, board), ToItems(piece.Spec));
|
||||
|
||||
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, ToItems(spec));
|
||||
|
||||
private static string?[] ToItems(PieceSpec spec) => spec.Items?.ToArray() ?? new string?[PieceDefinitions.Minos(spec.Type, 0).Count];
|
||||
|
||||
private static MatchEventDto ToDto(MatchEvent e) => e switch
|
||||
{
|
||||
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,139 @@
|
||||
using ActionBlocks.Game;
|
||||
using ActionBlocks.Server.Config;
|
||||
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, GameConfigProvider config) : 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(config.Current.Settings, config.Current.Items);
|
||||
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));
|
||||
|
||||
/// <summary>Items of the current config with their spawn share and duration (used by the lobby help panel).</summary>
|
||||
public IReadOnlyList<ItemInfoDto> GetItems()
|
||||
{
|
||||
var items = config.Current.Items;
|
||||
return items.All.Select(i => new ItemInfoDto(i.Id, i.DisplayName, items.Share(i.Id), i.DurationSeconds)).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,80 @@
|
||||
using ActionBlocks.Game;
|
||||
using ActionBlocks.Game.Items;
|
||||
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, ItemRegistry items)
|
||||
{
|
||||
Match = new Match(_players.Select(p => new MatchPlayer(p.Id, p.Name)), settings, items);
|
||||
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,56 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using ActionBlocks.Server.Config;
|
||||
using ActionBlocks.Server.Hubs;
|
||||
using ActionBlocks.Server.Lobbies;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Comma separated list of origins allowed to connect, e.g. "https://blocks.example.com".
|
||||
// Empty = allow any origin (useful for local development).
|
||||
var allowedOrigins = (builder.Configuration["ALLOWED_ORIGINS"] ?? "")
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var hubPath = builder.Configuration["HUB_PATH"] ?? "/hub";
|
||||
// Optional prefix when the reverse proxy forwards a sub-path without stripping it, e.g. "/server".
|
||||
// Requests without the prefix keep working.
|
||||
var pathBase = builder.Configuration["PATH_BASE"] ?? "";
|
||||
|
||||
builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
if (allowedOrigins.Length > 0)
|
||||
{
|
||||
policy.WithOrigins(allowedOrigins);
|
||||
}
|
||||
else
|
||||
{
|
||||
policy.SetIsOriginAllowed(_ => true);
|
||||
}
|
||||
policy.AllowAnyHeader().AllowAnyMethod().AllowCredentials();
|
||||
}));
|
||||
|
||||
builder.Services
|
||||
.AddSignalR()
|
||||
.AddJsonProtocol(o => o.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
|
||||
// Rules come from the "Game" section: config/game.json (path in GAME_CONFIG, reloaded for each new match),
|
||||
// overridable by env vars such as Game__ItemSpawnChance=0.2.
|
||||
builder.Services.AddSingleton(
|
||||
GameConfigFile.AddTo(builder.Configuration, builder.Configuration["GAME_CONFIG"], builder.Environment.ContentRootPath));
|
||||
builder.Services.AddSingleton<GameConfigProvider>();
|
||||
builder.Services.AddSingleton<LobbyManager>();
|
||||
builder.Services.AddHostedService<MatchRunner>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Read the game config now, so an invalid file stops the server at startup instead of at the first match.
|
||||
app.Services.GetRequiredService<GameConfigProvider>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pathBase))
|
||||
{
|
||||
app.UsePathBase(pathBase);
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||
app.MapHub<GameHub>(hubPath);
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ActionBlocks.Game\ActionBlocks.Game.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,134 @@
|
||||
using ActionBlocks.Game.Core;
|
||||
using ActionBlocks.Game.Items;
|
||||
using static ActionBlocks.Game.Tests.TestHelpers;
|
||||
|
||||
namespace ActionBlocks.Game.Tests;
|
||||
|
||||
public class ConfigTests
|
||||
{
|
||||
private static GameSettings WithItems(params (string Id, ItemSettings Item)[] items) =>
|
||||
new() { Items = items.ToDictionary(i => i.Id, i => i.Item, StringComparer.OrdinalIgnoreCase) };
|
||||
|
||||
private static ItemContext Context(PlayerGame target, double now) => new(target, target, new Random(3), now);
|
||||
|
||||
[Fact]
|
||||
public void Empty_settings_keep_the_built_in_defaults()
|
||||
{
|
||||
var registry = ItemRegistry.FromSettings(new GameSettings());
|
||||
|
||||
Assert.Equal(6, registry.All.Count);
|
||||
Assert.All(registry.All, i => Assert.Equal(1.0 / 6, registry.Share(i.Id), 6));
|
||||
Assert.Equal(4, registry.Get("blind")!.DurationSeconds);
|
||||
Assert.Equal(8, registry.Get("transform")!.DurationSeconds);
|
||||
Assert.Null(registry.Get("nuke")!.DurationSeconds);
|
||||
Assert.Empty(new GameSettings().Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configured_durations_change_the_effect_length()
|
||||
{
|
||||
var registry = ItemRegistry.FromSettings(WithItems(
|
||||
("blind", new ItemSettings { DurationSeconds = 2 }),
|
||||
("transform", new ItemSettings { DurationSeconds = 3 })));
|
||||
var target = CreatePlayer("b");
|
||||
target.Start();
|
||||
|
||||
registry.Get("blind")!.Apply(Context(target, 10));
|
||||
registry.Get("transform")!.Apply(Context(target, 10));
|
||||
|
||||
target.Update(0.01, 11.9);
|
||||
Assert.True(target.IsInvisible);
|
||||
target.Update(0.01, 12.1);
|
||||
Assert.False(target.IsInvisible);
|
||||
Assert.True(target.IsTransformed);
|
||||
target.Update(0.01, 13.1);
|
||||
Assert.False(target.IsTransformed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_items_with_weight_spawn()
|
||||
{
|
||||
var settings = WithItems(
|
||||
("add_line", new ItemSettings { Weight = 0 }),
|
||||
("clear_line", new ItemSettings { Weight = 0 }),
|
||||
("nuke", new ItemSettings { Weight = 0 }),
|
||||
("swap", new ItemSettings { Weight = 0 }),
|
||||
("transform", new ItemSettings { Weight = 0 }));
|
||||
var spawner = new ItemSpawner(ItemRegistry.FromSettings(settings), 1, new Random(1));
|
||||
|
||||
var items = Enumerable.Range(0, 50).SelectMany(_ => spawner.Decorate(PieceType.T).Items!).ToHashSet();
|
||||
|
||||
Assert.Equal(["blind"], items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weights_split_the_item_chance()
|
||||
{
|
||||
var settings = WithItems(
|
||||
("add_line", new ItemSettings { Weight = 3 }),
|
||||
("nuke", new ItemSettings { Weight = 1 }),
|
||||
("clear_line", new ItemSettings { Weight = 0 }),
|
||||
("swap", new ItemSettings { Weight = 0 }),
|
||||
("blind", new ItemSettings { Weight = 0 }),
|
||||
("transform", new ItemSettings { Weight = 0 }));
|
||||
var registry = ItemRegistry.FromSettings(settings);
|
||||
var random = new Random(7);
|
||||
|
||||
var picks = Enumerable.Range(0, 4000).Select(_ => registry.PickRandom(random).Id).ToList();
|
||||
|
||||
Assert.Equal(0.75, registry.Share("add_line"));
|
||||
Assert.Equal(0.25, registry.Share("nuke"));
|
||||
Assert.InRange(picks.Count(p => p == "add_line") / 4000.0, 0.72, 0.78);
|
||||
Assert.Equal(4000, picks.Count(p => p is "add_line" or "nuke"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_weights_zero_means_no_items()
|
||||
{
|
||||
var settings = new GameSettings
|
||||
{
|
||||
Items = ItemRegistry.Default.All.ToDictionary(i => i.Id, _ => new ItemSettings { Weight = 0 }),
|
||||
};
|
||||
var spawner = new ItemSpawner(ItemRegistry.FromSettings(settings), 1, new Random(1));
|
||||
|
||||
Assert.Empty(settings.Validate());
|
||||
Assert.False(spawner.Decorate(PieceType.T).HasItem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Item_ids_are_case_insensitive()
|
||||
{
|
||||
var settings = new GameSettings { Items = new() { ["Blind"] = new ItemSettings { DurationSeconds = 2 } } };
|
||||
|
||||
Assert.Empty(settings.Validate());
|
||||
Assert.Equal(2, ItemRegistry.FromSettings(settings).Get("blind")!.DurationSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_items_keep_weight_one()
|
||||
{
|
||||
var registry = ItemRegistry.FromSettings(WithItems(("nuke", new ItemSettings { Weight = 5 })));
|
||||
|
||||
Assert.Equal(0.5, registry.Share("nuke"));
|
||||
Assert.Equal(0.1, registry.Share("blind"), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_reports_every_problem()
|
||||
{
|
||||
var settings = WithItems(
|
||||
("add_line", new ItemSettings { Weight = -1 }),
|
||||
("blind", new ItemSettings { DurationSeconds = 0 }),
|
||||
("nuke", new ItemSettings { DurationSeconds = 5 }),
|
||||
("laser", new ItemSettings { Weight = 1 })) with { ItemSpawnChance = 1.5 };
|
||||
|
||||
var errors = settings.Validate();
|
||||
|
||||
Assert.Equal(5, errors.Count);
|
||||
Assert.Contains(errors, e => e.StartsWith("ItemSpawnChance"));
|
||||
Assert.Contains(errors, e => e.StartsWith("Items.add_line.Weight"));
|
||||
Assert.Contains(errors, e => e.StartsWith("Items.blind.DurationSeconds"));
|
||||
Assert.Contains(errors, e => e.StartsWith("Items.nuke: this item acts instantly"));
|
||||
Assert.Contains(errors, e => e.StartsWith("Items.laser: unknown item"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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 Rotations_keep_the_mino_count()
|
||||
{
|
||||
foreach (var type in PieceDefinitions.All)
|
||||
{
|
||||
var count = PieceDefinitions.Minos(type, 0).Count;
|
||||
Assert.All(Enumerable.Range(1, 3), r => Assert.Equal(count, PieceDefinitions.Minos(type, r).Distinct().Count()));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seven_bag_never_deals_odd_shapes()
|
||||
{
|
||||
var randomizer = new SevenBagRandomizer(new Random(42));
|
||||
|
||||
Assert.All(Enumerable.Range(0, 70), _ => Assert.Contains(randomizer.Next(), PieceDefinitions.Standard));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seven_bag_deals_every_shape_once_per_bag()
|
||||
{
|
||||
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,326 @@
|
||||
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 Blackout_durations_stack()
|
||||
{
|
||||
var target = CreatePlayer("b");
|
||||
target.Start();
|
||||
|
||||
new InvisibilityItem().Apply(Context(target, target, now: 10));
|
||||
new InvisibilityItem().Apply(Context(target, target, now: 12));
|
||||
|
||||
target.Update(0.01, 17.9);
|
||||
Assert.True(target.IsInvisible);
|
||||
target.Update(0.01, 18.1);
|
||||
Assert.False(target.IsInvisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blackout_after_expiry_starts_fresh()
|
||||
{
|
||||
var target = CreatePlayer("b");
|
||||
target.Start();
|
||||
|
||||
new InvisibilityItem().Apply(Context(target, target, now: 10));
|
||||
new InvisibilityItem().Apply(Context(target, target, now: 20));
|
||||
|
||||
target.Update(0.01, 23.9);
|
||||
Assert.True(target.IsInvisible);
|
||||
target.Update(0.01, 24.1);
|
||||
Assert.False(target.IsInvisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transformation_turns_the_queue_into_odd_shapes_but_not_the_falling_piece()
|
||||
{
|
||||
var target = CreatePlayer("b", pieces: [PieceType.T, PieceType.O, PieceType.S]);
|
||||
target.Start();
|
||||
|
||||
new TransformationItem().Apply(Context(target, target, now: 10));
|
||||
|
||||
Assert.Equal(PieceType.T, target.Active!.Type);
|
||||
Assert.All(target.Next, p => Assert.Contains(p.Type, PieceDefinitions.Odd));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transformation_deals_odd_shapes_while_active()
|
||||
{
|
||||
var target = CreatePlayer("b");
|
||||
target.Start();
|
||||
|
||||
new TransformationItem().Apply(Context(target, target));
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
target.HandleInput(InputAction.HardDrop);
|
||||
}
|
||||
|
||||
Assert.True(target.IsAlive);
|
||||
Assert.Contains(target.Active!.Type, PieceDefinitions.Odd);
|
||||
Assert.All(target.Next, p => Assert.Contains(p.Type, PieceDefinitions.Odd));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transformation_expiry_restores_queue_and_hold()
|
||||
{
|
||||
var target = CreatePlayer("b", pieces: [PieceType.T, PieceType.O, PieceType.S]);
|
||||
target.Start();
|
||||
var queued = target.Next.Select(p => p.Type).ToList();
|
||||
|
||||
new TransformationItem().Apply(Context(target, target));
|
||||
target.HandleInput(InputAction.HardDrop);
|
||||
target.HandleInput(InputAction.Hold);
|
||||
Assert.Contains(target.Hold!.Type, PieceDefinitions.Odd);
|
||||
|
||||
target.Update(0.01, 7.9);
|
||||
Assert.True(target.IsTransformed);
|
||||
target.Update(0.01, 8.1);
|
||||
|
||||
Assert.False(target.IsTransformed);
|
||||
Assert.Equal(queued[0], target.Hold!.Type);
|
||||
Assert.All(target.Next, p => Assert.Contains(p.Type, PieceDefinitions.Standard));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transformation_durations_stack()
|
||||
{
|
||||
var target = CreatePlayer("b");
|
||||
target.Start();
|
||||
|
||||
new TransformationItem().Apply(Context(target, target, now: 10));
|
||||
new TransformationItem().Apply(Context(target, target, now: 12));
|
||||
|
||||
target.Update(0.01, 25.9);
|
||||
Assert.True(target.IsTransformed);
|
||||
target.Update(0.01, 26.1);
|
||||
Assert.False(target.IsTransformed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transformed_piece_keeps_its_items_and_restores_them()
|
||||
{
|
||||
var spec = new PieceSpec(PieceType.T, [null, "nuke", null, "swap"]);
|
||||
|
||||
var plus = spec.Transform(PieceType.Plus);
|
||||
var dot = plus.Transform(PieceType.Dot);
|
||||
|
||||
Assert.Equal(["nuke", "swap", null, null, null], plus.Items);
|
||||
Assert.Equal(["nuke"], dot.Items);
|
||||
Assert.Same(spec, dot.Restore());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_contains_six_unique_items()
|
||||
{
|
||||
Assert.Equal(6, ItemRegistry.Default.All.Select(i => i.Id).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spawner_sizes_items_to_the_shape()
|
||||
{
|
||||
var spawner = new ItemSpawner(ItemRegistry.Default, 1, new Random(1));
|
||||
|
||||
Assert.Equal(5, spawner.Decorate(PieceType.Plus).Items!.Count);
|
||||
Assert.Single(spawner.Decorate(PieceType.Dot).Items!);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void Spawner_with_zero_chance_never_adds_items(double chance)
|
||||
{
|
||||
var spawner = new ItemSpawner(ItemRegistry.Default, chance, new Random(1));
|
||||
|
||||
Assert.All(Enumerable.Range(0, 100), _ => Assert.False(spawner.Decorate(PieceType.T).HasItem));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void Spawner_with_full_chance_puts_an_item_in_every_mino(double chance)
|
||||
{
|
||||
var spawner = new ItemSpawner(ItemRegistry.Default, chance, new Random(1));
|
||||
|
||||
var spec = spawner.Decorate(PieceType.T);
|
||||
|
||||
Assert.NotNull(spec.Items);
|
||||
Assert.Equal(4, spec.Items.Count);
|
||||
Assert.All(spec.Items, i => Assert.NotNull(i));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spawner_rolls_each_mino_separately()
|
||||
{
|
||||
var spawner = new ItemSpawner(ItemRegistry.Default, 0.5, new Random(1));
|
||||
|
||||
var counts = Enumerable.Range(0, 1000)
|
||||
.Select(_ => spawner.Decorate(PieceType.T).Items?.Count(i => i is not null) ?? 0)
|
||||
.ToList();
|
||||
|
||||
Assert.Contains(counts, c => c >= 2);
|
||||
Assert.InRange(counts.Average(), 1.8, 2.2);
|
||||
}
|
||||
}
|
||||
|
||||
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,130 @@
|
||||
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 Items_inside_piece_are_collected_when_their_row_clears()
|
||||
{
|
||||
var player = CreatePlayer(itemChance: 1, pieces: PieceType.I);
|
||||
FillRow(player.Board, Bottom(player.Board), 3, 4, 5, 6);
|
||||
player.Start();
|
||||
var items = player.Active!.Spec.Items;
|
||||
Assert.NotNull(items);
|
||||
Assert.All(items, i => Assert.NotNull(i));
|
||||
|
||||
player.HandleInput(InputAction.HardDrop);
|
||||
|
||||
Assert.Equal(items.Order(), player.Inventory.Order());
|
||||
}
|
||||
|
||||
[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.Equal(4, Enumerable.Range(0, 10).Count(x => player.Board[x, bottom].ItemId is not null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hold_swaps_piece_once_per_lock()
|
||||
{
|
||||
var player = CreatePlayer(pieces: [PieceType.T, PieceType.O, PieceType.S]);
|
||||
player.Start();
|
||||
|
||||
player.HandleInput(InputAction.Hold);
|
||||
Assert.Equal(PieceType.T, player.Hold!.Type);
|
||||
Assert.Equal(PieceType.O, player.Active!.Type);
|
||||
|
||||
player.HandleInput(InputAction.Hold);
|
||||
Assert.Equal(PieceType.O, player.Active!.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pending_garbage_rises_after_a_lock_without_clear()
|
||||
{
|
||||
var player = CreatePlayer(pieces: PieceType.O);
|
||||
player.Start();
|
||||
player.QueueGarbage(2);
|
||||
|
||||
player.HandleInput(InputAction.HardDrop);
|
||||
|
||||
var bottom = Bottom(player.Board);
|
||||
Assert.Equal(9, Enumerable.Range(0, 10).Count(x => player.Board[x, bottom].Color == CellColor.Garbage));
|
||||
Assert.Equal(0, player.PendingGarbage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Double_clear_sends_one_line()
|
||||
{
|
||||
var player = CreatePlayer(pieces: PieceType.O);
|
||||
var bottom = Bottom(player.Board);
|
||||
FillRow(player.Board, bottom, 4, 5);
|
||||
FillRow(player.Board, bottom - 1, 4, 5);
|
||||
player.Board[0, bottom - 2] = new Cell(1); // avoid a perfect clear
|
||||
player.Start();
|
||||
|
||||
player.HandleInput(InputAction.HardDrop);
|
||||
|
||||
Assert.Equal([1], player.DrainOutgoingAttacks());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stacking_to_the_top_ends_the_game()
|
||||
{
|
||||
var player = CreatePlayer(pieces: PieceType.O);
|
||||
player.Start();
|
||||
for (var i = 0; i < 30 && player.IsAlive; i++)
|
||||
{
|
||||
player.HandleInput(InputAction.HardDrop);
|
||||
}
|
||||
Assert.False(player.IsAlive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inventory_is_capped()
|
||||
{
|
||||
var player = CreatePlayer();
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
player.AddItem("nuke");
|
||||
}
|
||||
Assert.Equal(new GameSettings().MaxInventory, player.Inventory.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using ActionBlocks.Game.Core;
|
||||
using ActionBlocks.Game.Garbage;
|
||||
using ActionBlocks.Game.Items;
|
||||
|
||||
namespace ActionBlocks.Game.Tests;
|
||||
|
||||
internal sealed class FixedRandomizer(params PieceType[] sequence) : IRandomizer
|
||||
{
|
||||
private int _index;
|
||||
|
||||
public PieceType Next() => sequence[_index++ % sequence.Length];
|
||||
}
|
||||
|
||||
internal static class TestHelpers
|
||||
{
|
||||
public static PlayerGame CreatePlayer(string id = "p1", double itemChance = 0, params PieceType[] pieces) =>
|
||||
new(
|
||||
id,
|
||||
id,
|
||||
new GameSettings { ItemSpawnChance = itemChance },
|
||||
new FixedRandomizer(pieces.Length == 0 ? [PieceType.I] : pieces),
|
||||
new ItemSpawner(ItemRegistry.Default, itemChance, new Random(1)),
|
||||
new SuperRotationSystem(),
|
||||
new StandardGarbageCalculator(),
|
||||
new StandardScoreCalculator(),
|
||||
new Random(1));
|
||||
|
||||
public static int Bottom(Board board) => board.Height - 1;
|
||||
|
||||
/// <summary>Fills a row except the given columns.</summary>
|
||||
public static void FillRow(Board board, int y, params int[] holes)
|
||||
{
|
||||
for (var x = 0; x < board.Width; x++)
|
||||
{
|
||||
board[x, y] = holes.Contains(x) ? Cell.Empty : new Cell(CellColor.Garbage);
|
||||
}
|
||||
}
|
||||
|
||||
public static int FilledCount(Board board) =>
|
||||
Enumerable.Range(0, board.Height).Sum(y => Enumerable.Range(0, board.Width).Count(x => !board[x, y].IsEmpty));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
|
||||
|
||||
# Full URL of the game hub as seen by the browser, e.g. https://blocks.example.com/hub.
|
||||
# Empty = same origin + /hub (reverse proxy routes /hub to the server).
|
||||
ENV SERVER_URL=""
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Writes the runtime configuration consumed by the website (see src/config/runtimeConfig.ts).
|
||||
set -eu
|
||||
|
||||
# Escape backslashes and double quotes for the JS string literal.
|
||||
server_url=$(printf '%s' "${SERVER_URL:-}" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
||||
|
||||
cat > /usr/share/nginx/html/config.js <<CONFIG
|
||||
window.__ACTIONBLOCKS_CONFIG__ = { serverUrl: "${server_url}" };
|
||||
CONFIG
|
||||
@@ -0,0 +1,41 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# Fingerprinted build output can be cached forever.
|
||||
location /static/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# Exchangeable graphics (public/assets) are not fingerprinted; revalidate them
|
||||
# so replaced files show up immediately.
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location = /config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ @prefixed;
|
||||
}
|
||||
|
||||
# The reverse proxy may forward a sub-path without stripping it (e.g. /website/static/x.js).
|
||||
# Drop the leading path segment and try again; fall back to the app's index.html.
|
||||
location @prefixed {
|
||||
rewrite ^/[^/]+(/.*)$ $1 last;
|
||||
rewrite ^ /index.html last;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- Relative asset URLs need a trailing slash when served under a sub-path (/website -> /website/). -->
|
||||
<script>
|
||||
if (!/\/$|\.[a-z0-9]+$/i.test(location.pathname)) location.replace(location.pathname + '/' + location.search + location.hash);
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="./assets/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
|
||||
<title>Action Blocks</title>
|
||||
<!-- Runtime configuration, rewritten by the container on startup. -->
|
||||
<script src="./config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "actionblocks-website",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"typecheck": "tsc",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "10.0.11",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "5.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.3.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4" width="64" height="64" shape-rendering="crispEdges"><rect width="4" height="4" fill="#000"/><rect x="0" y="0" width="2" height="2" fill="#ff4256"/><rect x="2" y="0" width="2" height="2" fill="#ffe23f"/><rect x="0" y="2" width="2" height="2" fill="#3a66ff"/><rect x="2" y="2" width="2" height="2" fill="#4fe35f"/></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="3" y="0" width="1" height="1" fill="#ffffff"/><rect x="4" y="0" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="3" y="1" width="1" height="1" fill="#ffffff"/><rect x="4" y="1" width="1" height="1" fill="#ffffff"/><rect x="5" y="1" width="1" height="1" fill="#ffffff"/><rect x="1" y="2" width="1" height="1" fill="#ffffff"/><rect x="2" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="6" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="4" y="3" width="1" height="1" fill="#ffffff"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="4" y="4" width="1" height="1" fill="#ffffff"/><rect x="0" y="6" width="1" height="1" fill="#ff4256"/><rect x="1" y="6" width="1" height="1" fill="#ff4256"/><rect x="2" y="6" width="1" height="1" fill="#ff4256"/><rect x="3" y="6" width="1" height="1" fill="#ff4256"/><rect x="5" y="6" width="1" height="1" fill="#ff4256"/><rect x="6" y="6" width="1" height="1" fill="#ff4256"/><rect x="7" y="6" width="1" height="1" fill="#ff4256"/><rect x="0" y="7" width="1" height="1" fill="#ff4256"/><rect x="1" y="7" width="1" height="1" fill="#ff4256"/><rect x="2" y="7" width="1" height="1" fill="#ff4256"/><rect x="3" y="7" width="1" height="1" fill="#ff4256"/><rect x="5" y="7" width="1" height="1" fill="#ff4256"/><rect x="6" y="7" width="1" height="1" fill="#ff4256"/><rect x="7" y="7" width="1" height="1" fill="#ff4256"/></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="0" y="2" width="1" height="1" fill="#c452ff"/><rect x="7" y="2" width="1" height="1" fill="#c452ff"/><rect x="1" y="3" width="1" height="1" fill="#c452ff"/><rect x="2" y="3" width="1" height="1" fill="#c452ff"/><rect x="3" y="3" width="1" height="1" fill="#c452ff"/><rect x="4" y="3" width="1" height="1" fill="#c452ff"/><rect x="5" y="3" width="1" height="1" fill="#c452ff"/><rect x="6" y="3" width="1" height="1" fill="#c452ff"/><rect x="0" y="4" width="1" height="1" fill="#c452ff"/><rect x="2" y="4" width="1" height="1" fill="#c452ff"/><rect x="5" y="4" width="1" height="1" fill="#c452ff"/><rect x="7" y="4" width="1" height="1" fill="#c452ff"/><rect x="2" y="5" width="1" height="1" fill="#c452ff"/><rect x="5" y="5" width="1" height="1" fill="#c452ff"/></svg>
|
||||
|
After Width: | Height: | Size: 930 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="1" y="0" width="1" height="1" fill="#ffffff"/><rect x="6" y="0" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="5" y="1" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="4" y="3" width="1" height="1" fill="#ffffff"/><rect x="2" y="4" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#ffffff"/><rect x="1" y="5" width="1" height="1" fill="#ffffff"/><rect x="6" y="5" width="1" height="1" fill="#ffffff"/><rect x="0" y="6" width="1" height="1" fill="#4fe35f"/><rect x="1" y="6" width="1" height="1" fill="#4fe35f"/><rect x="2" y="6" width="1" height="1" fill="#4fe35f"/><rect x="3" y="6" width="1" height="1" fill="#4fe35f"/><rect x="4" y="6" width="1" height="1" fill="#4fe35f"/><rect x="5" y="6" width="1" height="1" fill="#4fe35f"/><rect x="6" y="6" width="1" height="1" fill="#4fe35f"/><rect x="7" y="6" width="1" height="1" fill="#4fe35f"/><rect x="0" y="7" width="1" height="1" fill="#4fe35f"/><rect x="1" y="7" width="1" height="1" fill="#4fe35f"/><rect x="2" y="7" width="1" height="1" fill="#4fe35f"/><rect x="3" y="7" width="1" height="1" fill="#4fe35f"/><rect x="4" y="7" width="1" height="1" fill="#4fe35f"/><rect x="5" y="7" width="1" height="1" fill="#4fe35f"/><rect x="6" y="7" width="1" height="1" fill="#4fe35f"/><rect x="7" y="7" width="1" height="1" fill="#4fe35f"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="0" y="0" width="1" height="1" fill="#ffe23f"/><rect x="3" y="0" width="1" height="1" fill="#ffe23f"/><rect x="7" y="0" width="1" height="1" fill="#ffe23f"/><rect x="1" y="1" width="1" height="1" fill="#ffe23f"/><rect x="3" y="1" width="1" height="1" fill="#ffe23f"/><rect x="6" y="1" width="1" height="1" fill="#ffe23f"/><rect x="2" y="2" width="1" height="1" fill="#ffe23f"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffe23f"/><rect x="0" y="3" width="1" height="1" fill="#ffe23f"/><rect x="1" y="3" width="1" height="1" fill="#ffe23f"/><rect x="2" y="3" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="4" y="3" width="1" height="1" fill="#ffffff"/><rect x="5" y="3" width="1" height="1" fill="#ffffff"/><rect x="6" y="3" width="1" height="1" fill="#ffe23f"/><rect x="7" y="3" width="1" height="1" fill="#ffe23f"/><rect x="2" y="4" width="1" height="1" fill="#ffe23f"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="4" y="4" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#ffe23f"/><rect x="1" y="5" width="1" height="1" fill="#ffe23f"/><rect x="4" y="5" width="1" height="1" fill="#ffe23f"/><rect x="6" y="5" width="1" height="1" fill="#ffe23f"/><rect x="0" y="6" width="1" height="1" fill="#ffe23f"/><rect x="4" y="6" width="1" height="1" fill="#ffe23f"/><rect x="7" y="6" width="1" height="1" fill="#ffe23f"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="2" y="0" width="1" height="1" fill="#ffffff"/><rect x="1" y="1" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="0" y="2" width="1" height="1" fill="#ffffff"/><rect x="1" y="2" width="1" height="1" fill="#ffffff"/><rect x="2" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="6" y="2" width="1" height="1" fill="#ffffff"/><rect x="1" y="3" width="1" height="1" fill="#ffffff"/><rect x="2" y="3" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#3fe3f2"/><rect x="6" y="4" width="1" height="1" fill="#3fe3f2"/><rect x="1" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="2" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="3" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="4" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="5" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="6" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="7" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="5" y="6" width="1" height="1" fill="#3fe3f2"/><rect x="6" y="6" width="1" height="1" fill="#3fe3f2"/><rect x="6" y="7" width="1" height="1" fill="#3fe3f2"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="3" y="1" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="1" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="2" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="2" width="1" height="1" fill="#ff6fd8"/><rect x="1" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="2" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="5" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="6" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="1" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="2" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="5" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="6" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="5" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="5" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="6" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="6" width="1" height="1" fill="#ff6fd8"/><rect x="0" y="0" width="1" height="1" fill="#ffffff"/><rect x="7" y="0" width="1" height="1" fill="#ffffff"/><rect x="0" y="7" width="1" height="1" fill="#ffffff"/><rect x="7" y="7" width="1" height="1" fill="#ffffff"/><rect x="1" y="1" width="1" height="1" fill="#ffffff"/><rect x="6" y="6" width="1" height="1" fill="#ffffff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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,9 @@
|
||||
Optional audio files. Nothing in here is required: every sound effect and the default music are
|
||||
synthesized in the browser (src/audio/synthSounds.ts and src/audio/songs.ts).
|
||||
|
||||
- Sound effects: put e.g. `line_clear.wav` here and register it in `soundFiles` in
|
||||
`src/theme/assets.ts` under its SoundId (`lineClear: asset('assets/sounds/line_clear.wav')`).
|
||||
- Music: put tracks in `music/` and add them to `musicTracks` in `src/theme/assets.ts`
|
||||
(`{ id: 'my_track', title: 'My Track', kind: 'file', src: asset('assets/sounds/music/my_track.mp3') }`).
|
||||
|
||||
Any format the browser can decode works (wav, mp3, ogg). Only use files you have the rights to.
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect x="3" 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="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="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="7" y="2" width="1" height="1" fill="#ffffff"/><rect x="0" y="3" 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="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="6" y="3" width="1" height="1" fill="#ffffff"/><rect x="0" y="4" width="1" height="1" fill="#ffffff"/><rect x="1" y="4" width="1" height="1" fill="#ffffff"/><rect x="2" y="4" width="1" height="1" fill="#ffffff"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#ffffff"/><rect x="7" y="4" width="1" height="1" fill="#ffffff"/><rect x="0" y="5" width="1" height="1" fill="#ffffff"/><rect x="1" y="5" width="1" height="1" fill="#ffffff"/><rect x="2" y="5" width="1" height="1" fill="#ffffff"/><rect x="3" y="5" width="1" height="1" fill="#ffffff"/><rect x="2" y="6" width="1" height="1" fill="#ffffff"/><rect x="3" y="6" width="1" height="1" fill="#ffffff"/><rect x="3" y="7" width="1" height="1" fill="#ffffff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 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 x="3" 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="6" 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="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="0" y="3" 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="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="5" y="3" width="1" height="1" fill="#ffffff"/><rect x="7" y="3" width="1" height="1" fill="#ffffff"/><rect x="0" y="4" width="1" height="1" fill="#ffffff"/><rect x="1" y="4" width="1" height="1" fill="#ffffff"/><rect x="2" y="4" width="1" height="1" fill="#ffffff"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#ffffff"/><rect x="7" y="4" width="1" height="1" fill="#ffffff"/><rect x="0" y="5" width="1" height="1" fill="#ffffff"/><rect x="1" y="5" width="1" height="1" fill="#ffffff"/><rect x="2" y="5" width="1" height="1" fill="#ffffff"/><rect x="3" y="5" width="1" height="1" fill="#ffffff"/><rect x="5" y="5" width="1" height="1" fill="#ffffff"/><rect x="2" y="6" width="1" height="1" fill="#ffffff"/><rect x="3" y="6" width="1" height="1" fill="#ffffff"/><rect x="6" y="6" width="1" height="1" fill="#ffffff"/><rect x="3" y="7" width="1" height="1" fill="#ffffff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 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,151 @@
|
||||
import { soundMix } from '../config/sound';
|
||||
import { musicTracks, soundFiles } from '../theme/assets';
|
||||
import { MusicPlaylist } from './MusicPlaylist';
|
||||
import { defaultSoundSettings } from './settings';
|
||||
import { synthSounds } from './synthSounds';
|
||||
import type { MusicTrack, PlayOptions, SoundId, SoundPlayer, SoundSettings } from './types';
|
||||
|
||||
/** The same sound is not restarted more often than this, e.g. many moves inside one snapshot. */
|
||||
const THROTTLE_MS = 30;
|
||||
|
||||
const log = (id: SoundId) => {
|
||||
if (import.meta.env.DEV) console.debug('[sound]', id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Default SoundPlayer built on the Web Audio API. Effects use a registered file from
|
||||
* `soundFiles` when it loaded, otherwise the synth recipe; music comes from `musicTracks`.
|
||||
*/
|
||||
export class WebAudioPlayer implements SoundPlayer {
|
||||
private ctx: AudioContext | null = null;
|
||||
private sfxGain: GainNode | null = null;
|
||||
private musicGain: GainNode | null = null;
|
||||
/** One gain node per effect, set from soundMix.effects. */
|
||||
private readonly effectGains = new Map<SoundId, GainNode>();
|
||||
private playlist: MusicPlaylist | null = null;
|
||||
private settings: SoundSettings = defaultSoundSettings;
|
||||
private wantMusic = false;
|
||||
private tempo = 1;
|
||||
private readonly buffers = new Map<string, Promise<AudioBuffer | null>>();
|
||||
private readonly loaded = new Map<string, AudioBuffer>();
|
||||
private readonly lastPlayed = new Map<SoundId, number>();
|
||||
private readonly trackListeners = new Set<() => void>();
|
||||
|
||||
unlock() {
|
||||
if (!this.ctx) {
|
||||
try {
|
||||
this.ctx = new AudioContext();
|
||||
} catch {
|
||||
return; // No Web Audio support: the game simply stays silent.
|
||||
}
|
||||
const master = this.ctx.createGain();
|
||||
master.gain.value = soundMix.master;
|
||||
master.connect(this.ctx.destination);
|
||||
this.sfxGain = this.ctx.createGain();
|
||||
this.musicGain = this.ctx.createGain();
|
||||
this.sfxGain.connect(master);
|
||||
this.musicGain.connect(master);
|
||||
this.playlist = new MusicPlaylist(this.ctx, this.musicGain, musicTracks, (src) => this.loadBuffer(src));
|
||||
this.playlist.onChange(() => this.trackListeners.forEach((l) => l()));
|
||||
this.applySettings(this.settings);
|
||||
this.playlist.setTempo(this.tempo);
|
||||
Object.values(soundFiles).forEach((src) => void this.loadBuffer(src));
|
||||
if (this.wantMusic && !this.settings.musicMuted) this.playlist.start();
|
||||
}
|
||||
if (this.ctx.state === 'suspended') void this.ctx.resume();
|
||||
}
|
||||
|
||||
play(id: SoundId, options: PlayOptions = {}) {
|
||||
const ctx = this.ctx;
|
||||
if (!ctx || !this.sfxGain || this.settings.sfxMuted || ctx.state !== 'running') return;
|
||||
if (soundMix.effects[id] <= 0) return;
|
||||
|
||||
const now = performance.now();
|
||||
if (now - (this.lastPlayed.get(id) ?? -Infinity) < THROTTLE_MS) return;
|
||||
this.lastPlayed.set(id, now);
|
||||
log(id);
|
||||
|
||||
const file = soundFiles[id];
|
||||
const buffer = file ? this.loaded.get(file) : undefined;
|
||||
if (buffer) {
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(this.effectGain(id));
|
||||
source.start();
|
||||
} else {
|
||||
synthSounds[id](ctx, this.effectGain(id), options);
|
||||
}
|
||||
}
|
||||
|
||||
private effectGain(id: SoundId): GainNode {
|
||||
let gain = this.effectGains.get(id);
|
||||
if (!gain) {
|
||||
gain = this.ctx!.createGain();
|
||||
gain.gain.value = soundMix.effects[id];
|
||||
gain.connect(this.sfxGain!);
|
||||
this.effectGains.set(id, gain);
|
||||
}
|
||||
return gain;
|
||||
}
|
||||
|
||||
startMusic() {
|
||||
this.wantMusic = true;
|
||||
if (!this.settings.musicMuted) this.playlist?.start();
|
||||
}
|
||||
|
||||
stopMusic() {
|
||||
this.wantMusic = false;
|
||||
this.playlist?.stop();
|
||||
}
|
||||
|
||||
nextTrack() {
|
||||
if (this.playlist?.playing) this.playlist.next();
|
||||
}
|
||||
|
||||
currentTrack(): MusicTrack | null {
|
||||
return this.playlist?.currentTrack() ?? null;
|
||||
}
|
||||
|
||||
onTrackChange(listener: () => void) {
|
||||
this.trackListeners.add(listener);
|
||||
return () => void this.trackListeners.delete(listener);
|
||||
}
|
||||
|
||||
setMusicTempo(factor: number) {
|
||||
this.tempo = factor;
|
||||
this.playlist?.setTempo(factor);
|
||||
}
|
||||
|
||||
applySettings(settings: SoundSettings) {
|
||||
this.settings = settings;
|
||||
if (!this.ctx || !this.sfxGain || !this.musicGain) return;
|
||||
const t = this.ctx.currentTime;
|
||||
// Squared for a more natural loudness curve on the sliders.
|
||||
this.sfxGain.gain.setTargetAtTime(settings.sfxMuted ? 0 : settings.sfxVolume ** 2 * soundMix.sfx, t, 0.02);
|
||||
this.musicGain.gain.setTargetAtTime(settings.musicVolume ** 2 * soundMix.music, t, 0.02);
|
||||
this.playlist?.setDisabled(settings.disabledTracks);
|
||||
// Music that is switched off doesn't run at all, so the header shows no track.
|
||||
if (settings.musicMuted) this.playlist?.stop();
|
||||
else if (this.wantMusic) this.playlist?.start();
|
||||
}
|
||||
|
||||
private loadBuffer(src: string): Promise<AudioBuffer | null> {
|
||||
let promise = this.buffers.get(src);
|
||||
if (!promise) {
|
||||
const ctx = this.ctx!;
|
||||
promise = fetch(src)
|
||||
.then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(new Error(response.statusText))))
|
||||
.then((data) => ctx.decodeAudioData(data))
|
||||
.then((buffer) => {
|
||||
this.loaded.set(src, buffer);
|
||||
return buffer;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(`Could not load sound ${src}, using the synth sound instead.`, error);
|
||||
return null;
|
||||
});
|
||||
this.buffers.set(src, promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { soundMix } from '../config/sound';
|
||||
import { SynthSongPlayer } from './music';
|
||||
import type { MusicTrack } from './types';
|
||||
|
||||
const log = (...args: unknown[]) => {
|
||||
if (import.meta.env.DEV) console.debug('[music]', ...args);
|
||||
};
|
||||
|
||||
/**
|
||||
* Plays the registered music tracks one after another in shuffled order. Every enabled track is
|
||||
* played once before the order is reshuffled. File tracks advance when they end, synth tracks
|
||||
* after their configured number of loops.
|
||||
*/
|
||||
export class MusicPlaylist {
|
||||
private queue: string[] = [];
|
||||
private disabled = new Set<string>();
|
||||
private current: MusicTrack | null = null;
|
||||
private lastPlayed: string | null = null;
|
||||
private stopCurrent: (() => void) | null = null;
|
||||
private synth: SynthSongPlayer | null = null;
|
||||
private tempo = 1;
|
||||
/** Incremented on every start/stop so late async loads and end callbacks of old tracks are ignored. */
|
||||
private generation = 0;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(
|
||||
private readonly ctx: AudioContext,
|
||||
private readonly out: AudioNode,
|
||||
private readonly tracks: MusicTrack[],
|
||||
private readonly loadBuffer: (src: string) => Promise<AudioBuffer | null>,
|
||||
) {}
|
||||
|
||||
get playing() {
|
||||
return this.stopCurrent !== null;
|
||||
}
|
||||
|
||||
currentTrack() {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
onChange(listener: () => void) {
|
||||
this.listeners.add(listener);
|
||||
return () => void this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
setDisabled(ids: string[]) {
|
||||
this.disabled = new Set(ids);
|
||||
this.queue = this.queue.filter((id) => !this.disabled.has(id));
|
||||
if (this.current && this.disabled.has(this.current.id) && this.playing) this.next();
|
||||
}
|
||||
|
||||
setTempo(factor: number) {
|
||||
this.tempo = factor;
|
||||
this.synth?.setTempo(factor);
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.playing) this.next();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.generation++;
|
||||
this.stopCurrent?.();
|
||||
this.stopCurrent = null;
|
||||
this.synth = null;
|
||||
if (this.current) log('stop', this.current.id);
|
||||
this.setCurrent(null);
|
||||
}
|
||||
|
||||
next() {
|
||||
this.stop();
|
||||
const track = this.takeNext();
|
||||
if (!track) return;
|
||||
const generation = this.generation;
|
||||
this.setCurrent(track);
|
||||
log('play', track.id);
|
||||
|
||||
// Each track gets its own level from the central mix.
|
||||
const trackGain = this.ctx.createGain();
|
||||
trackGain.gain.value = soundMix.tracks[track.id] ?? 1;
|
||||
trackGain.connect(this.out);
|
||||
|
||||
// Marks playback active right away so start() during a file load doesn't start a second track.
|
||||
this.stopCurrent = () => trackGain.disconnect();
|
||||
const ended = () => {
|
||||
if (generation === this.generation) this.next();
|
||||
};
|
||||
|
||||
if (track.kind === 'synth') {
|
||||
const player = new SynthSongPlayer(this.ctx, trackGain, track.song, ended);
|
||||
player.setTempo(this.tempo);
|
||||
player.start();
|
||||
this.synth = player;
|
||||
this.stopCurrent = () => {
|
||||
player.stop();
|
||||
// Disconnect after the player's short fade out.
|
||||
window.setTimeout(() => trackGain.disconnect(), 100);
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
void this.loadBuffer(track.src).then((buffer) => {
|
||||
if (generation !== this.generation) return;
|
||||
if (!buffer) {
|
||||
log('skip, could not load', track.src);
|
||||
// Avoid spinning when every enabled track fails to load.
|
||||
window.setTimeout(ended, 500);
|
||||
return;
|
||||
}
|
||||
const source = this.ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(trackGain);
|
||||
source.onended = ended;
|
||||
source.start();
|
||||
this.stopCurrent = () => {
|
||||
source.onended = null;
|
||||
source.stop();
|
||||
source.disconnect();
|
||||
trackGain.disconnect();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private takeNext(): MusicTrack | null {
|
||||
const enabled = this.tracks.filter((t) => !this.disabled.has(t.id));
|
||||
if (enabled.length === 0) return null;
|
||||
if (this.queue.length === 0) {
|
||||
this.queue = shuffle(enabled.map((t) => t.id));
|
||||
// Don't repeat the last track right after reshuffling.
|
||||
if (this.queue.length > 1 && this.queue[0] === this.lastPlayed) this.queue.push(this.queue.shift()!);
|
||||
}
|
||||
const id = this.queue.shift()!;
|
||||
this.lastPlayed = id;
|
||||
return this.tracks.find((t) => t.id === id) ?? null;
|
||||
}
|
||||
|
||||
private setCurrent(track: MusicTrack | null) {
|
||||
if (this.current === track) return;
|
||||
this.current = track;
|
||||
this.listeners.forEach((l) => l());
|
||||
}
|
||||
}
|
||||
|
||||
function shuffle<T>(items: T[]): T[] {
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[items[i], items[j]] = [items[j], items[i]];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||
import { muteKey, musicKey, nextTrackKey } from '../config/keybindings';
|
||||
import { WebAudioPlayer } from './AudioEngine';
|
||||
import { loadSoundSettings, saveSoundSettings } from './settings';
|
||||
import type { SoundPlayer, SoundSettings } from './types';
|
||||
|
||||
interface SoundApi {
|
||||
player: SoundPlayer;
|
||||
settings: SoundSettings;
|
||||
setSettings: (patch: Partial<SoundSettings>) => void;
|
||||
toggleSfx: () => void;
|
||||
toggleMusic: () => void;
|
||||
}
|
||||
|
||||
const SoundContext = createContext<SoundApi | null>(null);
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** Defaults to the Web Audio engine; pass another SoundPlayer to replace it. */
|
||||
createPlayer?: () => SoundPlayer;
|
||||
}
|
||||
|
||||
export function SoundProvider({ children, createPlayer = () => new WebAudioPlayer() }: Props) {
|
||||
const [player] = useState(createPlayer);
|
||||
const [settings, setSettingsState] = useState(loadSoundSettings);
|
||||
|
||||
useEffect(() => {
|
||||
player.applySettings(settings);
|
||||
saveSoundSettings(settings);
|
||||
}, [player, settings]);
|
||||
|
||||
const setSettings = useCallback((patch: Partial<SoundSettings>) => setSettingsState((s) => ({ ...s, ...patch })), []);
|
||||
const toggleSfx = useCallback(() => setSettingsState((s) => ({ ...s, sfxMuted: !s.sfxMuted })), []);
|
||||
const toggleMusic = useCallback(() => setSettingsState((s) => ({ ...s, musicMuted: !s.musicMuted })), []);
|
||||
// Mutes everything if anything is audible, otherwise turns everything back on.
|
||||
const toggleAll = useCallback(
|
||||
() =>
|
||||
setSettingsState((s) => {
|
||||
const mute = !s.sfxMuted || !s.musicMuted;
|
||||
return { ...s, sfxMuted: mute, musicMuted: mute };
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Browsers only allow audio after a user gesture.
|
||||
const unlock = () => player.unlock();
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
unlock();
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (e.repeat || target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') return;
|
||||
if (e.code === muteKey) toggleAll();
|
||||
else if (e.code === musicKey) toggleMusic();
|
||||
else if (e.code === nextTrackKey) player.nextTrack();
|
||||
};
|
||||
window.addEventListener('pointerdown', unlock);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', unlock);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [player, toggleAll, toggleMusic]);
|
||||
|
||||
const api = useMemo(
|
||||
() => ({ player, settings, setSettings, toggleSfx, toggleMusic }),
|
||||
[player, settings, setSettings, toggleSfx, toggleMusic],
|
||||
);
|
||||
return <SoundContext.Provider value={api}>{children}</SoundContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSound(): SoundApi {
|
||||
const api = useContext(SoundContext);
|
||||
if (!api) throw new Error('useSound must be used inside <SoundProvider>');
|
||||
return api;
|
||||
}
|
||||
|
||||
/** The music track that is playing right now, or null. */
|
||||
export function useCurrentTrack() {
|
||||
const { player } = useSound();
|
||||
return useSyncExternalStore(
|
||||
useCallback((onChange) => player.onTrackChange(onChange), [player]),
|
||||
() => player.currentTrack(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { noteToFreq } from './pitch';
|
||||
import { noise } from './synthSounds';
|
||||
import type { SynthChannel, SynthSong } from './types';
|
||||
|
||||
type Step = { freq: number; length: number } | { hit: 'short' | 'long' } | null;
|
||||
|
||||
interface ParsedChannel {
|
||||
wave: SynthChannel['wave'];
|
||||
volume: number;
|
||||
steps: Step[];
|
||||
}
|
||||
|
||||
function parseChannel(channel: SynthChannel): ParsedChannel {
|
||||
const tokens = channel.pattern.trim().split(/\s+/);
|
||||
const steps: Step[] = [];
|
||||
let lastNote: { freq: number; length: number } | null = null;
|
||||
for (const token of tokens) {
|
||||
if (token === '-') {
|
||||
if (lastNote) lastNote.length++;
|
||||
steps.push(null);
|
||||
} else if (token === '.') {
|
||||
lastNote = null;
|
||||
steps.push(null);
|
||||
} else if (token === 'x' || token === 'o') {
|
||||
lastNote = null;
|
||||
steps.push({ hit: token === 'x' ? 'short' : 'long' });
|
||||
} else {
|
||||
lastNote = { freq: noteToFreq(token), length: 1 };
|
||||
steps.push(lastNote);
|
||||
}
|
||||
}
|
||||
return { wave: channel.wave, volume: channel.volume, steps };
|
||||
}
|
||||
|
||||
const LOOKAHEAD_S = 0.12;
|
||||
const TIMER_MS = 25;
|
||||
|
||||
/**
|
||||
* Plays a SynthSong with a lookahead scheduler: a timer regularly schedules the notes of the next
|
||||
* ~120 ms on the audio clock, so playback stays exact even when the timer itself is late.
|
||||
*/
|
||||
export class SynthSongPlayer {
|
||||
private readonly channels: ParsedChannel[];
|
||||
private readonly loopSteps: number;
|
||||
private readonly output: GainNode;
|
||||
private timer: number | undefined;
|
||||
private step = 0;
|
||||
private loopsDone = 0;
|
||||
private nextTime = 0;
|
||||
private tempo = 1;
|
||||
|
||||
constructor(
|
||||
private readonly ctx: AudioContext,
|
||||
out: AudioNode,
|
||||
private readonly song: SynthSong,
|
||||
private readonly onEnded: () => void,
|
||||
) {
|
||||
this.channels = song.channels.map(parseChannel);
|
||||
this.loopSteps = Math.max(...this.channels.map((c) => c.steps.length));
|
||||
this.output = ctx.createGain();
|
||||
this.output.connect(out);
|
||||
}
|
||||
|
||||
setTempo(factor: number) {
|
||||
this.tempo = factor;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.nextTime = this.ctx.currentTime + 0.05;
|
||||
this.timer = window.setInterval(() => this.schedule(), TIMER_MS);
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
stop() {
|
||||
window.clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
// Fade out quickly; notes already scheduled ahead are cut off with the output node.
|
||||
const t = this.ctx.currentTime;
|
||||
this.output.gain.setValueAtTime(this.output.gain.value, t);
|
||||
this.output.gain.linearRampToValueAtTime(0, t + 0.05);
|
||||
window.setTimeout(() => this.output.disconnect(), 100);
|
||||
}
|
||||
|
||||
private stepDuration() {
|
||||
return 60 / (this.song.bpm * this.tempo) / this.song.stepsPerBeat;
|
||||
}
|
||||
|
||||
private schedule() {
|
||||
while (this.timer !== undefined && this.nextTime < this.ctx.currentTime + LOOKAHEAD_S) {
|
||||
const duration = this.stepDuration();
|
||||
for (const channel of this.channels) this.playStep(channel, channel.steps[this.step % channel.steps.length], duration);
|
||||
this.nextTime += duration;
|
||||
if (++this.step >= this.loopSteps) {
|
||||
this.step = 0;
|
||||
if (++this.loopsDone >= this.song.loops) {
|
||||
this.onEnded();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private playStep(channel: ParsedChannel, step: Step, stepDuration: number) {
|
||||
if (!step) return;
|
||||
const start = this.nextTime - this.ctx.currentTime;
|
||||
if ('hit' in step) {
|
||||
const long = step.hit === 'long';
|
||||
noise(this.ctx, this.output, {
|
||||
start,
|
||||
duration: long ? 0.12 : 0.04,
|
||||
volume: channel.volume,
|
||||
filter: long ? 'bandpass' : 'highpass',
|
||||
filterFrom: long ? 1800 : 7000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (channel.wave === 'noise') return;
|
||||
const t = this.nextTime;
|
||||
const end = t + step.length * stepDuration * 0.92;
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.type = channel.wave;
|
||||
osc.frequency.setValueAtTime(step.freq, t);
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(channel.volume, t + 0.005);
|
||||
gain.gain.setValueAtTime(channel.volume * 0.8, Math.max(t + 0.006, end - 0.03));
|
||||
gain.gain.linearRampToValueAtTime(0, end);
|
||||
osc.connect(gain).connect(this.output);
|
||||
osc.start(t);
|
||||
osc.stop(end + 0.01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
const SEMITONES: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
|
||||
|
||||
/** Converts a note name like "A4", "C#5" or "Bb3" to a frequency in Hz (A4 = 440). */
|
||||
export function noteToFreq(name: string): number {
|
||||
const match = /^([A-G])([#b]?)(-?\d)$/.exec(name);
|
||||
if (!match) throw new Error(`Invalid note name: ${name}`);
|
||||
const [, letter, accidental, octave] = match;
|
||||
const semitone = SEMITONES[letter] + (accidental === '#' ? 1 : accidental === 'b' ? -1 : 0);
|
||||
const midi = (Number(octave) + 1) * 12 + semitone;
|
||||
return 440 * 2 ** ((midi - 69) / 12);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defaultMusicVolume, defaultSfxVolume } from '../config/sound';
|
||||
import type { SoundSettings } from './types';
|
||||
|
||||
const SETTINGS_KEY = 'actionblocks.sound';
|
||||
|
||||
export const defaultSoundSettings: SoundSettings = {
|
||||
sfxMuted: false,
|
||||
musicMuted: false,
|
||||
sfxVolume: defaultSfxVolume,
|
||||
musicVolume: defaultMusicVolume,
|
||||
disabledTracks: [],
|
||||
};
|
||||
|
||||
const volume = (value: unknown, fallback: number) =>
|
||||
typeof value === 'number' && value >= 0 && value <= 1 ? value : fallback;
|
||||
|
||||
export function loadSoundSettings(): SoundSettings {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? '{}') as Partial<SoundSettings> & { muted?: boolean };
|
||||
// Older versions had a single "muted" flag for everything.
|
||||
const legacyMuted = stored.muted === true;
|
||||
return {
|
||||
sfxMuted: stored.sfxMuted === true || legacyMuted,
|
||||
musicMuted: stored.musicMuted === true || legacyMuted,
|
||||
sfxVolume: volume(stored.sfxVolume, defaultSoundSettings.sfxVolume),
|
||||
musicVolume: volume(stored.musicVolume, defaultSoundSettings.musicVolume),
|
||||
disabledTracks: Array.isArray(stored.disabledTracks) ? stored.disabledTracks.filter((id) => typeof id === 'string') : [],
|
||||
};
|
||||
} catch {
|
||||
return defaultSoundSettings;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSoundSettings(settings: SoundSettings) {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
// Storage may be unavailable (private mode); settings are just not remembered.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { SynthSong } from './types';
|
||||
|
||||
/*
|
||||
* Original background tunes for the synth sequencer (see SynthChannel for the pattern format).
|
||||
* Each line of a pattern is one bar of eighth notes. Channel volumes balance the voices of a
|
||||
* song against each other; the overall level of each track is set in config/sound.ts.
|
||||
*/
|
||||
|
||||
const pixelRush: SynthSong = {
|
||||
bpm: 150,
|
||||
stepsPerBeat: 2,
|
||||
loops: 8,
|
||||
channels: [
|
||||
{
|
||||
wave: 'triangle',
|
||||
volume: 0.3,
|
||||
pattern: `
|
||||
A2 A3 A2 A3 A2 A3 A2 A3
|
||||
F2 F3 F2 F3 F2 F3 F2 F3
|
||||
C3 C4 C3 C4 C3 C4 C3 C4
|
||||
G2 G3 G2 G3 G2 G3 G2 G3`,
|
||||
},
|
||||
{
|
||||
wave: 'square',
|
||||
volume: 0.07,
|
||||
pattern: `
|
||||
A4 . C5 . E5 . D5 C5
|
||||
C5 - A4 . F4 . A4 .
|
||||
G4 . C5 . E5 . G5 E5
|
||||
D5 - - . B4 . G4 .
|
||||
A4 . C5 . E5 . A5 .
|
||||
G5 F5 E5 . C5 . A4 .
|
||||
E5 . D5 . C5 . B4 .
|
||||
B4 - D5 - G5 - - .`,
|
||||
},
|
||||
{ wave: 'noise', volume: 0.05, pattern: 'x . o . x x o .' },
|
||||
],
|
||||
};
|
||||
|
||||
const neonStack: SynthSong = {
|
||||
bpm: 132,
|
||||
stepsPerBeat: 2,
|
||||
loops: 6,
|
||||
channels: [
|
||||
{
|
||||
wave: 'triangle',
|
||||
volume: 0.3,
|
||||
pattern: `
|
||||
E2 . E2 E3 . E2 E3 .
|
||||
C2 . C2 C3 . C2 C3 .
|
||||
G2 . G2 G3 . G2 G3 .
|
||||
D2 . D2 D3 . D2 D3 .`,
|
||||
},
|
||||
{
|
||||
wave: 'square',
|
||||
volume: 0.06,
|
||||
pattern: `
|
||||
B4 - - . G4 - E4 .
|
||||
C5 - - . E5 - D5 .
|
||||
B4 - - . D5 - G5 .
|
||||
F#5 - - - D5 - - .
|
||||
E5 - - . B4 - G4 .
|
||||
A4 - G4 - E4 - C5 .
|
||||
B4 - D5 - G5 - F#5 .
|
||||
E5 - - - - - - .`,
|
||||
},
|
||||
{
|
||||
wave: 'triangle',
|
||||
volume: 0.08,
|
||||
pattern: `
|
||||
. . G4 . . . B4 .
|
||||
. . G4 . . . E4 .
|
||||
. . B4 . . . D5 .
|
||||
. . A4 . . . F#4 .`,
|
||||
},
|
||||
{ wave: 'noise', volume: 0.05, pattern: 'x . o . x x o .' },
|
||||
],
|
||||
};
|
||||
|
||||
const lastLine: SynthSong = {
|
||||
bpm: 168,
|
||||
stepsPerBeat: 2,
|
||||
loops: 8,
|
||||
channels: [
|
||||
{
|
||||
wave: 'triangle',
|
||||
volume: 0.3,
|
||||
pattern: `
|
||||
D2 D2 D3 D2 D2 D3 D2 D3
|
||||
A#1 A#1 A#2 A#1 A#1 A#2 A#1 A#2
|
||||
C2 C2 C3 C2 C2 C3 C2 C3
|
||||
A1 A1 A2 A1 A1 A2 A1 A2`,
|
||||
},
|
||||
{
|
||||
wave: 'square',
|
||||
volume: 0.06,
|
||||
pattern: `
|
||||
D5 F5 A5 F5 D5 F5 A5 F5
|
||||
D5 F5 A#5 F5 D5 F5 A#5 F5
|
||||
E5 G5 C6 G5 E5 G5 C6 G5
|
||||
C#5 E5 A5 E5 C#5 E5 A5 E5
|
||||
A5 - G5 - F5 - E5 F5
|
||||
D5 - - . A#4 - D5 .
|
||||
E5 - F5 - G5 - E5 C5
|
||||
C#5 - E5 - A5 - - .`,
|
||||
},
|
||||
{ wave: 'noise', volume: 0.05, pattern: 'x x o x x x o x' },
|
||||
],
|
||||
};
|
||||
|
||||
export const songs = { pixelRush, neonStack, lastLine };
|
||||
@@ -0,0 +1,131 @@
|
||||
import { noteToFreq } from './pitch';
|
||||
import type { PlayOptions, SoundId } from './types';
|
||||
|
||||
/*
|
||||
* The volumes in these recipes shape each sound and balance its parts against each other.
|
||||
* To make a whole sound louder or quieter, change its value in config/sound.ts instead.
|
||||
*/
|
||||
|
||||
/** Builds one sound effect from oscillators and noise, connected to `out`. */
|
||||
export type SynthRecipe = (ctx: AudioContext, out: AudioNode, options: PlayOptions) => void;
|
||||
|
||||
interface ToneOptions {
|
||||
type?: OscillatorType;
|
||||
from: number;
|
||||
to?: number;
|
||||
/** Delay in seconds from now. */
|
||||
start?: number;
|
||||
duration: number;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
/** A single oscillator with an exponential fade out and an optional pitch slide. */
|
||||
export function tone(ctx: AudioContext, out: AudioNode, { type = 'square', from, to = from, start = 0, duration, volume = 0.2 }: ToneOptions) {
|
||||
const t = ctx.currentTime + start;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(from, t);
|
||||
if (to !== from) osc.frequency.exponentialRampToValueAtTime(to, t + duration);
|
||||
gain.gain.setValueAtTime(volume, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
|
||||
osc.connect(gain).connect(out);
|
||||
osc.start(t);
|
||||
osc.stop(t + duration + 0.02);
|
||||
}
|
||||
|
||||
interface NoiseOptions {
|
||||
start?: number;
|
||||
duration: number;
|
||||
volume?: number;
|
||||
filter?: BiquadFilterType;
|
||||
filterFrom?: number;
|
||||
filterTo?: number;
|
||||
}
|
||||
|
||||
const noiseBuffers = new WeakMap<BaseAudioContext, AudioBuffer>();
|
||||
|
||||
function noiseBuffer(ctx: BaseAudioContext): AudioBuffer {
|
||||
let buffer = noiseBuffers.get(ctx);
|
||||
if (!buffer) {
|
||||
buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
|
||||
noiseBuffers.set(ctx, buffer);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Filtered white noise burst, for drops, hits and drums. */
|
||||
export function noise(
|
||||
ctx: AudioContext,
|
||||
out: AudioNode,
|
||||
{ start = 0, duration, volume = 0.2, filter = 'lowpass', filterFrom = 4000, filterTo = filterFrom }: NoiseOptions,
|
||||
) {
|
||||
const t = ctx.currentTime + start;
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = noiseBuffer(ctx);
|
||||
const biquad = ctx.createBiquadFilter();
|
||||
biquad.type = filter;
|
||||
biquad.frequency.setValueAtTime(filterFrom, t);
|
||||
if (filterTo !== filterFrom) biquad.frequency.exponentialRampToValueAtTime(filterTo, t + duration);
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(volume, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
|
||||
source.connect(biquad).connect(gain).connect(out);
|
||||
source.start(t);
|
||||
source.stop(t + duration + 0.02);
|
||||
}
|
||||
|
||||
/** Plays notes one after another, `step` seconds apart. */
|
||||
function arpeggio(ctx: AudioContext, out: AudioNode, notes: string[], step: number, type: OscillatorType, volume: number, noteLength = step * 1.5) {
|
||||
notes.forEach((note, i) =>
|
||||
tone(ctx, out, { type, from: noteToFreq(note), start: i * step, duration: noteLength, volume }),
|
||||
);
|
||||
}
|
||||
|
||||
export const synthSounds: Record<SoundId, SynthRecipe> = {
|
||||
move: (ctx, out) => tone(ctx, out, { from: 220, duration: 0.03, volume: 0.06 }),
|
||||
rotate: (ctx, out) => tone(ctx, out, { from: 440, to: 660, duration: 0.045, volume: 0.07 }),
|
||||
hold: (ctx, out) => {
|
||||
tone(ctx, out, { type: 'triangle', from: 330, to: 495, duration: 0.07, volume: 0.2 });
|
||||
tone(ctx, out, { type: 'triangle', from: 495, start: 0.05, duration: 0.08, volume: 0.18 });
|
||||
},
|
||||
hardDrop: (ctx, out) => {
|
||||
noise(ctx, out, { duration: 0.12, volume: 0.25, filterFrom: 3000, filterTo: 200 });
|
||||
tone(ctx, out, { from: 160, to: 60, duration: 0.1, volume: 0.12 });
|
||||
},
|
||||
lock: (ctx, out) => tone(ctx, out, { type: 'triangle', from: 130, to: 80, duration: 0.07, volume: 0.25 }),
|
||||
lineClear: (ctx, out, { intensity = 1 }) => {
|
||||
// Each extra line raises the sweep by a major third.
|
||||
const base = 523 * 2 ** (((Math.min(intensity, 3) - 1) * 4) / 12);
|
||||
tone(ctx, out, { from: base, to: base * 2, duration: 0.16, volume: 0.1 });
|
||||
tone(ctx, out, { type: 'triangle', from: base * 1.5, to: base * 3, start: 0.04, duration: 0.16, volume: 0.15 });
|
||||
noise(ctx, out, { duration: 0.18, volume: 0.06, filter: 'highpass', filterFrom: 2000, filterTo: 8000 });
|
||||
},
|
||||
quad: (ctx, out) => {
|
||||
arpeggio(ctx, out, ['C5', 'E5', 'G5', 'C6', 'E6'], 0.065, 'square', 0.09);
|
||||
tone(ctx, out, { type: 'triangle', from: noteToFreq('C6'), start: 0.33, duration: 0.4, volume: 0.2 });
|
||||
},
|
||||
garbageIncoming: (ctx, out) => tone(ctx, out, { type: 'sawtooth', from: 110, to: 70, duration: 0.2, volume: 0.09 }),
|
||||
itemPickup: (ctx, out) => {
|
||||
tone(ctx, out, { from: noteToFreq('E6'), duration: 0.06, volume: 0.08 });
|
||||
tone(ctx, out, { from: noteToFreq('B6'), start: 0.06, duration: 0.12, volume: 0.08 });
|
||||
},
|
||||
itemUse: (ctx, out) => {
|
||||
tone(ctx, out, { from: 660, to: 1760, duration: 0.14, volume: 0.08 });
|
||||
noise(ctx, out, { duration: 0.12, volume: 0.06, filter: 'highpass', filterFrom: 1000, filterTo: 6000 });
|
||||
},
|
||||
itemHit: (ctx, out) => {
|
||||
tone(ctx, out, { type: 'sawtooth', from: 600, to: 120, duration: 0.28, volume: 0.1 });
|
||||
noise(ctx, out, { duration: 0.22, volume: 0.12, filterFrom: 5000, filterTo: 300 });
|
||||
},
|
||||
countdown: (ctx, out) => tone(ctx, out, { from: noteToFreq('A4'), duration: 0.12, volume: 0.1 }),
|
||||
go: (ctx, out) => tone(ctx, out, { from: noteToFreq('A5'), duration: 0.35, volume: 0.1 }),
|
||||
knockout: (ctx, out) => {
|
||||
tone(ctx, out, { from: 400, to: 100, duration: 0.3, volume: 0.08 });
|
||||
noise(ctx, out, { start: 0.05, duration: 0.25, volume: 0.1, filterFrom: 1500, filterTo: 100 });
|
||||
},
|
||||
lose: (ctx, out) => arpeggio(ctx, out, ['G4', 'E4', 'C4', 'G3'], 0.18, 'triangle', 0.25, 0.22),
|
||||
win: (ctx, out) => arpeggio(ctx, out, ['C5', 'E5', 'G5', 'C6', 'G5', 'C6'], 0.1, 'square', 0.08, 0.16),
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Every sound effect the game can play. Add an id here, a recipe in synthSounds.ts and a trigger. */
|
||||
export type SoundId =
|
||||
| 'move'
|
||||
| 'rotate'
|
||||
| 'hold'
|
||||
| 'hardDrop'
|
||||
| 'lock'
|
||||
| 'lineClear'
|
||||
| 'quad'
|
||||
| 'garbageIncoming'
|
||||
| 'itemPickup'
|
||||
| 'itemUse'
|
||||
| 'itemHit'
|
||||
| 'countdown'
|
||||
| 'go'
|
||||
| 'knockout'
|
||||
| 'lose'
|
||||
| 'win';
|
||||
|
||||
export interface PlayOptions {
|
||||
/** Effect strength, e.g. number of lines cleared. Synth recipes raise their pitch with it. */
|
||||
intensity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One voice of a synth song. `pattern` is a space separated list of steps:
|
||||
* a note name ("C4", "F#3", "A#5") starts a note, "-" holds the previous note, "." is a rest.
|
||||
* Noise channels use "x" (short hit) and "o" (long hit) instead of notes.
|
||||
* Channels shorter than the longest one repeat.
|
||||
*/
|
||||
export interface SynthChannel {
|
||||
wave: OscillatorType | 'noise';
|
||||
volume: number;
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
export interface SynthSong {
|
||||
bpm: number;
|
||||
/** Steps per beat, 2 = eighth notes, 4 = sixteenth notes. */
|
||||
stepsPerBeat: number;
|
||||
/** How often the pattern repeats before the playlist moves to the next track. */
|
||||
loops: number;
|
||||
channels: SynthChannel[];
|
||||
}
|
||||
|
||||
export type MusicTrack =
|
||||
| { id: string; title: string; kind: 'synth'; song: SynthSong }
|
||||
| { id: string; title: string; kind: 'file'; src: string };
|
||||
|
||||
export interface SoundSettings {
|
||||
/** Silences the sound effects. */
|
||||
sfxMuted: boolean;
|
||||
/** Turns off the background music. */
|
||||
musicMuted: boolean;
|
||||
/** 0..1 */
|
||||
sfxVolume: number;
|
||||
/** 0..1 */
|
||||
musicVolume: number;
|
||||
/** Ids of music tracks the player switched off. */
|
||||
disabledTracks: string[];
|
||||
}
|
||||
|
||||
/** Everything the UI needs from the sound system. Implement it to replace the Web Audio engine. */
|
||||
export interface SoundPlayer {
|
||||
/** Must be called from a user gesture once; browsers block audio before that. */
|
||||
unlock(): void;
|
||||
play(id: SoundId, options?: PlayOptions): void;
|
||||
startMusic(): void;
|
||||
stopMusic(): void;
|
||||
nextTrack(): void;
|
||||
currentTrack(): MusicTrack | null;
|
||||
/** Subscribe to track changes; returns the unsubscribe function. */
|
||||
onTrackChange(listener: () => void): () => void;
|
||||
/** 1 = normal speed. Only affects synth songs. */
|
||||
setMusicTempo(factor: number): void;
|
||||
applySettings(settings: SoundSettings): void;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ActivePieceState, MatchState, PlayerState } from '../net/types';
|
||||
import { useSound } from './SoundContext';
|
||||
|
||||
interface Previous {
|
||||
me?: PlayerState;
|
||||
phase?: MatchState['phase'];
|
||||
countdown?: number;
|
||||
}
|
||||
|
||||
/** Shape of a piece independent of its position, to tell rotations from moves. */
|
||||
function shapeKey(piece: ActivePieceState) {
|
||||
const minX = Math.min(...piece.cells.map(([x]) => x));
|
||||
const minY = Math.min(...piece.cells.map(([, y]) => y));
|
||||
return piece.cells
|
||||
.map(([x, y]) => `${x - minX},${y - minY}`)
|
||||
.sort()
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const queueKey = (p: PlayerState) => p.next.map((n) => n.type).join('');
|
||||
|
||||
/**
|
||||
* Plays sounds for everything that changes between two match snapshots. Snapshots are
|
||||
* server-confirmed, so blocked moves or rotations stay silent.
|
||||
*/
|
||||
export function useGameSounds(match: MatchState, playerId: string) {
|
||||
const { player } = useSound();
|
||||
const previous = useRef<Previous>({});
|
||||
|
||||
useEffect(() => {
|
||||
const before = previous.current;
|
||||
const me = match.players.find((p) => p.id === playerId);
|
||||
const countdown = Math.ceil(match.countdown);
|
||||
|
||||
if (match.phase === 'countdown' && countdown > 0 && countdown !== before.countdown) player.play('countdown');
|
||||
if (match.phase === 'running' && before.phase !== 'running') {
|
||||
if (before.phase === 'countdown') player.play('go');
|
||||
player.startMusic();
|
||||
}
|
||||
if (match.phase === 'finished' && before.phase !== 'finished') {
|
||||
player.stopMusic();
|
||||
if (match.winnerId === playerId) player.play('win');
|
||||
}
|
||||
|
||||
for (const event of match.events) {
|
||||
if (event.type === 'itemUsed' && event.targetId === playerId) player.play('itemHit');
|
||||
else if (event.type === 'eliminated' && event.playerId === playerId) player.play('lose');
|
||||
// The last knockout ends the match; the win sound plays instead.
|
||||
else if (event.type === 'eliminated' && match.phase !== 'finished') player.play('knockout');
|
||||
}
|
||||
|
||||
const old = before.me;
|
||||
if (me && old && me.alive) {
|
||||
const cleared = me.lines - old.lines;
|
||||
if (cleared >= 4) player.play('quad');
|
||||
else if (cleared > 0) player.play('lineClear', { intensity: cleared });
|
||||
|
||||
const held = !old.holdUsed && me.holdUsed;
|
||||
const spawned = queueKey(me) !== queueKey(old);
|
||||
if (held) player.play('hold');
|
||||
else if (spawned && cleared === 0) player.play('lock');
|
||||
|
||||
if (!held && !spawned && me.active && old.active) {
|
||||
if (shapeKey(me.active) !== shapeKey(old.active)) player.play('rotate');
|
||||
else if (me.active.cells[0][0] !== old.active.cells[0][0]) player.play('move');
|
||||
}
|
||||
|
||||
if (me.inventory.length > old.inventory.length) player.play('itemPickup');
|
||||
else if (me.inventory.length < old.inventory.length) player.play('itemUse');
|
||||
if (me.pendingGarbage > old.pendingGarbage) player.play('garbageIncoming');
|
||||
|
||||
player.setMusicTempo(Math.min(1.3, 1 + (me.level - 1) * 0.03));
|
||||
}
|
||||
|
||||
previous.current = { me, phase: match.phase, countdown };
|
||||
}, [match, playerId, player]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
player.stopMusic();
|
||||
player.setMusicTempo(1);
|
||||
},
|
||||
[player],
|
||||
);
|
||||
}
|
||||
@@ -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,10 @@
|
||||
.corner {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState, type MouseEvent } from 'react';
|
||||
import { SoundMenu } from './SoundMenu';
|
||||
import { Button } from './ui/Button';
|
||||
import styles from './CornerControls.module.css';
|
||||
|
||||
const CONFIRM_MS = 3000;
|
||||
|
||||
interface Props {
|
||||
/** Shows a Leave button below the sound button. */
|
||||
onLeave?: () => void;
|
||||
/** Ask for a second click before leaving, e.g. because leaving forfeits a running match. */
|
||||
leaveConfirm?: boolean;
|
||||
}
|
||||
|
||||
/** Sound menu and optional Leave button, fixed in the upper right corner on every screen. */
|
||||
export function CornerControls({ onLeave, leaveConfirm = false }: Props) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirming) return;
|
||||
const timer = window.setTimeout(() => setConfirming(false), CONFIRM_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [confirming]);
|
||||
|
||||
const onLeaveClick = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
// Keep keyboard focus off the button so Space (hard drop) doesn't press it.
|
||||
e.currentTarget.blur();
|
||||
if (leaveConfirm && !confirming) setConfirming(true);
|
||||
else onLeave?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.corner}>
|
||||
<SoundMenu />
|
||||
{onLeave && (
|
||||
<Button
|
||||
size="small"
|
||||
variant={confirming ? 'danger' : 'secondary'}
|
||||
onClick={onLeaveClick}
|
||||
title={leaveConfirm ? 'Leaving gives up the running match' : 'Leave the lobby'}
|
||||
>
|
||||
{confirming ? 'Sure?' : 'Leave'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.timers {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.timer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border: 3px solid;
|
||||
background: #000;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.small {
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.small .timer {
|
||||
gap: 3px;
|
||||
padding: 2px 3px;
|
||||
border-width: 2px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.small .icon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Blink during the last second so the end of the effect isn't a surprise. */
|
||||
.ending {
|
||||
animation: blink 0.25s steps(2) infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { itemVisual } from '../theme/assets';
|
||||
import styles from './EffectTimers.module.css';
|
||||
|
||||
/** A timed item effect on a player: the item id and the seconds it has left. */
|
||||
export type Effect = [itemId: string, remaining: number];
|
||||
|
||||
/** Seconds left on a player's timed item effects (Blackout, Transformation), in the corner of their board. */
|
||||
export function EffectTimers({ effects, small = false }: { effects: Effect[]; small?: boolean }) {
|
||||
const active = effects.filter(([, remaining]) => remaining > 0);
|
||||
if (active.length === 0) return null;
|
||||
return (
|
||||
<div className={[styles.timers, small ? styles.small : ''].join(' ')}>
|
||||
{active.map(([itemId, remaining]) => {
|
||||
const visual = itemVisual(itemId);
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
className={[styles.timer, remaining < 1 ? styles.ending : ''].join(' ')}
|
||||
style={{ color: visual.color, borderColor: visual.color }}
|
||||
title={visual.name}
|
||||
>
|
||||
<img className={styles.icon} src={visual.icon} alt="" />
|
||||
{remaining.toFixed(1)}s
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,48 @@
|
||||
import { memo } from 'react';
|
||||
import type { PlayerState } from '../net/types';
|
||||
import type { ItemFlash } from '../net/useGameConnection';
|
||||
import { EffectTimers } from './EffectTimers';
|
||||
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 />
|
||||
{state.alive && (
|
||||
<EffectTimers
|
||||
effects={[
|
||||
['blind', state.invisibleRemaining],
|
||||
['transform', state.transformedRemaining],
|
||||
]}
|
||||
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,21 @@
|
||||
.tracks {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tracks label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tracks input {
|
||||
accent-color: var(--color-accent);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useSound } from '../audio/SoundContext';
|
||||
import { musicTracks } from '../theme/assets';
|
||||
import styles from './MusicTracks.module.css';
|
||||
|
||||
/** One checkbox per registered music track; unchecked tracks are skipped by the playlist. */
|
||||
export function MusicTracks() {
|
||||
const { settings, setSettings } = useSound();
|
||||
|
||||
const toggleTrack = (id: string, enabled: boolean) =>
|
||||
setSettings({
|
||||
disabledTracks: enabled ? settings.disabledTracks.filter((t) => t !== id) : [...settings.disabledTracks, id],
|
||||
});
|
||||
|
||||
return (
|
||||
<ul className={styles.tracks}>
|
||||
{musicTracks.map((track) => (
|
||||
<li key={track.id}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!settings.disabledTracks.includes(track.id)}
|
||||
onChange={(e) => toggleTrack(track.id, e.target.checked)}
|
||||
/>
|
||||
{track.title}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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,70 @@
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
background: var(--color-bg);
|
||||
border: 2px solid var(--color-fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconButton img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.iconButton:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.active {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 2px 2px 0 var(--color-dim);
|
||||
}
|
||||
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.channel input {
|
||||
width: 80px;
|
||||
margin: 0;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.muted .label,
|
||||
.muted input {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef, useState, type SyntheticEvent } from 'react';
|
||||
import { useSound } from '../audio/SoundContext';
|
||||
import type { SoundSettings } from '../audio/types';
|
||||
import { uiAssets } from '../theme/assets';
|
||||
import styles from './SoundMenu.module.css';
|
||||
|
||||
/** Give keyboard focus back to the game, so Space or the arrows don't operate the control. */
|
||||
const blur = (e: SyntheticEvent<HTMLElement>) => e.currentTarget.blur();
|
||||
|
||||
interface ChannelProps {
|
||||
label: string;
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
onToggle: () => void;
|
||||
onVolume: (volume: number) => void;
|
||||
}
|
||||
|
||||
function Channel({ label, muted, volume, onToggle, onVolume }: ChannelProps) {
|
||||
return (
|
||||
<div className={[styles.channel, muted ? styles.muted : ''].join(' ')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconButton}
|
||||
onClick={(e) => {
|
||||
onToggle();
|
||||
blur(e);
|
||||
}}
|
||||
title={muted ? `Unmute ${label.toLowerCase()}` : `Mute ${label.toLowerCase()}`}
|
||||
aria-pressed={muted}
|
||||
>
|
||||
<img src={muted ? uiAssets.soundOff : uiAssets.soundOn} alt={muted ? 'Muted' : 'On'} />
|
||||
</button>
|
||||
<span className={styles.label}>{label}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
aria-label={`${label} volume`}
|
||||
onChange={(e) => onVolume(Number(e.target.value))}
|
||||
onPointerUp={blur}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sound button for the upper right corner. It opens an inline strip to its left with a mute button
|
||||
* and a volume slider for effects and music. It only changes settings; the game keeps running.
|
||||
*/
|
||||
export function SoundMenu() {
|
||||
const { settings, setSettings, toggleSfx, toggleMusic } = useSound();
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Publish the menu's current width (it grows when open) as a CSS variable, so headers can keep
|
||||
// their content clear of it, e.g. GamePage's lobby code and track label.
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const style = document.documentElement.style;
|
||||
const observer = new ResizeObserver(() => style.setProperty('--sound-menu-width', `${root.offsetWidth}px`));
|
||||
observer.observe(root);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
style.removeProperty('--sound-menu-width');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Moving a muted slider turns that channel back on.
|
||||
const setVolume = (volume: 'sfxVolume' | 'musicVolume', mutedKey: 'sfxMuted' | 'musicMuted', value: number) => {
|
||||
const patch: Partial<SoundSettings> = { [volume]: value };
|
||||
if (settings[mutedKey]) patch[mutedKey] = false;
|
||||
setSettings(patch);
|
||||
};
|
||||
|
||||
const allMuted = settings.sfxMuted && settings.musicMuted;
|
||||
|
||||
return (
|
||||
<div className={styles.menu} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.iconButton, styles.toggle, open ? styles.active : ''].join(' ')}
|
||||
onClick={(e) => {
|
||||
setOpen((o) => !o);
|
||||
blur(e);
|
||||
}}
|
||||
title="Sound"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<img src={allMuted ? uiAssets.soundOff : uiAssets.soundOn} alt="Sound" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className={styles.strip}>
|
||||
<Channel
|
||||
label="SFX"
|
||||
muted={settings.sfxMuted}
|
||||
volume={settings.sfxVolume}
|
||||
onToggle={toggleSfx}
|
||||
onVolume={(v) => setVolume('sfxVolume', 'sfxMuted', v)}
|
||||
/>
|
||||
<Channel
|
||||
label="MUSIC"
|
||||
muted={settings.musicMuted}
|
||||
volume={settings.musicVolume}
|
||||
onToggle={toggleMusic}
|
||||
onVolume={(v) => setVolume('musicVolume', 'musicMuted', v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 6px 10px;
|
||||
font-size: 8px;
|
||||
box-shadow: 2px 2px 0 var(--color-dim);
|
||||
}
|
||||
|
||||
.small:active:not(:disabled) {
|
||||
transform: translate(1px, 1px);
|
||||
box-shadow: 1px 1px 0 var(--color-dim);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
import styles from './Button.module.css';
|
||||
|
||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
size?: 'normal' | 'small';
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', size = 'normal', className, ...rest }: Props) {
|
||||
return (
|
||||
<button
|
||||
className={[styles.button, styles[variant], size === 'small' ? styles.small : '', 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,54 @@
|
||||
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'];
|
||||
|
||||
/** Global sound keys, active on every screen. */
|
||||
export const muteKey = 'KeyM';
|
||||
export const musicKey = 'KeyB';
|
||||
export const nextTrackKey = 'KeyN';
|
||||
|
||||
/** 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' },
|
||||
{ keys: 'M', action: 'All sound on/off' },
|
||||
{ keys: 'B', action: 'Music on/off' },
|
||||
{ keys: 'N', action: 'Next music track' },
|
||||
];
|
||||
|
||||
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,44 @@
|
||||
import type { SoundId } from '../audio/types';
|
||||
|
||||
/**
|
||||
* Central mix for developers. Every value multiplies the level a sound was designed with:
|
||||
* 1 = as designed, 0 = silent, above 1 = louder (up to about 2 is sensible).
|
||||
* The players' volume sliders apply on top of this.
|
||||
*/
|
||||
export const soundMix = {
|
||||
/** Applied to everything. */
|
||||
master: 1,
|
||||
/** All sound effects. */
|
||||
sfx: 1,
|
||||
/** All background music. */
|
||||
music: 0.1,
|
||||
/** Per sound effect. Every SoundId must be listed, so new effects can't be forgotten. */
|
||||
effects: {
|
||||
move: 1,
|
||||
rotate: 1,
|
||||
hold: 1,
|
||||
hardDrop: 1,
|
||||
lock: 1,
|
||||
lineClear: 1,
|
||||
quad: 1,
|
||||
garbageIncoming: 1,
|
||||
itemPickup: 1,
|
||||
itemUse: 1,
|
||||
itemHit: 1,
|
||||
countdown: 1,
|
||||
go: 1,
|
||||
knockout: 1,
|
||||
lose: 1,
|
||||
win: 1,
|
||||
} satisfies Record<SoundId, number>,
|
||||
/** Per music track, by its id in `musicTracks` (theme/assets.ts). Tracks not listed use 1. */
|
||||
tracks: {
|
||||
pixel_rush: 1,
|
||||
neon_stack: 1,
|
||||
last_line: 1,
|
||||
} as Partial<Record<string, number>>,
|
||||
};
|
||||
|
||||
/** Initial slider positions (0..1) for players without saved settings. */
|
||||
export const defaultSfxVolume = 0.6;
|
||||
export const defaultMusicVolume = 0.35;
|
||||