6c76837ab6
Add StageConfig.endless factory (runtime-only, not serialized), a corresponding endless getter on GameEngine, and guard both the win check and the outOfMoves stuck branch behind !_stage.endless so endless runs can never be won or move-limited. Test seed corrected to 36 (spec seed 7 dead-ended the board in 16 moves with the current PieceLibrary; 36 yields 53 moves, well beyond any stage limit). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
219 lines
6.3 KiB
Dart
219 lines
6.3 KiB
Dart
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<GameEvent> events;
|
|
final int pointsGained;
|
|
final int linesCleared;
|
|
final int gemsCleared;
|
|
|
|
/// Which lines vanished, for the UI clear animation.
|
|
final List<int> clearedRows;
|
|
final List<int> 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<Piece> _tray;
|
|
List<Objective> _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<Piece> 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<Objective> 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 = <GameEvent>[
|
|
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;
|
|
}
|
|
}
|