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
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-16 — Playtest round 5: 7 polish items
- Ghost dial-back: nearest-legal snap capped to a 1-cell radius (catches
grid-line straddling, no longer teleports onto far gaps; over occupied
blocks it bounces).
- Would-clear line lights up in the dragged piece's color with a bright
glow (was a small yellow tint) — matches the reference.
- Falling-block + gem effects: cleared cells drop a short distance and
fade; gem cells instead fly up toward the goal counters. Uses the new
PlacementOutcome.stampedBoard (pre-clear colors).
- Random tiered combo praise ("Good!" → "Legendary!"), shown above the
action; the winning praise is reused (animated) on Level Complete.
- Quest goal banner: centered, animated, at stage start.
- Restart round: a Settings action (classic restarts the run, quest
retries the stage) — classic runs are otherwise persistent.
- Map advance animation: on Continue, the trail fills from the cleared
node to the next, which then lights up (questJustCompletedProvider +
fractional _TrailPainter.litUpTo + AnimationController).

### 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.
Expand Down
25 changes: 19 additions & 6 deletions lib/game/block_puzzle_game.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ class BlockPuzzleGame extends FlameGame {
late BoardGeometry geometry;
DragPreview? preview;

/// Last praise string shown by the effects (reused on Level Complete).
String? lastPraise;

// Screen shake, decayed in update(); board rendering reads it.
double _shakeTime = 0;
double _shakeDuration = 0;
Expand Down Expand Up @@ -216,18 +219,26 @@ class BlockPuzzleGame extends FlameGame {
);
}

/// Closest legal placement to the raw snap (Euclidean in cell space),
/// or null if the piece fits nowhere.
/// How far (in cells) the snap may forgive an off-target hover. Just
/// enough to catch a piece straddling grid lines over an empty area —
/// NOT enough to teleport onto a distant gap when the hover is squarely
/// over occupied blocks (that should bounce).
static const _snapRadius = 1;

/// Closest legal placement within [_snapRadius] of the raw snap, or null.
(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++) {
for (var dr = -_snapRadius; dr <= _snapRadius; dr++) {
for (var dc = -_snapRadius; dc <= _snapRadius; dc++) {
final r = rawRow + dr;
final c = rawCol + dc;
if (r < 0 || c < 0 || r > maxRow || c > maxCol) continue;
if (!canPlace(state.board, piece, r, c)) continue;
final d = (r - rawRow) * (r - rawRow) + (c - rawCol) * (c - rawCol);
final d = dr * dr + dc * dc;
if (d < bestDist) {
bestDist = d;
best = (r, c);
Expand All @@ -245,7 +256,9 @@ class BlockPuzzleGame extends FlameGame {
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);
if (outcome != null) {
spawnPlacementEffects(this, outcome.events, outcome.stampedBoard);
}
return outcome;
}
}
53 changes: 40 additions & 13 deletions lib/game/board_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,28 +132,55 @@ class BoardComponent extends PositionComponent
return;
}

// Would-clear lines glow across their full length.
final glow = Paint()..color = theme.lineHighlight;
// A line that would clear lights up entirely in the dragged piece's
// color and glows — a strong "this whole line breaks" signal.
final pieceColor = game.theme.blockColor(preview.piece.colorId);
for (final row in preview.wouldClearRows) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, row * cell, size.x, cell).deflate(cell * 0.06),
Radius.circular(cell * 0.12),
),
glow,
_paintClearingLine(
canvas,
Rect.fromLTWH(0, row * cell, size.x, cell),
pieceColor,
cell,
);
}
for (final col in preview.wouldClearCols) {
_paintClearingLine(
canvas,
Rect.fromLTWH(col * cell, 0, cell, size.y),
pieceColor,
cell,
);
}

_renderPreviewGhost(canvas, preview, cell);
}

/// Fills a would-clear line with the piece color and a bright glow so
/// the whole line reads as "about to break" (reference behavior).
void _paintClearingLine(Canvas canvas, Rect line, Color color, double cell) {
// Fill each cell of the line in the piece color (beveled block look).
if (line.width > line.height) {
for (var x = line.left; x < line.right - 1; x += cell) {
paintBlock(canvas, Rect.fromLTWH(x, line.top, cell, cell), color);
}
} else {
for (var y = line.top; y < line.bottom - 1; y += cell) {
paintBlock(canvas, Rect.fromLTWH(line.left, y, cell, cell), color);
}
}
// Bright glow: layered translucent strokes (no blur — perf).
for (var i = 0; i < 3; i++) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(col * cell, 0, cell, size.y).deflate(cell * 0.06),
Radius.circular(cell * 0.12),
line.inflate(i * 3.0).deflate(cell * 0.04),
Radius.circular(cell * 0.2),
),
glow,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 5 - i * 1.3
..color = const Color(0xFFFFFFFF).withValues(alpha: 0.55 - i * 0.16),
);
}

