From 1ba30028b5e91af221b81b3a11e9b0c6d6c02e23 Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 18 Jun 2026 12:40:13 +0900 Subject: [PATCH] feat(ui): booster bar targeting in the game screen Mount BoosterBar below the tray (only while playing), guarded so legacy GameScreen tests without a SaveRepository keep passing. Tapping a booster arms targeting: shuffle applies immediately; hammer/line-bomb arm a board tap (hammer clears a cell, line-bomb opens a row/column chooser). An empty booster opens a get-one dialog (ad grant lands in Task 15). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/ui/screens/game_screen.dart | 152 +++++++++++++++++++++++++- test/ui/game_screen_booster_test.dart | 81 ++++++++++++++ 2 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 test/ui/game_screen_booster_test.dart diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index 847e6de..8d67dd0 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../game/engine/game_engine.dart'; +import '../../game/models/booster.dart'; import '../../game/models/grid.dart'; import '../../l10n/gen/app_localizations.dart'; import '../../services/audio_service.dart'; @@ -14,6 +15,7 @@ import '../theme/palette.dart'; import '../widgets/board_geometry.dart'; import '../widgets/board_painter.dart'; import '../widgets/board_widget.dart'; +import '../widgets/booster_bar.dart'; import '../widgets/effects_overlay.dart'; import '../widgets/hud_widget.dart'; import '../widgets/piece_painter.dart'; @@ -21,6 +23,9 @@ import '../widgets/season_background.dart'; import '../widgets/tray_widget.dart'; import '../widgets/tutorial_overlay.dart'; +/// Which line a line-bomb clears, chosen from the tapped cell's row or column. +enum _LineAxis { row, col } + /// Renders whatever session [gameSessionProvider] holds; callers start the /// stage (via SeasonFlowNotifier) before navigating here. class GameScreen extends ConsumerStatefulWidget { @@ -46,6 +51,10 @@ class _GameScreenState extends ConsumerState int? _dragIndex; Offset? _dragGlobal; + /// Non-null while a targeted booster is armed and waiting for a board tap. + /// Shuffle never sets this (it applies immediately). + BoosterType? _arming; + /// How far the dragged piece floats above the finger so it stays visible. static const double _lift = 70; @@ -109,6 +118,116 @@ class _GameScreenState extends ConsumerState } } + /// True when a [SaveRepository] is wired up. The default provider throws + /// until overridden (in app start and most tests); a couple of legacy widget + /// tests mount GameScreen without it, and the booster bar tolerates that. + bool _hasSaveRepository() { + try { + ref.read(saveRepositoryProvider); + return true; + } catch (_) { + return false; + } + } + + // ---- Booster targeting ---- + + /// Tapping a booster button. Empty → offer a rewarded ad; shuffle applies + /// immediately; hammer/line-bomb arm targeting and show a hint. + Future _onBoosterTap(BoosterType type) async { + if ((ref.read(boosterInventoryProvider)[type] ?? 0) <= 0) { + await _offerBoosterAd(type); + return; + } + if (type == BoosterType.shuffle) { + await ref.read(gameSessionProvider.notifier).useShuffle(); + return; + } + // hammer / lineBomb need a board target. + setState(() => _arming = type); + final l10n = AppLocalizations.of(context)!; + final hint = + type == BoosterType.hammer ? l10n.boosterTapTarget : l10n.boosterTapLine; + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar( + content: Text(hint), + duration: const Duration(seconds: 3), + )); + } + + /// Converts a board tap into a cell, then applies the armed booster. + Future _onBoardTapUp(TapUpDetails details) async { + final armed = _arming; + if (armed == null) return; + final box = _boardBox; + if (box == null) return; + final local = box.globalToLocal(details.globalPosition); + final cell = BoardGeometry(boardSize: box.size.width).cellSize; + final x = (local.dx / cell).floor(); + final y = (local.dy / cell).floor(); + if (x < 0 || x >= GridState.size || y < 0 || y >= GridState.size) return; + + final session = ref.read(gameSessionProvider.notifier); + if (armed == BoosterType.hammer) { + await session.useHammer(x, y); + if (mounted) setState(() => _arming = null); + } else if (armed == BoosterType.lineBomb) { + final axis = await _chooseLineAxis(); + if (axis == _LineAxis.row) { + await session.useLineBomb(row: y); + } else if (axis == _LineAxis.col) { + await session.useLineBomb(col: x); + } + // A dismissed chooser cancels the use but still clears the armed state. + if (mounted) setState(() => _arming = null); + } + } + + /// Small chooser for the line-bomb: clear the tapped row or column. + Future<_LineAxis?> _chooseLineAxis() { + final l10n = AppLocalizations.of(context)!; + return showDialog<_LineAxis>( + context: context, + builder: (context) => AlertDialog( + content: Text(l10n.boosterTapLine), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(_LineAxis.row), + child: const Text('↔ 가로'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(_LineAxis.col), + child: const Text('↕ 세로'), + ), + ], + ), + ); + } + + /// An empty booster offers to get one. The rewarded-ad grant is wired in + /// Task 15. + Future _offerBoosterAd(BoosterType type) async { + final l10n = AppLocalizations.of(context)!; + await showDialog( + context: context, + builder: (context) => AlertDialog( + content: Text(l10n.boosterGetWithAd), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.giveUp), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(l10n.boosterGetWithAd), + ), + ], + ), + ); + } + @override void dispose() { _shake.dispose(); @@ -247,6 +366,13 @@ class _GameScreenState extends ConsumerState final draggedTopLeft = _draggedPieceTopLeft(view); final boardBox = _boardBox; + // The booster bar needs the save-backed inventory. A few legacy widget + // tests mount GameScreen without a SaveRepository override; in that case + // the inventory provider throws, so only watch it (and mount the bar) when + // the repository is actually wired up. + final hasSave = _hasSaveRepository(); + final boosterCounts = hasSave ? ref.watch(boosterInventoryProvider) : null; + final theme = ref.watch(activeThemeProvider); return Scaffold( backgroundColor: Colors.transparent, @@ -286,10 +412,20 @@ class _GameScreenState extends ConsumerState return Transform.translate( offset: Offset(dx, 0), child: child); }, - child: BoardWidget( - key: _boardKey, - view: view, - ghost: ghost, + // While a targeted booster is armed, taps on the + // board pick a cell. When not arming, onTapUp + // returns immediately so it never steals the + // tray-drag placement gestures. + child: GestureDetector( + behavior: HitTestBehavior.deferToChild, + onTapUp: _arming == null + ? null + : (details) => _onBoardTapUp(details), + child: BoardWidget( + key: _boardKey, + view: view, + ghost: ghost, + ), ), ), ), @@ -305,6 +441,14 @@ class _GameScreenState extends ConsumerState setState(() => _dragGlobal = global), onDragEnd: () => _onDragEnd(view), ), + if (view.phase == GamePhase.playing && + boosterCounts != null) ...[ + const SizedBox(height: 8), + BoosterBar( + counts: boosterCounts, + onTap: _onBoosterTap, + ), + ], ], ), ), diff --git a/test/ui/game_screen_booster_test.dart b/test/ui/game_screen_booster_test.dart new file mode 100644 index 0000000..7bae090 --- /dev/null +++ b/test/ui/game_screen_booster_test.dart @@ -0,0 +1,81 @@ +// Widget tests for the booster bar mounted in the game screen. +// +// Board-geometry taps (hammer / line-bomb cell selection) are NOT covered here: +// the board is laid out inside an Expanded/Center whose pixel geometry is not +// deterministic in a widget test, so a pixel-accurate cell tap is unreliable. +// Those paths are left to manual QA (see the agent report). What we CAN verify +// deterministically is the inventory side-effect: shuffle applies immediately +// and spends one booster. +import 'package:block_seasons/core/rng.dart'; +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/game/engine/piece_generator.dart'; +import 'package:block_seasons/game/models/booster.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:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +StageConfig _stage() => StageConfig.fromJson({ + 'id': 'ui_b', + 'seed': 1, + 'moveLimit': 20, + 'preset': [ + {'x': 0, 'y': 0, 't': 'filled', 'c': 3}, + ], + 'objectives': [ + {'type': 'reachScore', 'target': 100000}, + ], + 'stars': { + 'two': {'movesLeft': 5}, + 'three': {'movesLeft': 10}, + }, + }); + +Widget _wrap(ProviderContainer c) => UncontrolledProviderScope( + container: c, + child: MaterialApp( + debugShowCheckedModeBanner: false, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const GameScreen(), + ), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future startedContainer() async { + SharedPreferences.setMockInitialValues({}); + final repo = SaveRepository(await SharedPreferences.getInstance()); + final c = ProviderContainer(overrides: [ + saveRepositoryProvider.overrideWithValue(repo), + ]); + c.read(gameSessionProvider.notifier).startStage( + _stage(), + generator: PieceGenerator(SeededRng(1)), + ); + return c; + } + + testWidgets('tapping shuffle applies immediately and spends one booster', + (tester) async { + final c = await startedContainer(); + await c.read(boosterInventoryProvider.notifier).grant(BoosterType.shuffle); + expect(c.read(boosterInventoryProvider)[BoosterType.shuffle], 1); + + await tester.pumpWidget(_wrap(c)); + await tester.pump(); + + await tester.tap(find.byKey(const ValueKey('booster_shuffle'))); + await tester.pump(); + await tester.pump(); + + expect(c.read(boosterInventoryProvider)[BoosterType.shuffle], 0); + c.dispose(); + }); +}