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