_renderPreviewGhost(canvas, preview, cell);
}

/// Eight-point star, the quest gem sitting inside its gold block.
Expand Down
169 changes: 158 additions & 11 deletions lib/game/effects.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import 'package:flame/particles.dart';
import 'package:flame/text.dart';
import 'package:flutter/animation.dart' show Curves;

import '../models/board.dart';
import '../models/cell.dart';
import '../models/game_theme.dart';
import '../systems/game_constants.dart';
import '../systems/game_engine.dart';
import 'block_painter.dart';
import 'block_puzzle_game.dart';

/// Visual-only tuning for juice; rule constants stay in game_constants.
Expand All @@ -18,23 +22,70 @@ const double _popupSeconds = 0.9;

final _rng = Random();

/// Praise ladder, scaled to the moment (reference: "Good!" → "Unbelievable!").
/// Random praise, in tiers of rising excitement — a clear picks a message
/// from the tier matching how big the moment was. The chosen string is
/// also reused on the quest Level Complete screen.
const List<List<String>> praiseTiers = [
['Good!', 'Nice!', 'Good job!', 'Sweet!'],
['Great!', 'Well done!', 'Awesome!', 'Cool!'],
['Fantastic!', 'Amazing!', 'Brilliant!', 'Superb!'],
['Unbelievable!', 'Incredible!', 'The Best!', 'Legendary!'],
];

int _praiseTier(PlacementEvents e) {
if (e.allClear || e.combo >= 10 || e.linesCleared >= 3) return 3;
if (e.combo >= 6 || e.linesCleared == 2) return 2;
if (e.combo >= 3) return 1;
return 0;
}

/// A random praise for the moment, or null if nothing cleared.
String? praiseFor(PlacementEvents e) {
if (e.linesCleared == 0) return null;
if (e.linesCleared >= 3 || e.combo >= 10) return 'Unbelievable!';
if (e.linesCleared == 2 || e.combo >= 6) return 'Amazing!';
if (e.combo >= 4) return 'Great!';
if (e.combo >= 2) return 'Good!';
return null;
final tier = praiseTiers[_praiseTier(e)];
return tier[_rng.nextInt(tier.length)];
}

