Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ Quest stages may pin the FIRST tray via the stage's `tray` field (the
"opening break" design — see CONTRIBUTING_QUESTS.md); the generator
takes over from the second tray on.

**Classic clear-focus** (`GameState.clearFocus`, set by the classic
controller): the same generator, turned up for maximum clears/combos.
Breakers are boosted ~9×, near-empty boards favor big/long pieces (to
set up multi-line clears and all-clears), and among more candidate sets
it keeps the one with the highest `clearingPotential` (a greedy estimate
of lines the set can clear). Quest keeps the balanced defaults.

Classic rolls a random seed per run (persisted); quest stages may pin a
seed in their data.

Expand Down
18 changes: 18 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,24 @@ lose-screen/80%-banner visual eyeballing during play, a CI workflow
- Session ended mid slice-D device verification at user request; all
code committed on `dev`, working tree clean, 43/43 tests green.

### 2026-07-15 — Playtest round 4: preview, quest colors, classic clears
- Ghost preview: over the board it now ALWAYS shows a pre-place, snapping
to the nearest legal cell (forgiving placement); the drop lands there.
Return-to-tray only happens when released off the board. Over-board-but-
no-fit shows a red invalid ghost.
- Quest visual: pre-placed puzzle blocks AND gem cells now render in one
neutral light tile (Cell.preplaced flag; puzzleBlockLight), so the
puzzle reads as a unit and the player's vivid pieces stand out (per the
reference image). Player-placed cells keep palette colors.
- Classic clears much easier: GameState.clearFocus threads a mode flag to
the generator — breakers boosted ~9×, open boards favor big/long pieces
for multi-line clears + all-clears, and the deal keeps the set with the
best clearingPotential. Quest keeps its balance. Deterministic per seed,
tests added (countCompletedLines, clearingPotential, clearFocus bias).
- iOS: verified the project + all plugins are iOS-ready; can't build here
(this Mac lacks the iOS platform SDK in Xcode and a signing cert — both
user-side). Steps for the user in RELEASING.md / final summary.

