Files
BlockSeasons/lib/game/engine/piece_generator.dart
T
airkjw 0210c14858 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>
2026-06-11 13:05:55 +09:00

118 lines
3.6 KiB
Dart

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