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,
}
///
/// Simulation of a single player's field: falling piece, hold, queue, line clears, garbage and items.
/// The object is not thread-safe; the owning serializes access.
///
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 _next = [];
private readonly List _inventory = [];
private readonly List _pendingGarbage = [];
private readonly List _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 Next => _next;
public IReadOnlyList 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; }
/// Match time until which locked cells and the ghost piece are hidden from this player.
public double InvisibleUntil { get; set; }
public bool IsInvisible => Now < InvisibleUntil;
/// The player selected as item target (own id = self).
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;
}
}
/// Where the active piece would land.
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;
}
/// Queues incoming garbage; it rises when the player next locks a piece without clearing lines.
public void QueueGarbage(int lines)
{
if (lines > 0 && IsAlive)
{
_pendingGarbage.Add(lines);
}
}
/// Returns and clears the attacks produced since the last call.
public IReadOnlyList 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();
}
/// Uses outgoing lines to cancel incoming garbage first; returns what is left to send.
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;
}
/// Raises all pending garbage. Returns false if the player topped out.
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;
}
/// After the board changed under the active piece, push the piece up until it fits again.
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();
}
}