### 2026-07-15 — Perf: thermal throttling after 5–10 min of play
- User report: sound/vibration/animation all degrade and the phone gets
hot after 5–10 min. Diagnosed with a monitored on-device soak: a
Expand Down
4 changes: 3 additions & 1 deletion SAVE_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,15 @@ silently discarding progress.
"combo": 3,
"roundBestCombo": 7,
"allClears": 1, // this run; absent in old saves -> 0
"clearFocus": true, // classic mode's easy-clears dealing
"gems": {"red": 1} // collected this run (quest only)
}
```

Notes:
- Board cell: `null` = empty; otherwise `c` = theme palette color index,
optional `g` = gem color name (quest boards only).
optional `g` = gem color name (quest boards only), optional `p` = true
for pre-placed quest puzzle blocks (rendered in the neutral light tile).
- 64-bit integers (`rngState`, `classicRunSeed`) are stored as **strings**
because JSON tooling commonly round-trips numbers through doubles, which
corrupts values above 2^53.
Expand Down
80 changes: 60 additions & 20 deletions lib/game/block_puzzle_game.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/foundation.dart';

import '../models/board.dart';
import '../models/game_state.dart';
import '../models/game_theme.dart';
import '../models/piece.dart';
Expand Down Expand Up @@ -174,37 +175,76 @@ class BlockPuzzleGame extends FlameGame {
_renderedTray = List.of(state.tray);
}

/// Updates [preview] for a piece hovering with its top-left cell corner
/// at [pieceTopLeft] (canvas coords). Null when off-board/illegal cells
/// still produce a preview with legal=false so the ghost can hide.
/// Updates [preview] while a piece hovers with its top-left cell corner
/// at [pieceTopLeft]. Behaviour (per playtest feedback):
/// - off the board → no preview (the drop will bounce back to the tray),
/// - over the board → always show a ghost. Snap to the nearest legal
/// spot so there is always a pre-place, and the drop lands there —
/// you don't have to hit the exact cell.
/// - over the board but the piece fits nowhere → an invalid (red) ghost
/// at the clamped position; the drop bounces.
void updatePreview(Piece piece, Offset pieceTopLeft) {
final (row, col) = geometry.snapCell(pieceTopLeft);
final legal = canPlace(state.board, piece, row, col);
var rows = const <int>[];
var cols = const <int>[];
if (legal) {
final result = clearFullLines(stamp(state.board, piece, row, col));
rows = result.rows;
cols = result.cols;
final cell = geometry.cell;
final center =
pieceTopLeft + Offset(piece.width * cell / 2, piece.height * cell / 2);
if (!geometry.boardRect.inflate(cell * 0.6).contains(center)) {
preview = null; // off the grid
return;
}
final (rawRow, rawCol) = geometry.snapCell(pieceTopLeft);
final resolved = _nearestLegal(piece, rawRow, rawCol);
if (resolved == null) {
preview = DragPreview(
piece: piece,
row: rawRow.clamp(0, Board.size - piece.height),
col: rawCol.clamp(0, Board.size - piece.width),
legal: false,
wouldClearRows: const [],
wouldClearCols: const [],
);
return;
}
final (row, col) = resolved;
final result = clearFullLines(stamp(state.board, piece, row, col));
preview = DragPreview(
piece: piece,
row: row,
col: col,
legal: legal,
wouldClearRows: rows,
wouldClearCols: cols,
legal: true,
wouldClearRows: result.rows,
wouldClearCols: result.cols,
);
}

/// Closest legal placement to the raw snap (Euclidean in cell space),
/// or null if the piece fits nowhere.
(int, int)? _nearestLegal(Piece piece, int rawRow, int rawCol) {
final maxRow = Board.size - piece.height;
final maxCol = Board.size - piece.width;
if (maxRow < 0 || maxCol < 0) return null;
(int, int)? best;
var bestDist = 1 << 30;
for (var r = 0; r <= maxRow; r++) {
for (var c = 0; c <= maxCol; c++) {
if (!canPlace(state.board, piece, r, c)) continue;
final d = (r - rawRow) * (r - rawRow) + (c - rawCol) * (c - rawCol);
if (d < bestDist) {
bestDist = d;
best = (r, c);
}
}
}
return best;
}

void clearPreview() => preview = null;

/// Attempts the drop; returns the outcome (null = bounce back to tray).
PlacementOutcome? tryPlace(int trayIndex, Offset pieceTopLeft) {
final piece = pieceById[state.tray[trayIndex]!]!;
final (row, col) = geometry.snapCell(pieceTopLeft);
if (!canPlace(state.board, piece, row, col)) return null;
final outcome = onPlace(trayIndex, row, col);
/// Drops [trayIndex] at the resolved preview cell. Returns null (bounce
/// to tray) when there is no legal preview — i.e. off-grid or no fit.
PlacementOutcome? tryPlace(int trayIndex) {
final p = preview;
if (p == null || !p.legal) return null;
final outcome = onPlace(trayIndex, p.row, p.col);
if (outcome != null) spawnPlacementEffects(this, outcome.events);
return outcome;
}
Expand Down
27 changes: 25 additions & 2 deletions lib/game/board_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,39 @@ class BoardComponent extends PositionComponent
Paint()..color = theme.emptyCell,
);
} else if (occupant.gem != null) {
paintBlock(canvas, rect, gemBlockGold);
paintBlock(canvas, rect, puzzleBlockLight);
_paintGem(canvas, rect, gemColors[occupant.gem]!);
} else if (occupant.preplaced) {
// Pre-placed puzzle blocks share the neutral light tile.
paintBlock(canvas, rect, puzzleBlockLight);
} else {
paintBlock(canvas, rect, theme.blockColor(occupant.colorId));
}
}
}

final preview = game.preview;
if (preview == null || !preview.legal) return;
if (preview == null) return;

// Over the board but the piece fits nowhere near: a red invalid ghost
// so there is still a pre-place (the drop will bounce).
if (!preview.legal) {
for (final (r, c) in preview.piece.cells) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(
(preview.col + c) * cell,
(preview.row + r) * cell,
cell,
cell,
).deflate(cell * 0.06),
Radius.circular(cell * 0.12),
),
Paint()..color = const Color(0x55E53935),
);
}
return;
}

// Would-clear lines glow across their full length.
final glow = Paint()..color = theme.lineHighlight;
Expand Down
2 changes: 1 addition & 1 deletion lib/game/tray_piece_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ class TrayPieceComponent extends PositionComponent
if (!_dragging) return;
_dragging = false;
game.dragActive = false;
final dropped = game.tryPlace(trayIndex, _topLeftCorner());
final dropped = game.tryPlace(trayIndex);
game.clearPreview();
if (dropped == null) _returnToTray();
// On success the state listener rebuilds the tray, removing this
Expand Down
7 changes: 5 additions & 2 deletions lib/models/board_strings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,19 @@ Board parseBoardRows(List<String> rows) {
);
}
for (final ch in row.split('')) {
// Cells authored in a board string are the pre-placed puzzle
// (quest stages) — mark them so they render in the neutral light
// color rather than a vivid player-block color.
if (ch == '.') {
cells.add(null);
} else if (gemLetters.containsKey(ch)) {
cells.add(Cell(gemBlockColorId, gem: gemLetters[ch]));
cells.add(Cell(gemBlockColorId, gem: gemLetters[ch], preplaced: true));
} else {
final colorId = int.tryParse(ch);
if (colorId == null || colorId > 7) {
throw FormatException('Unknown board char "$ch" in row $r');
}
cells.add(Cell(colorId));
cells.add(Cell(colorId, preplaced: true));
}
}
}
Expand Down
17 changes: 14 additions & 3 deletions lib/models/cell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,41 @@ enum GemColor { red, blue, purple, yellow, green }
/// One occupied board cell. Empty cells are represented as `null` in
/// [Board.cells], so this class only models occupancy.
class Cell {
const Cell(this.colorId, {this.gem});
const Cell(this.colorId, {this.gem, this.preplaced = false});

/// Index into the active theme's block palette.
final int colorId;

final GemColor? gem;

/// True for a quest stage's pre-placed puzzle blocks (and gem cells),
/// as opposed to blocks the player dropped. Pre-placed cells render in
/// one neutral light color so gems and the puzzle read as a unit and
/// the player's own vivid pieces stand out against it.
final bool preplaced;

Map<String, Object?> toJson() => {
'c': colorId,
if (gem != null) 'g': gem!.name,
if (preplaced) 'p': true,
};

factory Cell.fromJson(Map<String, Object?> json) {
final gemName = json['g'] as String?;
return Cell(
json['c'] as int,
gem: gemName == null ? null : GemColor.values.byName(gemName),
preplaced: json['p'] as bool? ?? false,
);
}

@override
bool operator ==(Object other) =>
other is Cell && other.colorId == colorId && other.gem == gem;
other is Cell &&
other.colorId == colorId &&
other.gem == gem &&
other.preplaced == preplaced;

@override
int get hashCode => Object.hash(colorId, gem);
int get hashCode => Object.hash(colorId, gem, preplaced);
}
8 changes: 8 additions & 0 deletions lib/models/game_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class GameState {
required this.combo,
required this.roundBestCombo,
this.allClears = 0,
this.clearFocus = false,
this.gemsCollected = const {},
});

Expand All @@ -33,6 +34,9 @@ class GameState {
/// All-clears earned this run (drives the end-of-run summary variants).
final int allClears;

/// Classic mode: bias tray generation hard toward clears/combos.
final bool clearFocus;

/// Gems collected so far this run (quest mode).
final Map<GemColor, int> gemsCollected;

Expand All @@ -44,6 +48,7 @@ class GameState {
int? combo,
int? roundBestCombo,
int? allClears,
bool? clearFocus,
Map<GemColor, int>? gemsCollected,
}) {
return GameState(
Expand All @@ -54,6 +59,7 @@ class GameState {
combo: combo ?? this.combo,
roundBestCombo: roundBestCombo ?? this.roundBestCombo,
allClears: allClears ?? this.allClears,
clearFocus: clearFocus ?? this.clearFocus,
gemsCollected: gemsCollected ?? this.gemsCollected,
);
}
Expand All @@ -68,6 +74,7 @@ class GameState {
'combo': combo,
'roundBestCombo': roundBestCombo,
'allClears': allClears,
if (clearFocus) 'clearFocus': true,
'gems': gemsCollected.map((color, count) => MapEntry(color.name, count)),
};

Expand All @@ -82,6 +89,7 @@ class GameState {
roundBestCombo: json['roundBestCombo'] as int,
// Tolerant default: saves written before this field existed.
allClears: json['allClears'] as int? ?? 0,
clearFocus: json['clearFocus'] as bool? ?? false,
gemsCollected: {
for (final entry in gems.entries)
GemColor.values.byName(entry.key): entry.value,
Expand Down
7 changes: 5 additions & 2 deletions lib/models/game_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ const Map<GemColor, Color> gemColors = {
GemColor.green: Color(0xFF66DD66),
};

/// Blocks that carry a gem render in gold regardless of theme palette.
const Color gemBlockGold = Color(0xFFE8B454);
/// Neutral light tile for a quest stage's pre-placed puzzle — both plain
/// pre-placed blocks and gem-carrying blocks use it, so the puzzle reads
/// as one light substrate with gems on top and the player's own vivid
/// pieces stand out against it.
const Color puzzleBlockLight = Color(0xFFE8D6A6);

/// Visual skin for the board and blocks. All rendering colors come from
/// here so new themes are pure data (a contribution target — never locked
Expand Down
3 changes: 2 additions & 1 deletion lib/state/classic_game_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ class ClassicGameController extends Notifier<GameState?> {
? _pinnedSeed
: DateTime.now().microsecondsSinceEpoch);
_seed = s;
final fresh = GameEngine.newGame(s);
// Classic leans into clears/combos (quest keeps its designed balance).
final fresh = GameEngine.newGame(s, clearFocus: true);
state = fresh;
ref.read(saveDataProvider.notifier).storeClassicRun(fresh, s);
}
Expand Down
20 changes: 20 additions & 0 deletions lib/systems/game_constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,25 @@ const double snugBoost = 3.0;
/// playable in sequence (and ideally contains a breaker) before settling.
const int traySetDrawAttempts = 6;

// --- Classic "clear focus" dealing -----------------------------------
// Classic mode leans hard into clears and combos: the whole point is the
// satisfaction of breaking lines, so deals should almost always enable a
// clear, and open boards should hand out big/long pieces to set up
// multi-line clears and all-clears.

/// Breaker weight multiplier in clear-focus mode (vs [breakerBoost]).
const double classicBreakerBoost = 9.0;

/// On a near-empty board, favor large pieces this strongly (weight ×
/// `1 + cellCount * classicBigPieceBias`) so rows fill fast.
const double classicBigPieceBias = 0.6;

/// Board fill fraction below which the big-piece bias applies.
const double classicOpenBoardFullness = 0.2;

/// Clear-focus draws more candidate sets and keeps the one whose pieces
/// can clear the most lines when played well.
const int classicDrawAttempts = 14;

/// Search budget for canPlaceAllInSomeOrder; past this, assume playable.
const int solvabilityNodeCap = 1500;
Loading
Loading