diff --git a/PROGRESS.md b/PROGRESS.md index d267417..b6d27df 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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. diff --git a/lib/game/block_puzzle_game.dart b/lib/game/block_puzzle_game.dart index 43e72f2..8976af5 100644 --- a/lib/game/block_puzzle_game.dart +++ b/lib/game/block_puzzle_game.dart @@ -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; @@ -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); @@ -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; } } diff --git a/lib/game/board_component.dart b/lib/game/board_component.dart index f9bac4b..0a74ad8 100644 --- a/lib/game/board_component.dart +++ b/lib/game/board_component.dart @@ -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. diff --git a/lib/game/effects.dart b/lib/game/effects.dart index 56e3ff8..e572e47 100644 --- a/lib/game/effects.dart +++ b/lib/game/effects.dart @@ -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. @@ -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> 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) { @@ -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), ), ); @@ -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), @@ -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)); + } +} diff --git a/lib/models/quest.dart b/lib/models/quest.dart index d75310d..c3d5cc3 100644 --- a/lib/models/quest.dart +++ b/lib/models/quest.dart @@ -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(); diff --git a/lib/state/quest_providers.dart b/lib/state/quest_providers.dart index 83587fe..354760a 100644 --- a/lib/state/quest_providers.dart +++ b/lib/state/quest_providers.dart @@ -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 { + @override + int? build() => null; + void set(int? value) => state = value; +} + +final questJustCompletedProvider = NotifierProvider( + QuestJustCompleted.new, +); + final questServiceProvider = Provider( (ref) => QuestService( prefs: ref.watch(sharedPreferencesProvider), diff --git a/lib/systems/game_engine.dart b/lib/systems/game_engine.dart index 5915309..f33fc55 100644 --- a/lib/systems/game_engine.dart +++ b/lib/systems/game_engine.dart @@ -36,10 +36,15 @@ class PlacementEvents { } class PlacementOutcome { - const PlacementOutcome(this.state, this.events); + const PlacementOutcome(this.state, this.events, {required this.stampedBoard}); final GameState state; final PlacementEvents events; + + /// The board right after the piece was stamped but BEFORE lines were + /// cleared — lets effects read the colors/gems of the cells about to + /// vanish (falling-block and gem-collect animations). + final Board stampedBoard; } /// Pure, deterministic rules engine: `place(state, ...)` is the only way a @@ -152,6 +157,7 @@ class GameEngine { gems: clear.gems, trayRefilled: trayRefilled, ), + stampedBoard: stamped, ); } diff --git a/lib/ui/classic_screen.dart b/lib/ui/classic_screen.dart index 8dee6cb..b902133 100644 --- a/lib/ui/classic_screen.dart +++ b/lib/ui/classic_screen.dart @@ -119,7 +119,13 @@ class _ClassicScreenState extends ConsumerState { ), const Spacer(), IconButton( - onPressed: () => showSettingsSheet(context), + onPressed: () => showSettingsSheet( + context, + onRestart: () { + _gameOverShown = false; + ref.read(classicGameProvider.notifier).startNew(); + }, + ), icon: const Icon(Icons.settings), color: Colors.white70, ), diff --git a/lib/ui/quest_map_screen.dart b/lib/ui/quest_map_screen.dart index 3b64cf6..17b2715 100644 --- a/lib/ui/quest_map_screen.dart +++ b/lib/ui/quest_map_screen.dart @@ -18,7 +18,8 @@ class QuestMapScreen extends ConsumerStatefulWidget { ConsumerState createState() => _QuestMapScreenState(); } -class _QuestMapScreenState extends ConsumerState { +class _QuestMapScreenState extends ConsumerState + with SingleTickerProviderStateMixin { final _scroll = ScrollController(); bool _jumped = false; @@ -28,12 +29,35 @@ class _QuestMapScreenState extends ConsumerState { static const _bottomPad = 28.0; static const _colFractions = [0.15, 0.38, 0.62, 0.85]; + // Advance-to-next-stage animation: the trail fills from the just-cleared + // node to the new current one, which then lights up. + AnimationController? _advance; + int? _animatedFrom; // level number being animated (guards re-trigger) + double _animLit = 0; + @override void dispose() { + _advance?.dispose(); _scroll.dispose(); super.dispose(); } + /// Kicks off the fill animation from [fromIndex] to [toIndex] once. + void _startAdvance(int fromIndex, int toIndex) { + _advance?.dispose(); + _advance = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + ) + ..addListener(() { + setState(() { + _animLit = fromIndex + (toIndex - fromIndex) * _advance!.value; + }); + }) + ..forward(); + } + Offset _nodeCenter(int index, double width, double totalHeight) { final row = index ~/ _perRow; final idx = index % _perRow; @@ -149,6 +173,31 @@ class _QuestMapScreenState extends ConsumerState { ); } + // Start the advance animation once when arriving + // from a just-completed stage. + final justDone = ref.watch(questJustCompletedProvider); + if (justDone != null && _animatedFrom != justDone) { + _animatedFrom = justDone; + final fromIndex = justDone - 1; // 0-based + final toIndex = currentIndex >= 0 + ? currentIndex + : levels.length; // all done → into the chest + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _startAdvance(fromIndex, toIndex); + ref + .read(questJustCompletedProvider.notifier) + .set(null); + }); + } + + final animating = _advance?.isAnimating ?? false; + final litUpTo = animating + ? _animLit + : (allDone + ? centers.length.toDouble() + : currentIndex.toDouble()); + return SingleChildScrollView( controller: _scroll, reverse: true, @@ -162,9 +211,7 @@ class _QuestMapScreenState extends ConsumerState { size: Size(width, totalHeight), painter: _TrailPainter( points: [...centers, chestCenter], - litUpTo: allDone - ? centers.length - : currentIndex, + litUpTo: litUpTo, ), ), for (final (i, node) in levels.indexed) @@ -173,7 +220,12 @@ class _QuestMapScreenState extends ConsumerState { top: centers[i].dy - 27, child: _NodeTile( node: node, - isCurrent: i == currentIndex, + // During the advance, the new current + // node only lights up once the trail + // fill reaches it. + isCurrent: + i == currentIndex && + (!animating || _animLit >= i - 0.12), onTap: i == currentIndex ? () => _play(context, node) : null, @@ -288,8 +340,9 @@ class _TrailPainter extends CustomPainter { final List points; - /// Number of leading points already reached (lit segment count). - final int litUpTo; + /// Leading points already reached (lit segment count). Fractional while + /// the advance-to-next-stage animation is running. + final double litUpTo; @override void paint(Canvas canvas, Size size) { diff --git a/lib/ui/quest_result_screen.dart b/lib/ui/quest_result_screen.dart index 025b034..4acadd1 100644 --- a/lib/ui/quest_result_screen.dart +++ b/lib/ui/quest_result_screen.dart @@ -3,13 +3,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/quest.dart'; import '../state/quest_game_controller.dart'; +import '../state/quest_providers.dart'; import 'quest_screen.dart'; /// Stage outcome. Win: "Level Complete!" + back to the map. Lose: the /// reference "So Close!" screen — progress toward the goal, Retry, back. /// Replaces the QuestScreen route; Retry pushes a fresh one. class QuestResultScreen extends ConsumerWidget { - const QuestResultScreen({super.key}); + const QuestResultScreen({super.key, this.praise}); + + /// Praise carried over from the winning combo (shown under the title). + final String? praise; @override Widget build(BuildContext context, WidgetRef ref) { @@ -47,6 +51,10 @@ class QuestResultScreen extends ConsumerWidget { shadows: [Shadow(color: Colors.black26, blurRadius: 8)], ), ), + if (won && praise != null) ...[ + const SizedBox(height: 12), + _AnimatedPraise(text: praise!), + ], const Spacer(), if (won) const Icon( @@ -116,6 +124,11 @@ class QuestResultScreen extends ConsumerWidget { } void _backToMap(BuildContext context, WidgetRef ref) { + final run = ref.read(questGameProvider); + if (run != null && run.status == QuestStatus.won) { + // Tell the map to animate the advance to the next stage. + ref.read(questJustCompletedProvider.notifier).set(run.levelNumber); + } ref.read(questGameProvider.notifier).quit(); Navigator.pop(context); } @@ -170,3 +183,48 @@ class _ProgressRecap extends StatelessWidget { ); } } + +/// Praise text that pops in with a scale+fade (matches the in-game popup +/// feel) rather than appearing as plain text. +class _AnimatedPraise extends StatefulWidget { + const _AnimatedPraise({required this.text}); + + final String text; + + @override + State<_AnimatedPraise> createState() => _AnimatedPraiseState(); +} + +class _AnimatedPraiseState extends State<_AnimatedPraise> + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + )..forward(); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scale = CurvedAnimation(parent: _c, curve: Curves.easeOutBack); + return ScaleTransition( + scale: scale, + child: FadeTransition( + opacity: _c, + child: Text( + widget.text, + style: const TextStyle( + fontSize: 30, + fontWeight: FontWeight.w900, + color: Color(0xFFFFE082), + shadows: [Shadow(color: Colors.black38, blurRadius: 8)], + ), + ), + ), + ); + } +} diff --git a/lib/ui/quest_screen.dart b/lib/ui/quest_screen.dart index 4cbc3ca..e437839 100644 --- a/lib/ui/quest_screen.dart +++ b/lib/ui/quest_screen.dart @@ -33,9 +33,22 @@ class _QuestScreenState extends ConsumerState { String? _bannerText; bool _bannerVisible = false; + // Centered goal banner shown at stage start. + bool _goalVisible = false; + + void _showGoalBanner() { + setState(() => _goalVisible = true); + Timer(const Duration(milliseconds: 2000), () { + if (mounted) setState(() => _goalVisible = false); + }); + } + @override void initState() { super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _showGoalBanner(); + }); final controller = ref.read(questGameProvider.notifier); _game = BlockPuzzleGame( theme: ref.read(themeProvider), @@ -82,12 +95,13 @@ class _QuestScreenState extends ConsumerState { } else { ref.read(audioProvider).runEnded(); } + final praise = _game.lastPraise; // Let the final clear's effects play out first. Future.delayed(const Duration(milliseconds: 900), () { if (!mounted) return; Navigator.pushReplacement( context, - MaterialPageRoute(builder: (_) => const QuestResultScreen()), + MaterialPageRoute(builder: (_) => QuestResultScreen(praise: praise)), ); }); } @@ -141,7 +155,15 @@ class _QuestScreenState extends ConsumerState { ), ), IconButton( - onPressed: () => showSettingsSheet(context), + onPressed: () => showSettingsSheet( + context, + onRestart: () { + _resultShown = false; + _shownBanners.clear(); + ref.read(questGameProvider.notifier).retry(); + _showGoalBanner(); + }, + ), icon: const Icon(Icons.settings), color: Colors.white70, ), @@ -187,6 +209,57 @@ class _QuestScreenState extends ConsumerState { ), ), ), + // Stage goal, centered, at the start of the stage. + Center( + child: IgnorePointer( + child: AnimatedScale( + scale: _goalVisible ? 1 : 0.6, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutBack, + child: AnimatedOpacity( + opacity: _goalVisible ? 1 : 0, + duration: const Duration(milliseconds: 300), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 18, + ), + decoration: BoxDecoration( + color: const Color(0xE6202C54), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: const Color(0xFFF2C94C), + width: 2, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'GOAL', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: Color(0xFFF2C94C), + letterSpacing: 3, + ), + ), + const SizedBox(height: 6), + Text( + goalDescription(run.stage.goal), + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ), + ), ], ), ), diff --git a/lib/ui/settings_sheet.dart b/lib/ui/settings_sheet.dart index c47a7fd..dc6bc61 100644 --- a/lib/ui/settings_sheet.dart +++ b/lib/ui/settings_sheet.dart @@ -3,21 +3,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../state/providers.dart'; -/// Sound / haptics toggles, reset progress, and about+licenses. Theme +/// Sound / haptics toggles, restart, reset progress, and about+licenses. +/// [onRestart] (when in a game) adds a "Restart round" action. Theme /// picker appears here once a second theme ships (M4 seam). -void showSettingsSheet(BuildContext context) { +void showSettingsSheet(BuildContext context, {VoidCallback? onRestart}) { showModalBottomSheet( context: context, backgroundColor: const Color(0xFF2C3A6B), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (_) => const _SettingsSheet(), + builder: (_) => _SettingsSheet(onRestart: onRestart), ); } class _SettingsSheet extends ConsumerWidget { - const _SettingsSheet(); + const _SettingsSheet({this.onRestart}); + + final VoidCallback? onRestart; @override Widget build(BuildContext context, WidgetRef ref) { @@ -33,6 +36,16 @@ class _SettingsSheet extends ConsumerWidget { 'Settings', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800), ), + if (onRestart != null) + ListTile( + leading: const Icon(Icons.refresh_rounded), + title: const Text('Restart round'), + subtitle: const Text('Start this round over'), + onTap: () { + Navigator.pop(context); + onRestart!(); + }, + ), SwitchListTile( title: const Text('Sound'), secondary: const Icon(Icons.volume_up_rounded), diff --git a/test/game_engine_test.dart b/test/game_engine_test.dart index 6777b9f..53a8908 100644 --- a/test/game_engine_test.dart +++ b/test/game_engine_test.dart @@ -146,6 +146,11 @@ void main() { expect(outcome.events.allClear, isTrue); expect(outcome.state.board.isEmpty, isTrue); expect(outcome.events.scoreDelta, 1 + 10 + allClearBonus); + // stampedBoard is the board BEFORE clearing (for fall/gem effects): + // the row is full there even though the final board is empty. + for (var c = 0; c < 8; c++) { + expect(outcome.stampedBoard.isOccupied(0, c), isTrue); + } }); test('tray refills after the third placement', () { diff --git a/test/quest_parser_test.dart b/test/quest_parser_test.dart index 4e7c8fe..f506272 100644 --- a/test/quest_parser_test.dart +++ b/test/quest_parser_test.dart @@ -245,4 +245,14 @@ void main() { ); }); }); + + group('goalDescription', () { + test('describes score and gem goals', () { + expect(goalDescription(const ScoreGoal(700)), contains('700')); + expect( + goalDescription(const GemsGoal({GemColor.red: 2})), + contains('gems'), + ); + }); + }); }