/// Spawns everything a placement earned: line sweeps, per-cell bursts,
/// screen shake, praise + combo popups, and the all-clear celebration.
void spawnPlacementEffects(BlockPuzzleGame game, PlacementEvents events) {
/// Spawns everything a placement earned: falling cleared blocks, gem
/// collect fly-ups, line sweeps, screen shake, praise + combo popups, and
/// the all-clear celebration. [stamped] is the board just before the
/// clear, so the vanishing cells' colors/gems are known.
void spawnPlacementEffects(
BlockPuzzleGame game,
PlacementEvents events,
Board stamped,
) {
if (events.linesCleared == 0) return;
final geo = game.geometry;
final board = geo.boardRect;

// Every cleared cell drops a short distance and fades; gem cells instead
// fly up (toward the goal counters).
final clearedCells = <(int, int)>{};
for (final row in events.clearedRows) {
for (var c = 0; c < boardSize; c++) {
clearedCells.add((row, c));
}
}
for (final col in events.clearedCols) {
for (var r = 0; r < boardSize; r++) {
clearedCells.add((r, col));
}
}
for (final (r, c) in clearedCells) {
final occupant = stamped.at(r, c);
if (occupant == null) continue;
final origin = geo.cellTopLeft(r, c);
if (occupant.gem != null) {
_gemFlyUp(game, origin, occupant.gem!);
} else {
final color = occupant.preplaced
? puzzleBlockLight
: game.theme.blockColor(occupant.colorId);
_fallingBlock(game, origin, color);
}
}

// Shake scales with the moment: any multi-line or combo rattles the
// board; an all-clear slams it.
if (events.allClear) {
Expand Down Expand Up @@ -80,11 +131,13 @@ void spawnPlacementEffects(BlockPuzzleGame game, PlacementEvents events) {
final center = Vector2(board.center.dx, board.center.dy);
final praise = praiseFor(events);
if (praise != null) {
game.lastPraise = praise; // reused on the quest Level Complete screen
game.add(
_PopupText(
praise,
at: center - Vector2(0, geo.cell),
fontSize: 40,
// Above the action, near the top of the board.
at: Vector2(board.center.dx, board.top + geo.cell * 1.4),
fontSize: 42,
color: const Color(0xFFFFFFFF),
),
);
Expand Down Expand Up @@ -114,6 +167,25 @@ void spawnPlacementEffects(BlockPuzzleGame game, PlacementEvents events) {
}
}

/// A cleared block drops a short distance and fades (not all the way
/// down — just enough to read as "knocked loose").
void _fallingBlock(BlockPuzzleGame game, Offset origin, Color color) {
game.add(
_FallingBlock(origin: origin, size: game.geometry.cell, color: color),
);
}

/// A collected gem floats up toward the goal counters and fades.
void _gemFlyUp(BlockPuzzleGame game, Offset origin, GemColor gem) {
game.add(
_FlyingGem(
origin: origin,
cell: game.geometry.cell,
color: gemColors[gem]!,
),
);
}

void _sweep(BlockPuzzleGame game, Rect rect) {
final sweep = RectangleComponent(
position: Vector2(rect.left, rect.top),
Expand Down Expand Up @@ -255,3 +327,78 @@ class _PopupText extends TextComponent {
]);
}
}

/// A cleared block: renders the block falling a short distance while
/// fading out. Self-removes when done.
class _FallingBlock extends PositionComponent {
_FallingBlock({
required Offset origin,
required double size,
required this.color,
}) : _cell = size,
super(position: Vector2(origin.dx, origin.dy), priority: 42);

final double _cell;
final Color color;

static const _life = 0.45;
double _t = 0;

@override
void update(double dt) {
_t += dt;
// Ease-in fall (gravity feel), a little over one cell.
position.y += (_cell * 3.2) * _t * dt / _life;
if (_t >= _life) removeFromParent();
}

@override
void render(Canvas canvas) {
final alpha = (1 - _t / _life).clamp(0.0, 1.0);
paintBlock(
canvas,
Rect.fromLTWH(0, 0, _cell, _cell),
color,
opacity: alpha,
);
}
}

/// A collected gem: an 8-point star drifting up toward the goal counters,
/// shrinking and fading.
class _FlyingGem extends PositionComponent {
_FlyingGem({required Offset origin, required this.cell, required this.color})
: super(
position: Vector2(origin.dx + cell / 2, origin.dy + cell / 2),
priority: 55,
);

final double cell;
final Color color;

static const _life = 0.7;
double _t = 0;

@override
void update(double dt) {
_t += dt;
position.y -= cell * 4 * dt; // rise toward the counters above
if (_t >= _life) removeFromParent();
}

@override
void render(Canvas canvas) {
final progress = _t / _life;
final alpha = (1 - progress).clamp(0.0, 1.0);
final r = cell * 0.3 * (1 - 0.4 * progress);
final path = Path();
for (var i = 0; i < 16; i++) {
final radius = i.isEven ? r : r * 0.45;
final angle = i * pi / 8 - pi / 2;
final p = Offset(radius * cos(angle), radius * sin(angle));
i == 0 ? path.moveTo(p.dx, p.dy) : path.lineTo(p.dx, p.dy);
}
path.close();
canvas.drawPath(path, Paint()..color = color.withValues(alpha: alpha));
}
}
6 changes: 6 additions & 0 deletions lib/models/quest.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ class QuestManifest {
}
}

/// Short human description of a goal, for the stage-start banner.
String goalDescription(QuestGoal goal) => switch (goal) {
ScoreGoal(target: final t) => 'Reach $t points',
GemsGoal() => 'Collect the gems',
};

sealed class QuestGoal {
const QuestGoal();

Expand Down
13 changes: 13 additions & 0 deletions lib/state/quest_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ import '../services/quest_config.dart';
import '../services/quest_service.dart';
import 'providers.dart';

/// The level number (1-based) just completed, set on the Level Complete
/// screen and consumed once by the map to animate the advance to the next
/// stage. Null when there's nothing to animate.
class QuestJustCompleted extends Notifier<int?> {
@override
int? build() => null;
void set(int? value) => state = value;
}

final questJustCompletedProvider = NotifierProvider<QuestJustCompleted, int?>(
QuestJustCompleted.new,
);

final questServiceProvider = Provider<QuestService>(
(ref) => QuestService(
prefs: ref.watch(sharedPreferencesProvider),
Expand Down
Loading
Loading