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>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import '../models/piece.dart';
|
||||
|
||||
/// Events emitted by the engine during a placement, consumed by objectives
|
||||
/// and (later) the UI effects layer.
|
||||
sealed class GameEvent {
|
||||
const GameEvent();
|
||||
}
|
||||
|
||||
class PiecePlaced extends GameEvent {
|
||||
const PiecePlaced({required this.piece, required this.x, required this.y});
|
||||
|
||||
final Piece piece;
|
||||
final int x;
|
||||
final int y;
|
||||
}
|
||||
|
||||
class LinesCleared extends GameEvent {
|
||||
const LinesCleared({required this.lines, required this.gems});
|
||||
|
||||
final int lines;
|
||||
final int gems;
|
||||
}
|
||||
|
||||
class ScoreChanged extends GameEvent {
|
||||
const ScoreChanged({required this.total});
|
||||
|
||||
final int total;
|
||||
}
|
||||
@@ -97,7 +97,7 @@ class PieceGenerator {
|
||||
break;
|
||||
}
|
||||
}
|
||||
final tray = <Piece>[if (largestFitting != null) largestFitting];
|
||||
final tray = <Piece>[?largestFitting];
|
||||
for (final p in bySizeDesc.reversed) {
|
||||
if (tray.length == traySize) break;
|
||||
if (tray.any((t) => t.id == p.id)) continue;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import '../engine/game_event.dart';
|
||||
|
||||
/// Stage win condition with immutable progress tracking. New objective types
|
||||
/// plug in as subclasses with a JSON tag.
|
||||
sealed class Objective {
|
||||
const Objective();
|
||||
|
||||
factory Objective.clearGems(int count) => ClearGemsObjective(count, 0);
|
||||
factory Objective.reachScore(int target) => ReachScoreObjective(target, 0);
|
||||
factory Objective.clearLines(int count) => ClearLinesObjective(count, 0);
|
||||
|
||||
factory Objective.fromJson(Map<String, dynamic> json) {
|
||||
switch (json['type']) {
|
||||
case 'clearGems':
|
||||
return Objective.clearGems(json['count'] as int);
|
||||
case 'reachScore':
|
||||
return Objective.reachScore(json['target'] as int);
|
||||
case 'clearLines':
|
||||
return Objective.clearLines(json['count'] as int);
|
||||
default:
|
||||
throw FormatException('Unknown objective type: ${json['type']}');
|
||||
}
|
||||
}
|
||||
|
||||
int get target;
|
||||
int get current;
|
||||
bool get isComplete => current >= target;
|
||||
|
||||
Objective onEvent(GameEvent event);
|
||||
|
||||
Map<String, dynamic> toJson();
|
||||
}
|
||||
|
||||
class ClearGemsObjective extends Objective {
|
||||
const ClearGemsObjective(this.target, this.current);
|
||||
|
||||
@override
|
||||
final int target;
|
||||
@override
|
||||
final int current;
|
||||
|
||||
@override
|
||||
Objective onEvent(GameEvent event) => switch (event) {
|
||||
LinesCleared(:final gems) =>
|
||||
ClearGemsObjective(target, current + gems),
|
||||
_ => this,
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'type': 'clearGems', 'count': target};
|
||||
}
|
||||
|
||||
class ReachScoreObjective extends Objective {
|
||||
const ReachScoreObjective(this.target, this.current);
|
||||
|
||||
@override
|
||||
final int target;
|
||||
@override
|
||||
final int current;
|
||||
|
||||
@override
|
||||
Objective onEvent(GameEvent event) => switch (event) {
|
||||
ScoreChanged(:final total) => ReachScoreObjective(target, total),
|
||||
_ => this,
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'type': 'reachScore', 'target': target};
|
||||
}
|
||||
|
||||
class ClearLinesObjective extends Objective {
|
||||
const ClearLinesObjective(this.target, this.current);
|
||||
|
||||
@override
|
||||
final int target;
|
||||
@override
|
||||
final int current;
|
||||
|
||||
@override
|
||||
Objective onEvent(GameEvent event) => switch (event) {
|
||||
LinesCleared(:final lines) =>
|
||||
ClearLinesObjective(target, current + lines),
|
||||
_ => this,
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'type': 'clearLines', 'count': target};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'cell.dart';
|
||||
import 'grid.dart';
|
||||
import 'objective.dart';
|
||||
|
||||
/// A pre-placed cell in a stage layout.
|
||||
class PresetCell {
|
||||
const PresetCell({
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.type,
|
||||
this.colorId = 0,
|
||||
});
|
||||
|
||||
factory PresetCell.fromJson(Map<String, dynamic> json) => PresetCell(
|
||||
x: json['x'] as int,
|
||||
y: json['y'] as int,
|
||||
type: switch (json['t']) {
|
||||
'gem' => CellType.gem,
|
||||
'filled' => CellType.filled,
|
||||
_ => throw FormatException('Unknown preset cell type: ${json['t']}'),
|
||||
},
|
||||
colorId: (json['c'] as int?) ?? 0,
|
||||
);
|
||||
|
||||
final int x;
|
||||
final int y;
|
||||
final CellType type;
|
||||
final int colorId;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'x': x,
|
||||
'y': y,
|
||||
't': type.name,
|
||||
if (type == CellType.filled) 'c': colorId,
|
||||
};
|
||||
}
|
||||
|
||||
/// Star rating from moves remaining at the win.
|
||||
class StarThresholds {
|
||||
const StarThresholds({
|
||||
required this.twoMovesLeft,
|
||||
required this.threeMovesLeft,
|
||||
});
|
||||
|
||||
factory StarThresholds.fromJson(Map<String, dynamic> json) =>
|
||||
StarThresholds(
|
||||
twoMovesLeft: (json['two'] as Map<String, dynamic>)['movesLeft'] as int,
|
||||
threeMovesLeft:
|
||||
(json['three'] as Map<String, dynamic>)['movesLeft'] as int,
|
||||
);
|
||||
|
||||
final int twoMovesLeft;
|
||||
final int threeMovesLeft;
|
||||
|
||||
int starsFor({required int movesLeft}) {
|
||||
if (movesLeft >= threeMovesLeft) return 3;
|
||||
if (movesLeft >= twoMovesLeft) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'two': {'movesLeft': twoMovesLeft},
|
||||
'three': {'movesLeft': threeMovesLeft},
|
||||
};
|
||||
}
|
||||
|
||||
/// Immutable stage definition, the unit of season pack content.
|
||||
class StageConfig {
|
||||
const StageConfig({
|
||||
required this.id,
|
||||
required this.seed,
|
||||
required this.moveLimit,
|
||||
required this.preset,
|
||||
required this.objectives,
|
||||
required this.stars,
|
||||
required this.generatorProfile,
|
||||
});
|
||||
|
||||
factory StageConfig.fromJson(Map<String, dynamic> json) => StageConfig(
|
||||
id: json['id'] as String,
|
||||
seed: json['seed'] as int,
|
||||
moveLimit: json['moveLimit'] as int,
|
||||
preset: [
|
||||
for (final cell in (json['preset'] as List? ?? const []))
|
||||
PresetCell.fromJson(cell as Map<String, dynamic>),
|
||||
],
|
||||
objectives: [
|
||||
for (final obj in json['objectives'] as List)
|
||||
Objective.fromJson(obj as Map<String, dynamic>),
|
||||
],
|
||||
stars: StarThresholds.fromJson(json['stars'] as Map<String, dynamic>),
|
||||
generatorProfile: (json['generatorProfile'] as String?) ?? 'mid',
|
||||
);
|
||||
|
||||
final String id;
|
||||
final int seed;
|
||||
final int moveLimit;
|
||||
final List<PresetCell> preset;
|
||||
final List<Objective> objectives;
|
||||
final StarThresholds stars;
|
||||
final String generatorProfile;
|
||||
|
||||
GridState initialGrid() {
|
||||
var grid = GridState.empty();
|
||||
for (final cell in preset) {
|
||||
grid = grid.withCell(
|
||||
cell.x,
|
||||
cell.y,
|
||||
Cell(cell.type, colorId: cell.colorId),
|
||||
);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'seed': seed,
|
||||
'moveLimit': moveLimit,
|
||||
'preset': [for (final cell in preset) cell.toJson()],
|
||||
'objectives': [for (final obj in objectives) obj.toJson()],
|
||||
'stars': stars.toJson(),
|
||||
'generatorProfile': generatorProfile,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user