Add pure-Dart engine core: RNG, grid, placement, line clear, scoring, piece generator

PCG32 seeded RNG; immutable 8x8 GridState with occupancy bitmask;
placement legality + anyPlacementExists; simultaneous row/col clears
with single-count gem credit; combo scoring with one-move grace;
weighted-bag generator with pity bias and depth-3 solvability nudge.
All TDD, 51 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:05:55 +09:00
parent 40528238b2
commit 0210c14858
19 changed files with 1408 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
/// Deterministic seedable PRNG for the game engine.
///
/// dart:math's Random is implementation-unspecified across platforms; piece
/// sequences must be byte-identical on iOS, Android, and in tests, so we own
/// the algorithm (PCG32, https://www.pcg-random.org).
class SeededRng {
SeededRng(int seed, [int sequence = 0])
: _state = 0,
_inc = ((sequence << 1) | 1) {
_nextRaw();
_state = _state + seed;
_nextRaw();
}
// Dart native ints are 64-bit with wrapping arithmetic, which is exactly
// what the PCG32 state transition needs.
int _state;
final int _inc;
static const _multiplier = 6364136223846793005;
static const _mask32 = 0xFFFFFFFF;
/// One PCG32 step: 32-bit output via xorshift-high + random rotation.
int _nextRaw() {
final old = _state;
_state = old * _multiplier + _inc;
final xorshifted = (((old >>> 18) ^ old) >>> 27) & _mask32;
final rot = old >>> 59;
return ((xorshifted >>> rot) | (xorshifted << ((-rot) & 31))) & _mask32;
}
/// Uniform integer in [0, max), bias-free via rejection sampling.
int nextInt(int max) {
assert(max > 0);
final threshold = (0x100000000 - (0x100000000 % max)) & _mask32;
if (threshold == 0) return _nextRaw() % max;
while (true) {
final r = _nextRaw();
if (r < threshold) return r % max;
}
}
/// Uniform double in [0, 1).
double nextDouble() => _nextRaw() / 4294967296.0;
/// Derives an independent deterministic stream (e.g. per retry attempt).
SeededRng fork(int streamId) => SeededRng(_nextRaw() ^ streamId, streamId);
}
+56
View File
@@ -0,0 +1,56 @@
import '../models/cell.dart';
import '../models/grid.dart';
class ClearResult {
const ClearResult({
required this.grid,
required this.clearedRows,
required this.clearedCols,
required this.gemsCleared,
});
final GridState grid;
final List<int> clearedRows;
final List<int> clearedCols;
final int gemsCleared;
int get linesCleared => clearedRows.length + clearedCols.length;
}
/// Detects all simultaneously full rows/columns and clears them in one pass.
ClearResult detectAndClear(GridState grid) {
final rows = <int>[];
final cols = <int>[];
for (var y = 0; y < GridState.size; y++) {
if (grid.isRowFull(y)) rows.add(y);
}
for (var x = 0; x < GridState.size; x++) {
if (grid.isColFull(x)) cols.add(x);
}
if (rows.isEmpty && cols.isEmpty) {
return ClearResult(
grid: grid,
clearedRows: const [],
clearedCols: const [],
gemsCleared: 0,
);
}
final rowSet = rows.toSet();
final colSet = cols.toSet();
var gems = 0;
var next = grid;
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
if (!rowSet.contains(y) && !colSet.contains(x)) continue;
if (grid.cellAt(x, y).type == CellType.gem) gems++;
next = next.withCell(x, y, Cell.empty);
}
}
return ClearResult(
grid: next,
clearedRows: rows,
clearedCols: cols,
gemsCleared: gems,
);
}
+117
View File
@@ -0,0 +1,117 @@
import '../../core/rng.dart';
import '../models/grid.dart';
import '../models/piece.dart';
import '../models/piece_library.dart';
import 'line_clear.dart';
import 'placement.dart';
/// Whether some ordering of [tray] can be fully played out (with line clears
/// resolved between placements). Depth-3 backtracking on an 8x8 is cheap.
bool isTrayPlayable(GridState grid, List<Piece> tray) {
if (tray.isEmpty) return true;
final triedIds = <String>{};
for (var i = 0; i < tray.length; i++) {
final piece = tray[i];
if (!triedIds.add(piece.id)) continue;
final rest = [...tray]..removeAt(i);
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
if (!canPlace(grid, piece, x, y)) continue;
final after = detectAndClear(place(grid, piece, x, y)).grid;
if (isTrayPlayable(after, rest)) return true;
}
}
}
return false;
}
/// Weighted-bag tray dealer with pity bias on tight boards and a solvability
/// nudge: the game never deals an instantly dead tray when a playable deal
/// exists, but bad play can still lose.
class PieceGenerator {
PieceGenerator(this._rng, {List<Piece>? pool})
: _pool = pool ?? PieceLibrary.all;
static const int traySize = 3;
static const int _maxRedraws = 5;
static const double _pityThreshold = 0.6;
final SeededRng _rng;
final List<Piece> _pool;
List<Piece> nextTray(GridState grid) {
for (var attempt = 0; attempt < _maxRedraws; attempt++) {
final tray = _draw(grid);
if (isTrayPlayable(grid, tray)) return tray;
}
return _fallback(grid);
}
List<Piece> _draw(GridState grid) {
final fill = grid.fillRatio;
final candidates = List.of(_pool);
final tray = <Piece>[];
while (tray.length < traySize) {
final pick = _weightedPick(candidates, fill);
tray.add(pick);
candidates.removeWhere((p) => p.id == pick.id);
}
return tray;
}
/// Above 60% board fill, small pieces get progressively more likely so the
/// late game feels fair instead of dealing unplaceable monsters.
double _effectiveWeight(Piece piece, double fill) {
var w = piece.weight;
if (fill > _pityThreshold && piece.size <= 3) {
w *= 1 + 2 * (fill - _pityThreshold);
}
return w;
}
Piece _weightedPick(List<Piece> candidates, double fill) {
var total = 0.0;
for (final p in candidates) {
total += _effectiveWeight(p, fill);
}
var r = _rng.nextDouble() * total;
for (final p in candidates) {
r -= _effectiveWeight(p, fill);
if (r < 0) return p;
}
return candidates.last;
}
/// After repeated unplayable draws: the largest piece that still fits plus
/// the smallest fillers. Consumes no RNG, so retries stay deterministic.
List<Piece> _fallback(GridState grid) {
final bySizeDesc = [..._pool]
..sort((a, b) {
final s = b.size.compareTo(a.size);
return s != 0 ? s : a.id.compareTo(b.id);
});
Piece? largestFitting;
for (final p in bySizeDesc) {
if (_fitsAnywhere(grid, p)) {
largestFitting = p;
break;
}
}
final tray = <Piece>[if (largestFitting != null) largestFitting];
for (final p in bySizeDesc.reversed) {
if (tray.length == traySize) break;
if (tray.any((t) => t.id == p.id)) continue;
tray.add(p);
}
return tray;
}
bool _fitsAnywhere(GridState grid, Piece piece) {
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
if (canPlace(grid, piece, x, y)) return true;
}
}
return false;
}
}
+41
View File
@@ -0,0 +1,41 @@
import '../models/cell.dart';
import '../models/grid.dart';
import '../models/piece.dart';
/// Whether [piece] fits with its anchor at ([x], [y]).
bool canPlace(GridState grid, Piece piece, int x, int y) {
for (final (dx, dy) in piece.offsets) {
final px = x + dx;
final py = y + dy;
if (px < 0 || px >= GridState.size || py < 0 || py >= GridState.size) {
return false;
}
if (grid.isOccupied(px, py)) return false;
}
return true;
}
/// Places [piece] at ([x], [y]); caller must check [canPlace] first.
GridState place(GridState grid, Piece piece, int x, int y) {
var next = grid;
for (final (dx, dy) in piece.offsets) {
next = next.withCell(
x + dx,
y + dy,
Cell(CellType.filled, colorId: piece.colorId),
);
}
return next;
}
/// Whether at least one of [pieces] has at least one legal placement.
bool anyPlacementExists(GridState grid, List<Piece> pieces) {
for (final piece in pieces) {
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
if (canPlace(grid, piece, x, y)) return true;
}
}
}
return false;
}
+50
View File
@@ -0,0 +1,50 @@
/// Pure scoring math: placement points, line-clear bonuses, combo streaks.
library;
/// Base score for clearing [lines] rows/columns simultaneously.
/// 1 -> 100, 2 -> 300, 3 -> 600, 4 -> 1000: simultaneous clears are
/// superlinearly rewarded.
int lineClearBase(int lines) => 100 * lines + 50 * (lines - 1) * lines;
/// Multiplier applied to clear score at combo [streak], capped at streak 8
/// (x5.0) so late-game scores stay bounded.
double comboMultiplier(int streak) => 1 + 0.5 * (streak > 8 ? 8 : streak);
/// Combo streak with one dry-move grace before reset.
class ComboState {
const ComboState({required this.streak, required this.dryMoves});
static const initial = ComboState(streak: 0, dryMoves: 0);
final int streak;
final int dryMoves;
/// One dry move keeps the streak alive (grace); the second resets it.
ComboState advance({required bool cleared}) {
if (cleared) return ComboState(streak: streak + 1, dryMoves: 0);
if (dryMoves + 1 >= 2) return initial;
return ComboState(streak: streak, dryMoves: dryMoves + 1);
}
}
class ScoreDelta {
const ScoreDelta({required this.points, required this.combo});
final int points;
final ComboState combo;
}
/// Computes the score delta for one placement and the resulting combo state.
ScoreDelta scorePlacement({
required int cellsPlaced,
required int linesCleared,
required ComboState combo,
}) {
final next = combo.advance(cleared: linesCleared > 0);
var points = cellsPlaced;
if (linesCleared > 0) {
points += (lineClearBase(linesCleared) * comboMultiplier(next.streak))
.round();
}
return ScoreDelta(points: points, combo: next);
}
+22
View File
@@ -0,0 +1,22 @@
enum CellType { empty, filled, gem }
class Cell {
const Cell(this.type, {this.colorId = 0});
final CellType type;
final int colorId;
static const empty = Cell(CellType.empty);
bool get isOccupied => type != CellType.empty;
@override
bool operator ==(Object other) =>
other is Cell && other.type == type && other.colorId == colorId;
@override
int get hashCode => Object.hash(type, colorId);
@override
String toString() => 'Cell(${type.name}, color: $colorId)';
}
+63
View File
@@ -0,0 +1,63 @@
import 'cell.dart';
/// Immutable 8x8 board state. Mutating operations return new instances.
///
/// Occupancy is mirrored in a 64-bit bitmask (bit y*8+x) so fullness checks
/// and placement scans are O(1) mask operations.
class GridState {
static const int size = 8;
static const int cellCount = size * size;
GridState.empty()
: _cells = List.filled(cellCount, Cell.empty),
_mask = 0;
const GridState._(this._cells, this._mask);
final List<Cell> _cells;
final int _mask;
static int _index(int x, int y) => y * size + x;
/// Raw occupancy bitmask; exposed for placement/clear mask math.
int get mask => _mask;
Cell cellAt(int x, int y) => _cells[_index(x, y)];
bool isOccupied(int x, int y) => (_mask >>> _index(x, y)) & 1 == 1;
int get occupiedCount {
var n = 0;
var m = _mask;
while (m != 0) {
m &= m - 1;
n++;
}
return n;
}
double get fillRatio => occupiedCount / cellCount;
GridState withCell(int x, int y, Cell cell) {
final cells = List.of(_cells);
final i = _index(x, y);
cells[i] = cell;
final mask =
cell.isOccupied ? (_mask | (1 << i)) : (_mask & ~(1 << i));
return GridState._(List.unmodifiable(cells), mask);
}
static int _rowMask(int y) => 0xFF << (y * size);
static int _colMask(int x) {
var m = 0;
for (var y = 0; y < size; y++) {
m |= 1 << _index(x, y);
}
return m;
}
bool isRowFull(int y) => (_mask & _rowMask(y)) == _rowMask(y);
bool isColFull(int x) => (_mask & _colMask(x)) == _colMask(x);
}
+27
View File
@@ -0,0 +1,27 @@
/// A polyomino with fixed orientation (no rotation at play time, genre
/// convention). Offsets are normalized cell positions relative to the
/// top-left anchor.
class Piece {
const Piece({
required this.id,
required this.offsets,
required this.colorId,
this.weight = 1.0,
this.tier = 1,
});
final String id;
final List<(int, int)> offsets;
final int colorId;
/// Base draw weight; stage generator profiles scale this.
final double weight;
/// Difficulty tier 1 (easy to fit) .. 3 (demanding).
final int tier;
int get size => offsets.length;
@override
String toString() => 'Piece($id)';
}
+241
View File
@@ -0,0 +1,241 @@
import 'piece.dart';
/// The fixed-orientation polyomino set (no play-time rotation, genre
/// convention). Base weights skew small pieces common; stage generator
/// profiles rescale per difficulty.
class PieceLibrary {
static const double _small = 1.2;
static const double _mid = 1.0;
static const double _large = 0.7;
static const List<Piece> all = [
// --- size 1-2 ---
Piece(id: 'mono', colorId: 0, weight: _small, tier: 1, offsets: [(0, 0)]),
Piece(
id: 'domino_h',
colorId: 1,
weight: _small,
tier: 1,
offsets: [(0, 0), (1, 0)]),
Piece(
id: 'domino_v',
colorId: 1,
weight: _small,
tier: 1,
offsets: [(0, 0), (0, 1)]),
// --- trominoes ---
Piece(
id: 'line3_h',
colorId: 2,
weight: _small,
tier: 1,
offsets: [(0, 0), (1, 0), (2, 0)]),
Piece(
id: 'line3_v',
colorId: 2,
weight: _small,
tier: 1,
offsets: [(0, 0), (0, 1), (0, 2)]),
Piece(
id: 'corner3_tl',
colorId: 3,
weight: _small,
tier: 1,
offsets: [(0, 0), (1, 0), (0, 1)]),
Piece(
id: 'corner3_tr',
colorId: 3,
weight: _small,
tier: 1,
offsets: [(0, 0), (1, 0), (1, 1)]),
Piece(
id: 'corner3_bl',
colorId: 3,
weight: _small,
tier: 1,
offsets: [(0, 0), (0, 1), (1, 1)]),
Piece(
id: 'corner3_br',
colorId: 3,
weight: _small,
tier: 1,
offsets: [(1, 0), (0, 1), (1, 1)]),
// --- tetrominoes ---
Piece(
id: 'line4_h',
colorId: 4,
weight: _mid,
tier: 2,
offsets: [(0, 0), (1, 0), (2, 0), (3, 0)]),
Piece(
id: 'line4_v',
colorId: 4,
weight: _mid,
tier: 2,
offsets: [(0, 0), (0, 1), (0, 2), (0, 3)]),
Piece(
id: 'square2',
colorId: 5,
weight: _mid,
tier: 1,
offsets: [(0, 0), (1, 0), (0, 1), (1, 1)]),
Piece(
id: 't4_up',
colorId: 6,
weight: _mid,
tier: 2,
offsets: [(0, 0), (1, 0), (2, 0), (1, 1)]),
Piece(
id: 't4_down',
colorId: 6,
weight: _mid,
tier: 2,
offsets: [(1, 0), (0, 1), (1, 1), (2, 1)]),
Piece(
id: 't4_left',
colorId: 6,
weight: _mid,
tier: 2,
offsets: [(1, 0), (0, 1), (1, 1), (1, 2)]),
Piece(
id: 't4_right',
colorId: 6,
weight: _mid,
tier: 2,
offsets: [(0, 0), (0, 1), (1, 1), (0, 2)]),
Piece(
id: 's4_h',
colorId: 7,
weight: _mid,
tier: 2,
offsets: [(1, 0), (2, 0), (0, 1), (1, 1)]),
Piece(
id: 's4_v',
colorId: 7,
weight: _mid,
tier: 2,
offsets: [(0, 0), (0, 1), (1, 1), (1, 2)]),
Piece(
id: 'z4_h',
colorId: 7,
weight: _mid,
tier: 2,
offsets: [(0, 0), (1, 0), (1, 1), (2, 1)]),
Piece(
id: 'z4_v',
colorId: 7,
weight: _mid,
tier: 2,
offsets: [(1, 0), (0, 1), (1, 1), (0, 2)]),
Piece(
id: 'l4_0',
colorId: 0,
weight: _mid,
tier: 2,
offsets: [(0, 0), (0, 1), (0, 2), (1, 2)]),
Piece(
id: 'l4_90',
colorId: 0,
weight: _mid,
tier: 2,
offsets: [(0, 0), (1, 0), (2, 0), (0, 1)]),
Piece(
id: 'l4_180',
colorId: 0,
weight: _mid,
tier: 2,
offsets: [(0, 0), (1, 0), (1, 1), (1, 2)]),
Piece(
id: 'l4_270',
colorId: 0,
weight: _mid,
tier: 2,
offsets: [(2, 0), (0, 1), (1, 1), (2, 1)]),
Piece(
id: 'j4_0',
colorId: 1,
weight: _mid,
tier: 2,
offsets: [(1, 0), (1, 1), (0, 2), (1, 2)]),
Piece(
id: 'j4_90',
colorId: 1,
weight: _mid,
tier: 2,
offsets: [(0, 0), (0, 1), (1, 1), (2, 1)]),
Piece(
id: 'j4_180',
colorId: 1,
weight: _mid,
tier: 2,
offsets: [(0, 0), (1, 0), (0, 1), (0, 2)]),
Piece(
id: 'j4_270',
colorId: 1,
weight: _mid,
tier: 2,
offsets: [(0, 0), (1, 0), (2, 0), (2, 1)]),
// --- size 5-6 ---
Piece(
id: 'line5_h',
colorId: 2,
weight: _large,
tier: 3,
offsets: [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]),
Piece(
id: 'line5_v',
colorId: 2,
weight: _large,
tier: 3,
offsets: [(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)]),
Piece(
id: 'rect2x3',
colorId: 3,
weight: _large,
tier: 2,
offsets: [(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)]),
Piece(
id: 'rect3x2',
colorId: 3,
weight: _large,
tier: 2,
offsets: [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]),
Piece(
id: 'bigl_tl',
colorId: 4,
weight: _large,
tier: 3,
offsets: [(0, 0), (1, 0), (2, 0), (0, 1), (0, 2)]),
Piece(
id: 'bigl_tr',
colorId: 4,
weight: _large,
tier: 3,
offsets: [(0, 0), (1, 0), (2, 0), (2, 1), (2, 2)]),
Piece(
id: 'bigl_bl',
colorId: 4,
weight: _large,
tier: 3,
offsets: [(0, 0), (0, 1), (0, 2), (1, 2), (2, 2)]),
Piece(
id: 'bigl_br',
colorId: 4,
weight: _large,
tier: 3,
offsets: [(2, 0), (2, 1), (0, 2), (1, 2), (2, 2)]),
// --- size 9 ---
Piece(
id: 'square3',
colorId: 5,
weight: 0.5,
tier: 3,
offsets: [
(0, 0), (1, 0), (2, 0),
(0, 1), (1, 1), (2, 1),
(0, 2), (1, 2), (2, 2),
]),
];
static Piece byId(String id) => all.firstWhere((p) => p.id == id);
}