From 3138fc4b085d282a87c808b09647495ecee5f85e Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 11 Jun 2026 13:19:34 +0900 Subject: [PATCH] Add playable core UI: board painter, drag-and-drop, HUD, result overlay CustomPainter board with gems/ghost/clear-flash, finger-lifted drag with snap preview, combo text effect, HUD chips, phase overlays with rescue stubs, demo stage. E2E widget test drives a real drag gesture. Co-Authored-By: Claude Fable 5 --- lib/game/engine/game_engine.dart | 13 + lib/l10n/app_en.arb | 10 +- lib/l10n/app_ko.arb | 10 +- lib/state/game_session_notifier.dart | 120 +++++++++ lib/state/providers.dart | 8 + lib/ui/screens/game_screen.dart | 281 ++++++++++++++++++++- lib/ui/theme/palette.dart | 26 ++ lib/ui/widgets/board_geometry.dart | 41 +++ lib/ui/widgets/board_painter.dart | 137 ++++++++++ lib/ui/widgets/board_widget.dart | 95 +++++++ lib/ui/widgets/hud_widget.dart | 83 ++++++ lib/ui/widgets/piece_painter.dart | 82 ++++++ lib/ui/widgets/tray_widget.dart | 59 +++++ test/game/engine/game_engine_test.dart | 2 + test/state/game_session_notifier_test.dart | 127 ++++++++++ test/ui/board_geometry_test.dart | 46 ++++ test/ui/game_screen_test.dart | 116 +++++++++ 17 files changed, 1249 insertions(+), 7 deletions(-) create mode 100644 lib/state/game_session_notifier.dart create mode 100644 lib/state/providers.dart create mode 100644 lib/ui/theme/palette.dart create mode 100644 lib/ui/widgets/board_geometry.dart create mode 100644 lib/ui/widgets/board_painter.dart create mode 100644 lib/ui/widgets/board_widget.dart create mode 100644 lib/ui/widgets/hud_widget.dart create mode 100644 lib/ui/widgets/piece_painter.dart create mode 100644 lib/ui/widgets/tray_widget.dart create mode 100644 test/state/game_session_notifier_test.dart create mode 100644 test/ui/board_geometry_test.dart create mode 100644 test/ui/game_screen_test.dart diff --git a/lib/game/engine/game_engine.dart b/lib/game/engine/game_engine.dart index a0466ba..678c80f 100644 --- a/lib/game/engine/game_engine.dart +++ b/lib/game/engine/game_engine.dart @@ -20,12 +20,22 @@ class PlacementResult { required this.pointsGained, required this.linesCleared, required this.gemsCleared, + required this.clearedRows, + required this.clearedCols, + required this.comboStreak, }); final List events; final int pointsGained; final int linesCleared; final int gemsCleared; + + /// Which lines vanished, for the UI clear animation. + final List clearedRows; + final List clearedCols; + + /// Streak level after this placement, for combo effects. + final int comboStreak; } /// The single stateful pure-Dart session object for one stage attempt. @@ -130,6 +140,9 @@ class GameEngine { pointsGained: delta.points, linesCleared: clear.linesCleared, gemsCleared: clear.gemsCleared, + clearedRows: clear.clearedRows, + clearedCols: clear.clearedCols, + comboStreak: _combo.streak, ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9e77dcf..9368c9d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3,5 +3,13 @@ "appTitle": "Block Seasons", "play": "Play", "settings": "Settings", - "comingSoon": "Coming soon" + "comingSoon": "Coming soon", + "stageClear": "Stage Clear!", + "stageFailed": "Stage Failed", + "outOfMoves": "Out of moves", + "boardFull": "No space left", + "watchAdContinue": "Continue (ad)", + "plusFiveMoves": "+5 moves (ad)", + "giveUp": "Give up", + "playAgain": "Play again" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5dc5aa0..4f55308 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -3,5 +3,13 @@ "appTitle": "블록 시즌즈", "play": "플레이", "settings": "설정", - "comingSoon": "준비 중" + "comingSoon": "준비 중", + "stageClear": "스테이지 클리어!", + "stageFailed": "스테이지 실패", + "outOfMoves": "이동 횟수 소진", + "boardFull": "둘 공간이 없어요", + "watchAdContinue": "광고 보고 이어하기", + "plusFiveMoves": "+5 이동 (광고)", + "giveUp": "포기하기", + "playAgain": "다시 하기" } diff --git a/lib/state/game_session_notifier.dart b/lib/state/game_session_notifier.dart new file mode 100644 index 0000000..ff8dde5 --- /dev/null +++ b/lib/state/game_session_notifier.dart @@ -0,0 +1,120 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../game/engine/game_engine.dart'; +import '../game/engine/piece_generator.dart'; +import '../game/models/grid.dart'; +import '../game/models/objective.dart'; +import '../game/models/piece.dart'; +import '../game/models/stage.dart'; + +/// Immutable snapshot of one engine moment; the only game state the UI sees. +class GameViewState { + const GameViewState({ + required this.grid, + required this.tray, + required this.score, + required this.comboStreak, + required this.movesLeft, + required this.moveLimit, + required this.phase, + required this.stuckReason, + required this.objectives, + required this.starsEarned, + required this.objectiveProgress, + required this.lastPlacement, + required this.fxTick, + }); + + final GridState grid; + final List tray; + final int score; + final int comboStreak; + final int movesLeft; + final int moveLimit; + final GamePhase phase; + final StuckReason? stuckReason; + final List objectives; + final int starsEarned; + final double objectiveProgress; + final PlacementResult? lastPlacement; + + /// Increments on every accepted placement so animations can retrigger. + final int fxTick; +} + +/// Bridges the pure-Dart [GameEngine] to the widget tree. The engine object +/// never leaks past this class. +class GameSessionNotifier extends Notifier { + GameEngine? _engine; + StageConfig? _stage; + PieceGenerator? _generatorOverride; + int _attempt = 0; + int _fxTick = 0; + + @override + GameViewState? build() => null; + + void startStage(StageConfig stage, + {int attempt = 0, PieceGenerator? generator}) { + _stage = stage; + _attempt = attempt; + _generatorOverride = generator; + _engine = GameEngine(stage, attempt: attempt, generator: generator); + _fxTick = 0; + _publish(lastPlacement: null); + } + + /// Restarts the current stage as a new attempt (fresh piece sequence). + void restart() { + final stage = _stage; + if (stage == null) throw StateError('no stage to restart'); + startStage(stage, attempt: _attempt + 1, generator: _generatorOverride); + } + + bool tryPlace(int trayIndex, int x, int y) { + final engine = _engine; + if (engine == null) return false; + final result = engine.tryPlace(trayIndex, x, y); + if (result == null) return false; + _fxTick++; + _publish(lastPlacement: result); + return true; + } + + bool canPlaceAt(int trayIndex, int x, int y) => + _engine?.tryPlaceWouldSucceed(trayIndex, x, y) ?? false; + + void useContinue() { + _engine?.useContinue(); + _publish(lastPlacement: null); + } + + void addExtraMoves() { + _engine?.addExtraMoves(); + _publish(lastPlacement: null); + } + + void declineAndLose() { + _engine?.declineAndLose(); + _publish(lastPlacement: null); + } + + void _publish({required PlacementResult? lastPlacement}) { + final engine = _engine!; + state = GameViewState( + grid: engine.grid, + tray: engine.tray, + score: engine.score, + comboStreak: engine.combo.streak, + movesLeft: engine.movesLeft, + moveLimit: engine.movesLeft + engine.movesUsed, + phase: engine.phase, + stuckReason: engine.stuckReason, + objectives: engine.objectives, + starsEarned: engine.starsEarned, + objectiveProgress: engine.objectiveProgress, + lastPlacement: lastPlacement, + fxTick: _fxTick, + ); + } +} diff --git a/lib/state/providers.dart b/lib/state/providers.dart new file mode 100644 index 0000000..ff2e4aa --- /dev/null +++ b/lib/state/providers.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'game_session_notifier.dart'; + +final gameSessionProvider = + NotifierProvider( + GameSessionNotifier.new, +); diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index fc1b9ee..9ef9dd5 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -1,17 +1,288 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../game/engine/game_engine.dart'; +import '../../game/models/stage.dart'; import '../../l10n/gen/app_localizations.dart'; +import '../../state/game_session_notifier.dart'; +import '../../state/providers.dart'; +import '../theme/palette.dart'; +import '../widgets/board_geometry.dart'; +import '../widgets/board_painter.dart'; +import '../widgets/board_widget.dart'; +import '../widgets/hud_widget.dart'; +import '../widgets/piece_painter.dart'; +import '../widgets/tray_widget.dart'; -/// Placeholder until the board UI lands in Phase 2. -class GameScreen extends StatelessWidget { +/// Demo stage until the season map lands in Phase 3. +final _demoStage = StageConfig.fromJson({ + 'id': 'demo_001', + 'seed': 20260611, + 'moveLimit': 25, + 'preset': [ + {'x': 2, 'y': 2, 't': 'gem'}, + {'x': 5, 'y': 2, 't': 'gem'}, + {'x': 2, 'y': 5, 't': 'gem'}, + {'x': 5, 'y': 5, 't': 'gem'}, + {'x': 3, 'y': 7, 't': 'filled', 'c': 1}, + {'x': 4, 'y': 7, 't': 'filled', 'c': 1}, + ], + 'objectives': [ + {'type': 'clearGems', 'count': 4}, + ], + 'stars': { + 'two': {'movesLeft': 6}, + 'three': {'movesLeft': 12}, + }, + 'generatorProfile': 'mid', +}); + +class GameScreen extends ConsumerStatefulWidget { const GameScreen({super.key}); + @override + ConsumerState createState() => _GameScreenState(); +} + +class _GameScreenState extends ConsumerState { + final _boardKey = GlobalKey(); + final _stackKey = GlobalKey(); + + int? _dragIndex; + Offset? _dragGlobal; + + /// How far the dragged piece floats above the finger so it stays visible. + static const double _lift = 70; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (ref.read(gameSessionProvider) == null) { + ref.read(gameSessionProvider.notifier).startStage(_demoStage); + } + }); + } + + RenderBox? get _boardBox => + _boardKey.currentContext?.findRenderObject() as RenderBox?; + + /// Global top-left of the dragged piece, rendered at board cell size. + Offset? _draggedPieceTopLeft(GameViewState view) { + final i = _dragIndex; + final g = _dragGlobal; + final box = _boardBox; + if (i == null || g == null || box == null || i >= view.tray.length) { + return null; + } + final geo = BoardGeometry(boardSize: box.size.width); + final piece = view.tray[i]; + final (w, h) = pieceCellBounds(piece); + final pw = w * geo.cellSize; + final ph = h * geo.cellSize; + return g + Offset(-pw / 2, -_lift - ph); + } + + GhostSpec? _ghost(GameViewState view) { + final i = _dragIndex; + final box = _boardBox; + final topLeftGlobal = _draggedPieceTopLeft(view); + if (i == null || box == null || topLeftGlobal == null) return null; + + final geo = BoardGeometry(boardSize: box.size.width); + final piece = view.tray[i]; + final (w, h) = pieceCellBounds(piece); + final centerLocal = box.globalToLocal( + topLeftGlobal + Offset(w * geo.cellSize / 2, h * geo.cellSize / 2), + ); + // Only preview while the piece is actually over the board. + if (centerLocal.dx < -geo.cellSize || + centerLocal.dy < -geo.cellSize || + centerLocal.dx > box.size.width + geo.cellSize || + centerLocal.dy > box.size.height + geo.cellSize) { + return null; + } + + final topLeftLocal = box.globalToLocal(topLeftGlobal); + final (ax, ay) = geo.snapAnchor(piece, topLeftLocal); + final legal = + ref.read(gameSessionProvider.notifier).canPlaceAt(i, ax, ay); + return GhostSpec(piece: piece, anchorX: ax, anchorY: ay, legal: legal); + } + + void _onDragEnd(GameViewState view) { + final i = _dragIndex; + final ghost = _ghost(view); + setState(() { + _dragIndex = null; + _dragGlobal = null; + }); + if (i != null && ghost != null && ghost.legal) { + ref + .read(gameSessionProvider.notifier) + .tryPlace(i, ghost.anchorX, ghost.anchorY); + } + } + @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; + final view = ref.watch(gameSessionProvider); + if (view == null) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final ghost = _ghost(view); + final draggedTopLeft = _draggedPieceTopLeft(view); + final boardBox = _boardBox; + return Scaffold( - appBar: AppBar(title: Text(l10n.appTitle)), - body: Center(child: Text(l10n.comingSoon)), + body: SafeArea( + child: Stack( + key: _stackKey, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + HudWidget(view: view), + Expanded( + child: Center( + child: BoardWidget( + key: _boardKey, + view: view, + ghost: ghost, + ), + ), + ), + TrayWidget( + tray: view.tray, + draggingIndex: _dragIndex, + onDragStart: (index, global) => setState(() { + _dragIndex = index; + _dragGlobal = global; + }), + onDragUpdate: (global) => + setState(() => _dragGlobal = global), + onDragEnd: () => _onDragEnd(view), + ), + ], + ), + ), + if (_dragIndex != null && + draggedTopLeft != null && + boardBox != null && + _dragIndex! < view.tray.length) + _draggedPieceOverlay(view, draggedTopLeft, boardBox), + if (view.phase != GamePhase.playing) _resultOverlay(view), + ], + ), + ), + ); + } + + Widget _draggedPieceOverlay( + GameViewState view, Offset topLeftGlobal, RenderBox boardBox) { + final stackBox = + _stackKey.currentContext!.findRenderObject()! as RenderBox; + final local = stackBox.globalToLocal(topLeftGlobal); + final cellSize = boardBox.size.width / 8; + return Positioned( + left: local.dx, + top: local.dy, + child: IgnorePointer( + child: PieceWidget(piece: view.tray[_dragIndex!], cellSize: cellSize), + ), + ); + } + + Widget _resultOverlay(GameViewState view) { + final l10n = AppLocalizations.of(context)!; + final notifier = ref.read(gameSessionProvider.notifier); + final theme = Theme.of(context); + + final (title, actions) = switch ((view.phase, view.stuckReason)) { + (GamePhase.won, _) => ( + l10n.stageClear, + [ + FilledButton( + onPressed: notifier.restart, + child: Text(l10n.playAgain), + ), + ], + ), + (GamePhase.stuck, StuckReason.outOfMoves) => ( + l10n.outOfMoves, + [ + FilledButton( + onPressed: notifier.addExtraMoves, + child: Text(l10n.plusFiveMoves), + ), + TextButton( + onPressed: notifier.declineAndLose, + child: Text(l10n.giveUp), + ), + ], + ), + (GamePhase.stuck, _) => ( + l10n.boardFull, + [ + FilledButton( + onPressed: notifier.useContinue, + child: Text(l10n.watchAdContinue), + ), + TextButton( + onPressed: notifier.declineAndLose, + child: Text(l10n.giveUp), + ), + ], + ), + (_, _) => ( + l10n.stageFailed, + [ + FilledButton( + onPressed: notifier.restart, + child: Text(l10n.playAgain), + ), + ], + ), + }; + + return Positioned.fill( + child: ColoredBox( + color: Colors.black54, + child: Center( + child: Card( + color: GamePalette.boardBackground, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: theme.textTheme.headlineSmall), + if (view.phase == GamePhase.won) ...[ + const SizedBox(height: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < 3; i++) + Icon( + Icons.star, + size: 40, + color: i < view.starsEarned + ? Colors.amber + : Colors.white24, + ), + ], + ), + ], + const SizedBox(height: 20), + ...actions, + ], + ), + ), + ), + ), + ), ); } } diff --git a/lib/ui/theme/palette.dart b/lib/ui/theme/palette.dart new file mode 100644 index 0000000..45ea3f3 --- /dev/null +++ b/lib/ui/theme/palette.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +/// Season-themeable color set. Season 1 default: vivid candy tones on a +/// deep navy board. +class GamePalette { + static const boardBackground = Color(0xFF151A2E); + static const emptyCell = Color(0xFF222A45); + static const boardBorder = Color(0xFF2E3858); + + static const tileColors = [ + Color(0xFF5B7FFF), // blue + Color(0xFFFF6B6B), // red + Color(0xFFFFC94D), // yellow + Color(0xFF4DD599), // green + Color(0xFFB980FF), // purple + Color(0xFFFF8FAB), // pink + Color(0xFF4DC9E6), // cyan + Color(0xFFFF9A5B), // orange + ]; + + static Color tile(int colorId) => tileColors[colorId % tileColors.length]; + + static const gem = Color(0xFF7CF5FF); + static const ghostLegal = Color(0x66FFFFFF); + static const ghostIllegal = Color(0x55FF5252); +} diff --git a/lib/ui/widgets/board_geometry.dart b/lib/ui/widgets/board_geometry.dart new file mode 100644 index 0000000..34ec7b4 --- /dev/null +++ b/lib/ui/widgets/board_geometry.dart @@ -0,0 +1,41 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import '../../game/models/grid.dart'; +import '../../game/models/piece.dart'; + +/// Pure mapping between board-local pixels and grid cells. +class BoardGeometry { + const BoardGeometry({required this.boardSize}); + + final double boardSize; + + double get cellSize => boardSize / GridState.size; + + Rect cellRect(int x, int y) => + Rect.fromLTWH(x * cellSize, y * cellSize, cellSize, cellSize); + + /// Cell under a local point, or null outside the board. + (int, int)? cellAt(Offset local) { + if (local.dx < 0 || local.dy < 0) return null; + if (local.dx >= boardSize || local.dy >= boardSize) return null; + return (local.dx ~/ cellSize, local.dy ~/ cellSize); + } + + /// Nearest anchor for a piece whose visual top-left sits at [pieceTopLeft], + /// clamped so the piece's bounding box stays on the board. + (int, int) snapAnchor(Piece piece, Offset pieceTopLeft) { + var maxDx = 0; + var maxDy = 0; + for (final (dx, dy) in piece.offsets) { + maxDx = math.max(maxDx, dx); + maxDy = math.max(maxDy, dy); + } + final x = (pieceTopLeft.dx / cellSize).round(); + final y = (pieceTopLeft.dy / cellSize).round(); + return ( + x.clamp(0, GridState.size - 1 - maxDx), + y.clamp(0, GridState.size - 1 - maxDy), + ); + } +} diff --git a/lib/ui/widgets/board_painter.dart b/lib/ui/widgets/board_painter.dart new file mode 100644 index 0000000..e07e70f --- /dev/null +++ b/lib/ui/widgets/board_painter.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; + +import '../../game/models/cell.dart'; +import '../../game/models/grid.dart'; +import '../../game/models/piece.dart'; +import '../theme/palette.dart'; +import 'board_geometry.dart'; +import 'piece_painter.dart'; + +/// Drag ghost preview: a piece hovering at a snapped anchor. +class GhostSpec { + const GhostSpec({ + required this.piece, + required this.anchorX, + required this.anchorY, + required this.legal, + }); + + final Piece piece; + final int anchorX; + final int anchorY; + final bool legal; +} + +class BoardPainter extends CustomPainter { + const BoardPainter({ + required this.grid, + required this.ghost, + required this.flashProgress, + required this.flashRows, + required this.flashCols, + }); + + final GridState grid; + final GhostSpec? ghost; + + /// 1.0 -> 0.0 while the clear flash fades. + final double flashProgress; + final List flashRows; + final List flashCols; + + @override + void paint(Canvas canvas, Size size) { + final geo = BoardGeometry(boardSize: size.width); + final radius = Radius.circular(geo.cellSize * 0.18); + final inset = geo.cellSize * 0.05; + + final bg = Paint()..color = GamePalette.boardBackground; + canvas.drawRRect( + RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(12)), + bg, + ); + + for (var y = 0; y < GridState.size; y++) { + for (var x = 0; x < GridState.size; x++) { + final rect = geo.cellRect(x, y).deflate(inset); + final cell = grid.cellAt(x, y); + final paint = Paint() + ..color = switch (cell.type) { + CellType.empty => GamePalette.emptyCell, + CellType.filled => GamePalette.tile(cell.colorId), + CellType.gem => GamePalette.emptyCell, + }; + canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint); + + if (cell.type == CellType.gem) { + _paintGem(canvas, rect); + } else if (cell.type == CellType.filled) { + final highlight = Paint() + ..color = Colors.white.withValues(alpha: 0.15); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + rect.left, rect.top, rect.width, rect.height * 0.32), + radius, + ), + highlight, + ); + } + } + } + + final g = ghost; + if (g != null) { + paintPiece( + canvas, + g.piece, + cellSize: geo.cellSize, + origin: Offset(g.anchorX * geo.cellSize, g.anchorY * geo.cellSize), + overrideColor: + g.legal ? GamePalette.ghostLegal : GamePalette.ghostIllegal, + ); + } + + if (flashProgress > 0) { + final flash = Paint() + ..color = Colors.white.withValues(alpha: 0.75 * flashProgress); + for (final y in flashRows) { + canvas.drawRect( + Rect.fromLTWH(0, y * geo.cellSize, size.width, geo.cellSize), + flash, + ); + } + for (final x in flashCols) { + canvas.drawRect( + Rect.fromLTWH(x * geo.cellSize, 0, geo.cellSize, size.height), + flash, + ); + } + } + } + + void _paintGem(Canvas canvas, Rect rect) { + final center = rect.center; + final r = rect.width * 0.32; + final path = Path() + ..moveTo(center.dx, center.dy - r) + ..lineTo(center.dx + r, center.dy) + ..lineTo(center.dx, center.dy + r) + ..lineTo(center.dx - r, center.dy) + ..close(); + canvas.drawPath(path, Paint()..color = GamePalette.gem); + canvas.drawPath( + path, + Paint() + ..color = Colors.white.withValues(alpha: 0.6) + ..style = PaintingStyle.stroke + ..strokeWidth = rect.width * 0.05, + ); + } + + @override + bool shouldRepaint(BoardPainter old) => + old.grid != grid || + old.ghost != ghost || + old.flashProgress != flashProgress; +} diff --git a/lib/ui/widgets/board_widget.dart b/lib/ui/widgets/board_widget.dart new file mode 100644 index 0000000..106c7d6 --- /dev/null +++ b/lib/ui/widgets/board_widget.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; + +import '../../state/game_session_notifier.dart'; +import 'board_painter.dart'; + +/// The board with clear-flash and combo-text effects. Pure display: drag +/// orchestration lives in the game screen. +class BoardWidget extends StatefulWidget { + const BoardWidget({super.key, required this.view, required this.ghost}); + + final GameViewState view; + final GhostSpec? ghost; + + @override + State createState() => _BoardWidgetState(); +} + +class _BoardWidgetState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _flash = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + ); + + List _flashRows = const []; + List _flashCols = const []; + int _comboStreak = 0; + + @override + void didUpdateWidget(BoardWidget old) { + super.didUpdateWidget(old); + final placement = widget.view.lastPlacement; + if (widget.view.fxTick != old.view.fxTick && + placement != null && + placement.linesCleared > 0) { + _flashRows = placement.clearedRows; + _flashCols = placement.clearedCols; + _comboStreak = placement.comboStreak; + _flash.forward(from: 0); + } + } + + @override + void dispose() { + _flash.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 1, + child: AnimatedBuilder( + animation: _flash, + builder: (context, _) { + final fading = _flash.isAnimating ? 1 - _flash.value : 0.0; + return Stack( + fit: StackFit.expand, + children: [ + CustomPaint( + painter: BoardPainter( + grid: widget.view.grid, + ghost: widget.ghost, + flashProgress: fading, + flashRows: _flashRows, + flashCols: _flashCols, + ), + ), + if (_flash.isAnimating && _comboStreak >= 2) + Center( + child: Transform.scale( + scale: 0.8 + 0.6 * _flash.value, + child: Opacity( + opacity: 1 - _flash.value, + child: Text( + 'COMBO x$_comboStreak', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w900, + color: Colors.amber.shade300, + shadows: const [ + Shadow(blurRadius: 12, color: Colors.black54), + ], + ), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/ui/widgets/hud_widget.dart b/lib/ui/widgets/hud_widget.dart new file mode 100644 index 0000000..5639beb --- /dev/null +++ b/lib/ui/widgets/hud_widget.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import '../../game/models/objective.dart'; +import '../../state/game_session_notifier.dart'; + +class HudWidget extends StatelessWidget { + const HudWidget({super.key, required this.view}); + + final GameViewState view; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _movesChip(theme), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, anim) => + ScaleTransition(scale: anim, child: child), + child: Text( + '${view.score}', + key: ValueKey(view.score), + style: theme.textTheme.headlineMedium + ?.copyWith(fontWeight: FontWeight.w800), + ), + ), + _comboChip(theme), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final obj in view.objectives) ...[ + _objectiveChip(theme, obj), + const SizedBox(width: 12), + ], + ], + ), + ], + ); + } + + Widget _movesChip(ThemeData theme) { + return Chip( + avatar: const Icon(Icons.swipe, size: 18), + label: Text('${view.movesLeft}'), + labelStyle: theme.textTheme.titleMedium, + ); + } + + Widget _comboChip(ThemeData theme) { + final visible = view.comboStreak >= 2; + return AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: visible ? 1 : 0, + child: Chip( + avatar: const Icon(Icons.local_fire_department, + size: 18, color: Colors.deepOrange), + label: Text('x${view.comboStreak}'), + labelStyle: theme.textTheme.titleMedium, + ), + ); + } + + Widget _objectiveChip(ThemeData theme, Objective obj) { + final (icon, color) = switch (obj) { + ClearGemsObjective() => (Icons.diamond, const Color(0xFF7CF5FF)), + ReachScoreObjective() => (Icons.star, Colors.amber), + ClearLinesObjective() => (Icons.table_rows, Colors.lightGreen), + }; + final done = obj.isComplete; + return Chip( + avatar: Icon(done ? Icons.check_circle : icon, size: 18, color: color), + label: Text('${obj.current.clamp(0, obj.target)}/${obj.target}'), + labelStyle: theme.textTheme.titleSmall, + ); + } +} diff --git a/lib/ui/widgets/piece_painter.dart b/lib/ui/widgets/piece_painter.dart new file mode 100644 index 0000000..d0f5793 --- /dev/null +++ b/lib/ui/widgets/piece_painter.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import '../../game/models/piece.dart'; +import '../theme/palette.dart'; + +/// Draws a piece as rounded tiles at a given cell size; reused by the tray, +/// the drag overlay, and ghost previews. +void paintPiece( + Canvas canvas, + Piece piece, { + required double cellSize, + Offset origin = Offset.zero, + Color? overrideColor, +}) { + final paint = Paint() + ..color = overrideColor ?? GamePalette.tile(piece.colorId); + final inset = cellSize * 0.05; + final radius = Radius.circular(cellSize * 0.18); + for (final (dx, dy) in piece.offsets) { + final rect = Rect.fromLTWH( + origin.dx + dx * cellSize + inset, + origin.dy + dy * cellSize + inset, + cellSize - inset * 2, + cellSize - inset * 2, + ); + canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint); + if (overrideColor == null) { + // Subtle top highlight for depth. + final highlight = Paint()..color = Colors.white.withValues(alpha: 0.18); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(rect.left, rect.top, rect.width, rect.height * 0.32), + radius, + ), + highlight, + ); + } + } +} + +/// Bounding size of a piece in cells. +(int, int) pieceCellBounds(Piece piece) { + var w = 0; + var h = 0; + for (final (dx, dy) in piece.offsets) { + if (dx + 1 > w) w = dx + 1; + if (dy + 1 > h) h = dy + 1; + } + return (w, h); +} + +class PieceWidget extends StatelessWidget { + const PieceWidget({super.key, required this.piece, required this.cellSize}); + + final Piece piece; + final double cellSize; + + @override + Widget build(BuildContext context) { + final (w, h) = pieceCellBounds(piece); + return CustomPaint( + size: Size(w * cellSize, h * cellSize), + painter: _PiecePainter(piece, cellSize), + ); + } +} + +class _PiecePainter extends CustomPainter { + const _PiecePainter(this.piece, this.cellSize); + + final Piece piece; + final double cellSize; + + @override + void paint(Canvas canvas, Size size) { + paintPiece(canvas, piece, cellSize: cellSize); + } + + @override + bool shouldRepaint(_PiecePainter old) => + old.piece != piece || old.cellSize != cellSize; +} diff --git a/lib/ui/widgets/tray_widget.dart b/lib/ui/widgets/tray_widget.dart new file mode 100644 index 0000000..2abeb84 --- /dev/null +++ b/lib/ui/widgets/tray_widget.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../../game/models/piece.dart'; +import 'piece_painter.dart'; + +/// The 3-slot piece tray. Reports raw pan gestures up; the game screen owns +/// drag state so the lifted piece can float over the board. +class TrayWidget extends StatelessWidget { + const TrayWidget({ + super.key, + required this.tray, + required this.draggingIndex, + required this.onDragStart, + required this.onDragUpdate, + required this.onDragEnd, + }); + + final List tray; + final int? draggingIndex; + final void Function(int index, Offset globalPosition) onDragStart; + final void Function(Offset globalPosition) onDragUpdate; + final void Function() onDragEnd; + + static const double slotCellSize = 16; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 96, + child: Row( + children: [ + for (var i = 0; i < 3; i++) + Expanded( + child: i < tray.length + ? _slot(context, i, tray[i]) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + Widget _slot(BuildContext context, int index, Piece piece) { + final hidden = index == draggingIndex; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (d) => onDragStart(index, d.globalPosition), + onPanUpdate: (d) => onDragUpdate(d.globalPosition), + onPanEnd: (_) => onDragEnd(), + onPanCancel: onDragEnd, + child: Center( + child: Opacity( + opacity: hidden ? 0.0 : 1.0, + child: PieceWidget(piece: piece, cellSize: slotCellSize), + ), + ), + ); + } +} diff --git a/test/game/engine/game_engine_test.dart b/test/game/engine/game_engine_test.dart index 07c0842..88d507a 100644 --- a/test/game/engine/game_engine_test.dart +++ b/test/game/engine/game_engine_test.dart @@ -111,6 +111,8 @@ void main() { final monoIndex = engine.tray.indexWhere((p) => p.id == 'mono'); final result = engine.tryPlace(monoIndex, 0, 3)!; expect(result.linesCleared, 1); + expect(result.clearedRows, [3]); + expect(result.clearedCols, isEmpty); // 1 cell + round(100 * 1.5) = 151 expect(engine.score, 151); expect(engine.grid.occupiedCount, 0); diff --git a/test/state/game_session_notifier_test.dart b/test/state/game_session_notifier_test.dart new file mode 100644 index 0000000..b255986 --- /dev/null +++ b/test/state/game_session_notifier_test.dart @@ -0,0 +1,127 @@ +import 'package:block_seasons/core/rng.dart'; +import 'package:block_seasons/game/engine/game_engine.dart'; +import 'package:block_seasons/game/engine/piece_generator.dart'; +import 'package:block_seasons/game/models/cell.dart'; +import 'package:block_seasons/game/models/grid.dart'; +import 'package:block_seasons/game/models/piece_library.dart'; +import 'package:block_seasons/game/models/stage.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +StageConfig _stage({List>? objectives}) => + StageConfig.fromJson({ + 'id': 'ui_stage', + 'seed': 99, + 'moveLimit': 20, + 'preset': [ + for (var x = 1; x < GridState.size; x++) + {'x': x, 'y': 3, 't': 'filled', 'c': 0}, + ], + 'objectives': objectives ?? + [ + {'type': 'reachScore', 'target': 999999}, + ], + 'stars': { + 'two': {'movesLeft': 5}, + 'three': {'movesLeft': 10}, + }, + 'generatorProfile': 'mid', + }); + +PieceGenerator _smallPool() => PieceGenerator( + SeededRng(1), + pool: [ + PieceLibrary.byId('mono'), + PieceLibrary.byId('domino_h'), + PieceLibrary.byId('domino_v'), + ], + ); + +void main() { + test('starts idle with no session', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + expect(container.read(gameSessionProvider), isNull); + }); + + test('startStage exposes a fresh view state', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(gameSessionProvider.notifier); + notifier.startStage(_stage(), generator: _smallPool()); + + final view = container.read(gameSessionProvider)!; + expect(view.phase, GamePhase.playing); + expect(view.tray, hasLength(3)); + expect(view.score, 0); + expect(view.movesLeft, 20); + expect(view.grid.cellAt(1, 3).type, CellType.filled); + expect(view.fxTick, 0); + }); + + test('tryPlace mutates the view and bumps fxTick', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(gameSessionProvider.notifier); + notifier.startStage(_stage(), generator: _smallPool()); + + final placed = notifier.tryPlace(0, 0, 0); + expect(placed, isTrue); + + final view = container.read(gameSessionProvider)!; + expect(view.tray, hasLength(2)); + expect(view.score, greaterThan(0)); + expect(view.fxTick, 1); + expect(view.lastPlacement, isNotNull); + }); + + test('illegal placement leaves state untouched', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(gameSessionProvider.notifier); + notifier.startStage(_stage(), generator: _smallPool()); + + final placed = notifier.tryPlace(0, 1, 3); // occupied preset cell + expect(placed, isFalse); + final view = container.read(gameSessionProvider)!; + expect(view.tray, hasLength(3)); + expect(view.fxTick, 0); + }); + + test('winning surfaces stars and cleared lines fx', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(gameSessionProvider.notifier); + notifier.startStage( + _stage(objectives: [ + {'type': 'clearLines', 'count': 1}, + ]), + generator: _smallPool(), + ); + + final view0 = container.read(gameSessionProvider)!; + final monoIndex = view0.tray.indexWhere((p) => p.id == 'mono'); + notifier.tryPlace(monoIndex, 0, 3); + + final view = container.read(gameSessionProvider)!; + expect(view.phase, GamePhase.won); + expect(view.starsEarned, 3); + expect(view.lastPlacement!.clearedRows, [3]); + }); + + test('restart deals a fresh attempt with a different tray sequence', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(gameSessionProvider.notifier); + notifier.startStage(_stage()); + notifier.tryPlace(0, 0, 0); + notifier.restart(); + + final view = container.read(gameSessionProvider)!; + expect(view.phase, GamePhase.playing); + expect(view.score, 0); + expect(view.movesLeft, 20); + expect(view.tray, hasLength(3)); + }); +} diff --git a/test/ui/board_geometry_test.dart b/test/ui/board_geometry_test.dart new file mode 100644 index 0000000..c2035f9 --- /dev/null +++ b/test/ui/board_geometry_test.dart @@ -0,0 +1,46 @@ +import 'package:block_seasons/game/models/piece_library.dart'; +import 'package:block_seasons/ui/widgets/board_geometry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const geo = BoardGeometry(boardSize: 320); // cell = 40 + + group('cellAt', () { + test('maps points inside cells', () { + expect(geo.cellAt(const Offset(5, 5)), (0, 0)); + expect(geo.cellAt(const Offset(60, 100)), (1, 2)); + expect(geo.cellAt(const Offset(319, 319)), (7, 7)); + }); + + test('returns null outside the board', () { + expect(geo.cellAt(const Offset(-1, 10)), isNull); + expect(geo.cellAt(const Offset(10, 321)), isNull); + }); + }); + + group('cellRect', () { + test('returns the drawn rect for a cell', () { + final rect = geo.cellRect(2, 3); + expect(rect.left, 80); + expect(rect.top, 120); + expect(rect.width, 40); + expect(rect.height, 40); + }); + }); + + group('snapAnchor', () { + final mono = PieceLibrary.byId('mono'); + final line5h = PieceLibrary.byId('line5_h'); + + test('rounds to the nearest cell', () { + expect(geo.snapAnchor(mono, const Offset(78, 122)), (2, 3)); + expect(geo.snapAnchor(mono, const Offset(99, 99)), (2, 2)); + }); + + test('clamps so the piece bounding box stays on the board', () { + // line5_h is 5 wide: anchor x can be at most 3. + expect(geo.snapAnchor(line5h, const Offset(310, 0)), (3, 0)); + expect(geo.snapAnchor(line5h, const Offset(-50, -50)), (0, 0)); + }); + }); +} diff --git a/test/ui/game_screen_test.dart b/test/ui/game_screen_test.dart new file mode 100644 index 0000000..8f7de2c --- /dev/null +++ b/test/ui/game_screen_test.dart @@ -0,0 +1,116 @@ +import 'package:block_seasons/core/rng.dart'; +import 'package:block_seasons/game/engine/piece_generator.dart'; +import 'package:block_seasons/game/models/piece_library.dart'; +import 'package:block_seasons/game/models/stage.dart'; +import 'package:block_seasons/l10n/gen/app_localizations.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:block_seasons/ui/screens/game_screen.dart'; +import 'package:block_seasons/ui/widgets/board_widget.dart'; +import 'package:block_seasons/ui/widgets/piece_painter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final _stage = StageConfig.fromJson({ + 'id': 'drag_stage', + 'seed': 11, + 'moveLimit': 20, + 'preset': const >[], + 'objectives': [ + {'type': 'reachScore', 'target': 999999}, + ], + 'stars': { + 'two': {'movesLeft': 5}, + 'three': {'movesLeft': 10}, + }, + 'generatorProfile': 'mid', +}); + +PieceGenerator _smallPool() => PieceGenerator( + SeededRng(3), + pool: [ + PieceLibrary.byId('mono'), + PieceLibrary.byId('domino_h'), + PieceLibrary.byId('domino_v'), + ], + ); + +void main() { + testWidgets('dragging a tray piece onto the board places it', + (tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + container + .read(gameSessionProvider.notifier) + .startStage(_stage, generator: _smallPool()); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: GameScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + final view0 = container.read(gameSessionProvider)!; + final monoIndex = view0.tray.indexWhere((p) => p.id == 'mono'); + expect(monoIndex, greaterThanOrEqualTo(0)); + + final boardRect = tester.getRect(find.byType(BoardWidget)); + final cell = boardRect.width / 8; + + // The dragged piece floats 70px above the finger, so aim the finger + // below the intended landing cell (0, 0). + final targetCenter = boardRect.topLeft + Offset(cell * 0.5, cell * 0.5); + final fingerEnd = targetCenter + Offset(0, 70 + cell / 2); + + final start = tester.getCenter(find.byType(PieceWidget).at(monoIndex)); + final gesture = await tester.startGesture(start); + await gesture.moveBy(const Offset(0, -30)); + await tester.pump(); + await gesture.moveTo(fingerEnd); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + final view = container.read(gameSessionProvider)!; + expect(view.grid.isOccupied(0, 0), isTrue, + reason: 'mono should land on (0,0)'); + expect(view.tray, hasLength(2)); + expect(view.score, 1); + }); + + testWidgets('result overlay appears when out of moves', (tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + final oneMove = StageConfig.fromJson({ + ..._stage.toJson(), + 'moveLimit': 1, + }); + container + .read(gameSessionProvider.notifier) + .startStage(oneMove, generator: _smallPool()); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: GameScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + container.read(gameSessionProvider.notifier).tryPlace(0, 0, 0); + await tester.pumpAndSettle(); + + expect(find.text('Out of moves'), findsOneWidget); + expect(find.text('+5 moves (ad)'), findsOneWidget); + }); +}