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
+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);
}