"""
Reusable falling-block gameplay helpers for stacking puzzle games.
"""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from typing import Callable, Generic, Iterable, TypeVar
from mini_arcade_core.scenes.systems.builtins.grid import GridCoord
from mini_arcade_core.scenes.systems.phases import SystemPhase
# pylint: disable=invalid-name
TCtx = TypeVar("TCtx")
TCell = TypeVar("TCell")
TItem = TypeVar("TItem")
# pylint: enable=invalid-name
def _default_enabled_when(_ctx: object) -> bool:
return True
[docs]
def block_cells_from_strings(
*rows: str,
filled_chars: str = "#XO@[]",
) -> tuple[GridCoord, ...]:
"""
Parse one rotation from ASCII rows into local grid cells.
"""
filled = set(str(filled_chars))
out: list[GridCoord] = []
for row_idx, row in enumerate(rows):
for col_idx, char in enumerate(str(row)):
if char in filled:
out.append(GridCoord(col=col_idx, row=row_idx))
return tuple(out)
[docs]
@dataclass
class BlockBoard(Generic[TCell]):
"""
Dense visible board state for stacking puzzle games.
"""
cols: int
rows: int
empty: TCell | None = None
_cells: list[list[TCell | None]] = field(
default_factory=list,
init=False,
repr=False,
)
[docs]
def __post_init__(self) -> None:
self._cells = [
[self.empty for _ in range(int(self.cols))]
for _ in range(int(self.rows))
]
[docs]
def in_bounds(self, coord: GridCoord) -> bool:
"""
Return whether a cell lies inside the visible board.
"""
return 0 <= int(coord.col) < int(self.cols) and 0 <= int(
coord.row
) < int(self.rows)
[docs]
def get(self, coord: GridCoord) -> TCell | None:
"""
Return the stored value for a visible cell.
"""
if not self.in_bounds(coord):
return None
return self._cells[int(coord.row)][int(coord.col)]
[docs]
def set(self, coord: GridCoord, value: TCell | None) -> None:
"""
Write a value into a visible cell.
"""
if not self.in_bounds(coord):
raise IndexError(f"Cell out of bounds: {coord!r}")
self._cells[int(coord.row)][int(coord.col)] = value
[docs]
def clear(self, coord: GridCoord) -> None:
"""
Reset one visible cell to the configured empty value.
"""
self.set(coord, self.empty)
[docs]
def row_values(self, row: int) -> tuple[TCell | None, ...]:
"""
Return one row as an immutable tuple.
"""
return tuple(self._cells[int(row)])
[docs]
def row_is_filled(self, row: int) -> bool:
"""
Return whether a row contains no empty cells.
"""
return all(cell is not None for cell in self._cells[int(row)])
[docs]
def filled_rows(self) -> tuple[int, ...]:
"""
Return the indexes of completely filled rows.
"""
return tuple(
row for row in range(int(self.rows)) if self.row_is_filled(row)
)
[docs]
def occupied_cells(self) -> tuple[GridCoord, ...]:
"""
Return the coordinates of all non-empty cells.
"""
out: list[GridCoord] = []
for row in range(int(self.rows)):
for col in range(int(self.cols)):
if self._cells[row][col] is not None:
out.append(GridCoord(col=col, row=row))
return tuple(out)
[docs]
def occupied_entries(self) -> tuple[tuple[GridCoord, TCell], ...]:
"""
Return coordinates paired with stored cell values.
"""
out: list[tuple[GridCoord, TCell]] = []
for row in range(int(self.rows)):
for col in range(int(self.cols)):
value = self._cells[row][col]
if value is None:
continue
out.append((GridCoord(col=col, row=row), value))
return tuple(out)
[docs]
def can_place(
self,
cells: Iterable[GridCoord],
*,
allow_rows_above_board: bool = False,
) -> bool:
"""
Return whether the given cells can be occupied without collisions.
"""
for coord in cells:
if int(coord.col) < 0 or int(coord.col) >= int(self.cols):
return False
if int(coord.row) >= int(self.rows):
return False
if int(coord.row) < 0:
if allow_rows_above_board:
continue
return False
if self.get(coord) is not None:
return False
return True
[docs]
def stamp(
self,
cells: Iterable[GridCoord],
*,
value: TCell,
ignore_rows_above_board: bool = True,
) -> tuple[GridCoord, ...]:
"""
Write one value into multiple cells and return written coordinates.
"""
written: list[GridCoord] = []
for coord in cells:
if int(coord.row) < 0 and ignore_rows_above_board:
continue
self.set(coord, value)
written.append(coord)
return tuple(written)
[docs]
def collapse_rows(self, rows: Iterable[int]) -> tuple[int, ...]:
"""
Remove filled rows and shift higher rows downward.
"""
unique_rows = tuple(sorted({int(row) for row in rows}))
if not unique_rows:
return ()
removed = set(unique_rows)
survivors = [
list(self._cells[row])
for row in range(int(self.rows))
if row not in removed
]
while len(survivors) < int(self.rows):
survivors.insert(0, [self.empty for _ in range(int(self.cols))])
self._cells = survivors
return unique_rows
[docs]
@dataclass(frozen=True)
class FallingBlockPieceSpec:
"""
One falling-piece definition with precomputed rotation states.
"""
name: str
rotations: tuple[tuple[GridCoord, ...], ...]
[docs]
def __post_init__(self) -> None:
if not self.rotations:
raise ValueError("rotations must not be empty")
[docs]
def cells(self, rotation: int = 0) -> tuple[GridCoord, ...]:
"""
Return the local cells for the normalized rotation index.
"""
index = int(rotation) % len(self.rotations)
return self.rotations[index]
[docs]
@dataclass(frozen=True)
class FallingBlockPiece:
"""
Active falling piece instance positioned on a board grid.
"""
spec_name: str
origin: GridCoord
rotation: int = 0
[docs]
def translated(
self,
*,
dcol: int = 0,
drow: int = 0,
) -> "FallingBlockPiece":
"""
Return a translated copy of the active piece.
"""
return FallingBlockPiece(
spec_name=self.spec_name,
origin=self.origin.translated(dcol=dcol, drow=drow),
rotation=int(self.rotation),
)
[docs]
def rotated(self, delta: int = 1) -> "FallingBlockPiece":
"""
Return a copy rotated by a relative delta.
"""
return FallingBlockPiece(
spec_name=self.spec_name,
origin=self.origin,
rotation=int(self.rotation) + int(delta),
)
[docs]
def cells(self, spec: FallingBlockPieceSpec) -> tuple[GridCoord, ...]:
"""
Return the occupied board cells for this active piece.
"""
return tuple(
self.origin.translated(dcol=cell.col, drow=cell.row)
for cell in spec.cells(self.rotation)
)
[docs]
def piece_fits(
board: BlockBoard[TCell],
piece: FallingBlockPiece,
spec: FallingBlockPieceSpec,
*,
allow_rows_above_board: bool = False,
) -> bool:
"""
Return whether an active piece fits on a board without collisions.
"""
return board.can_place(
piece.cells(spec),
allow_rows_above_board=allow_rows_above_board,
)
[docs]
@dataclass
class BagRandomizer(Generic[TItem]):
"""
Deterministic bag-based sequence generator.
"""
items: tuple[TItem, ...]
seed: int = 1
_bag: list[TItem] = field(default_factory=list, init=False, repr=False)
_rng: random.Random = field(init=False, repr=False)
[docs]
def __post_init__(self) -> None:
if not self.items:
raise ValueError("items must not be empty")
self._rng = random.Random(int(self.seed))
[docs]
def refill(self) -> tuple[TItem, ...]:
"""
Refill and shuffle the current bag.
"""
self._bag = list(self.items)
self._rng.shuffle(self._bag)
return tuple(self._bag)
[docs]
def next(self) -> TItem:
"""
Draw the next item, refilling the bag as needed.
"""
if not self._bag:
self.refill()
return self._bag.pop(0)
[docs]
def peek(self) -> tuple[TItem, ...]:
"""
Return the remaining contents of the current bag.
"""
return tuple(self._bag)
[docs]
@dataclass(frozen=True)
class BoardRowClearBinding(Generic[TCtx]):
"""
Declarative row-clear rule for one falling-block board.
"""
board_getter: Callable[[TCtx], BlockBoard[object]]
enabled_when: Callable[[TCtx], bool] = _default_enabled_when
on_cleared: Callable[[TCtx, tuple[int, ...]], None] | None = None
[docs]
@dataclass
class BoardRowClearSystem(Generic[TCtx]):
"""
Clear fully occupied rows and collapse the board downward.
"""
name: str = "common_board_row_clear"
phase: int = SystemPhase.SIMULATION
order: int = 70
enabled_when: Callable[[TCtx], bool] = _default_enabled_when
bindings: tuple[BoardRowClearBinding[TCtx], ...] = ()
[docs]
def step(self, ctx: TCtx) -> None:
"""Collapse filled rows for each configured board and emit callbacks."""
if not self.enabled_when(ctx):
return
for binding in self.bindings:
if not binding.enabled_when(ctx):
continue
board = binding.board_getter(ctx)
cleared = board.collapse_rows(board.filled_rows())
if not cleared:
continue
if binding.on_cleared is not None:
binding.on_cleared(ctx, cleared)
__all__ = [
"BagRandomizer",
"BlockBoard",
"BoardRowClearBinding",
"BoardRowClearSystem",
"FallingBlockPiece",
"FallingBlockPieceSpec",
"block_cells_from_strings",
"piece_fits",
]