import '../../core/rng.dart'; import '../models/cell.dart'; import '../models/grid.dart'; import '../models/objective.dart'; import '../models/piece.dart'; import '../models/stage.dart'; import 'game_event.dart'; import 'line_clear.dart'; import 'piece_generator.dart'; import 'placement.dart'; import 'scoring.dart'; enum GamePhase { playing, won, stuck, lost } enum StuckReason { outOfMoves, boardDead } class PlacementResult { const PlacementResult({ required this.events, required this.pointsGained, required this.linesCleared, required this.gemsCleared, required this.clearedRows, required this.clearedCols, required this.comboStreak, }); final List events; final int pointsGained; final int linesCleared; final int gemsCleared; /// Which lines vanished, for the UI clear animation. final List clearedRows; final List clearedCols; /// Streak level after this placement, for combo effects. final int comboStreak; } /// The single stateful pure-Dart session object for one stage attempt. /// UI layers wrap this behind a notifier; it never imports Flutter. class GameEngine { GameEngine(this._stage, {int attempt = 0, PieceGenerator? generator}) : _generator = generator ?? PieceGenerator(SeededRng(_stage.seed ^ attempt)), _grid = _stage.initialGrid(), _objectives = List.of(_stage.objectives), _moveLimit = _stage.moveLimit { _tray = _generator.nextTray(_grid); _checkStuck(); } static const int extraMovesGrant = 5; static const int continueRowsCleared = 2; final StageConfig _stage; final PieceGenerator _generator; GridState _grid; late List _tray; List _objectives; int _moveLimit; int _movesUsed = 0; int _score = 0; ComboState _combo = ComboState.initial; GamePhase _phase = GamePhase.playing; StuckReason? _stuckReason; bool _rescueUsed = false; GridState get grid => _grid; List get tray => List.unmodifiable(_tray); int get score => _score; ComboState get combo => _combo; int get movesUsed => _movesUsed; // movesLeft: endless is effectively infinite. int get movesLeft => _stage.endless ? 1 << 30 : _moveLimit - _movesUsed; // UI branch selector for endless mode. bool get endless => _stage.endless; List get objectives => List.unmodifiable(_objectives); GamePhase get phase => _phase; StuckReason? get stuckReason => _stuckReason; int get starsEarned => _phase == GamePhase.won ? _stage.stars.starsFor(movesLeft: movesLeft) : 0; /// Overall objective completion in [0, 1] for the near-miss screen. double get objectiveProgress { if (_objectives.isEmpty) return 0; var total = 0.0; for (final obj in _objectives) { final frac = obj.current / obj.target; total += frac > 1 ? 1 : frac; } return total / _objectives.length; } /// Read-only legality probe (drag ghost preview, AI players, tests). bool tryPlaceWouldSucceed(int trayIndex, int x, int y) => _phase == GamePhase.playing && canPlace(_grid, _tray[trayIndex], x, y); PlacementResult? tryPlace(int trayIndex, int x, int y) { if (_phase != GamePhase.playing) return null; final piece = _tray[trayIndex]; if (!canPlace(_grid, piece, x, y)) return null; _tray.removeAt(trayIndex); _movesUsed++; final placed = place(_grid, piece, x, y); final clear = detectAndClear(placed); _grid = clear.grid; final delta = scorePlacement( cellsPlaced: piece.size, linesCleared: clear.linesCleared, combo: _combo, ); _score += delta.points; _combo = delta.combo; final events = [ PiecePlaced(piece: piece, x: x, y: y), if (clear.linesCleared > 0) LinesCleared(lines: clear.linesCleared, gems: clear.gemsCleared), ScoreChanged(total: _score), ]; _objectives = [ for (final obj in _objectives) events.fold(obj, (o, event) => o.onEvent(event)), ]; if (!_stage.endless && _objectives.every((o) => o.isComplete)) { _phase = GamePhase.won; } else { if (_tray.isEmpty) _tray = _generator.nextTray(_grid); _checkStuck(); } return PlacementResult( events: events, pointsGained: delta.points, linesCleared: clear.linesCleared, gemsCleared: clear.gemsCleared, clearedRows: clear.clearedRows, clearedCols: clear.clearedCols, comboStreak: _combo.streak, ); } void _checkStuck() { if (!_stage.endless && movesLeft <= 0) { _phase = GamePhase.stuck; _stuckReason = StuckReason.outOfMoves; } else if (!anyPlacementExists(_grid, _tray)) { _phase = GamePhase.stuck; _stuckReason = StuckReason.boardDead; } else { _phase = GamePhase.playing; _stuckReason = null; } } /// Rewarded-ad continue for a dead board: clears the two most-filled rows /// and redeals. One rescue (continue or extra moves) per attempt. void useContinue() { if (_phase != GamePhase.stuck || _stuckReason != StuckReason.boardDead || _rescueUsed) { throw StateError('continue not available'); } _rescueUsed = true; final rowCounts = <(int, int)>[]; for (var y = 0; y < GridState.size; y++) { var count = 0; for (var x = 0; x < GridState.size; x++) { if (_grid.isOccupied(x, y)) count++; } rowCounts.add((y, count)); } rowCounts.sort((a, b) { final c = b.$2.compareTo(a.$2); return c != 0 ? c : a.$1.compareTo(b.$1); }); for (final (y, _) in rowCounts.take(continueRowsCleared)) { for (var x = 0; x < GridState.size; x++) { _grid = _grid.withCell(x, y, const Cell(CellType.empty)); } } _tray = _generator.nextTray(_grid); _checkStuck(); } /// Rewarded-ad rescue for move exhaustion: +5 moves. One rescue per attempt. void addExtraMoves() { if (_phase != GamePhase.stuck || _stuckReason != StuckReason.outOfMoves || _rescueUsed) { throw StateError('extra moves not available'); } _rescueUsed = true; _moveLimit += extraMovesGrant; _checkStuck(); } /// Player declines rescue (or forfeits): the attempt is lost. void declineAndLose() { if (_phase == GamePhase.won || _phase == GamePhase.lost) { throw StateError('attempt already finished'); } _phase = GamePhase.lost; } }