fix(boosters): count hammer/line-bomb clears toward objectives and win

Owner playtesting found that hammering a gem (or line-bombing a gem line)
removed it visually but did not satisfy the clear-gems objective and never
completed the stage — it felt broken. Per owner decision, booster clears
now count: a hammered gem emits gems:1, a line-bomb emits lines:1 + the
line's gem count, both folded through the objectives. After any booster the
engine resolves the phase (completed objective -> won, else re-check stuck),
which was the direct cause of the stage not finishing. Score and the move
counter remain untouched. Reverses the earlier no-objective-credit rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 20:15:00 +09:00
parent 1695684fc9
commit 42deeaf242
2 changed files with 119 additions and 9 deletions
+30 -9
View File
@@ -217,19 +217,22 @@ class GameEngine {
_phase = GamePhase.lost;
}
/// Booster: empties one filled cell. No move/score/combo/objective effect.
/// Allowed only mid-attempt (playing or stuck). Returns false on an empty
/// cell or finished attempt so the caller keeps the booster.
/// Booster: empties one filled cell. No move/score/combo effect, but a
/// hammered gem DOES count toward a clear-gems objective (and can win the
/// stage). Allowed only mid-attempt. Returns false on an empty cell or a
/// finished attempt so the caller keeps the booster.
bool useHammer(int x, int y) {
if (_phase == GamePhase.won || _phase == GamePhase.lost) return false;
if (!_grid.isOccupied(x, y)) return false;
final wasGem = _grid.cellAt(x, y).type == CellType.gem;
_grid = _grid.withCell(x, y, const Cell(CellType.empty));
_checkStuck();
_resolveAfterBooster(
wasGem ? const LinesCleared(lines: 0, gems: 1) : null);
return true;
}
/// Booster: re-deals the tray. No move/score effect. Re-checks stuck so a
/// dead board with a hopeless tray can become playable again.
/// Booster: re-deals the tray. No move/score/objective effect. Re-checks
/// stuck so a dead board with a hopeless tray can become playable again.
bool useShuffle() {
if (_phase == GamePhase.won || _phase == GamePhase.lost) return false;
_tray = _generator.nextTray(_grid);
@@ -237,17 +240,35 @@ class GameEngine {
return true;
}
/// Booster: empties one row or one column (exactly one of [row]/[col]).
/// No move/score/objective effect. Re-checks stuck.
/// Booster: empties one row or one column (exactly one of [row]/[col]). No
/// move/score effect, but it counts as clearing one line plus any gems on
/// that line, toward the stage objectives (and can win the stage).
bool useLineBomb({int? row, int? col}) {
if (_phase == GamePhase.won || _phase == GamePhase.lost) return false;
if ((row == null) == (col == null)) return false; // need exactly one
var gems = 0;
for (var i = 0; i < GridState.size; i++) {
final x = col ?? i;
final y = row ?? i;
if (_grid.cellAt(x, y).type == CellType.gem) gems++;
_grid = _grid.withCell(x, y, const Cell(CellType.empty));
}
_checkStuck();
_resolveAfterBooster(LinesCleared(lines: 1, gems: gems));
return true;
}
/// After a booster mutates the grid, feed any [cleared] event through the
/// objectives so booster-cleared gems/lines count, then resolve the phase:
/// a completed objective wins the stage, otherwise re-check stuck. Score and
/// the move counter stay untouched — boosters are help, not a placement.
void _resolveAfterBooster(LinesCleared? cleared) {
if (cleared != null) {
_objectives = [for (final obj in _objectives) obj.onEvent(cleared)];
}
if (!_stage.endless && _objectives.every((o) => o.isComplete)) {
_phase = GamePhase.won;
} else {
_checkStuck();
}
}
}