init
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user