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(); } }