Files
BlockSeasons/lib/game/engine/game_engine.dart
T
airkjw 62cbb4b16a Add objectives, stage config, and GameEngine session core
Sealed Objective types (clearGems/reachScore/clearLines) with JSON
round-trip; StageConfig with preset cells and star thresholds;
GameEngine orchestrating placement -> clear -> scoring -> objectives
with stuck detection, one-shot rescue (continue / +5 moves), and
deterministic per-attempt RNG. 100-game headless stress test and
pure-Dart architecture guard. 76 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:11:31 +09:00

201 lines
5.7 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,
});
final List<GameEvent> events;
final int pointsGained;
final int linesCleared;
final int gemsCleared;
}
/// 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;
int get movesLeft => _moveLimit - _movesUsed;
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 (_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,
);
}
void _checkStuck() {
if (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;
}
}