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) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 12:40:13 +09:00
parent a04bb3b847
commit 1ba30028b5
2 changed files with 229 additions and 4 deletions
+148 -4
View File
@@ -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<GameScreen>
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<GameScreen>
}
}
/// 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<void> _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<void> _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<void> _offerBoosterAd(BoosterType type) async {
final l10n = AppLocalizations.of(context)!;
await showDialog<bool>(
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<GameScreen>
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<GameScreen>
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<GameScreen>
setState(() => _dragGlobal = global),
onDragEnd: () => _onDragEnd(view),
),
if (view.phase == GamePhase.playing &&
boosterCounts != null) ...[
const SizedBox(height: 8),
BoosterBar(
counts: boosterCounts,
onTap: _onBoosterTap,
),
],
],
),
),
+81
View File
@@ -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<ProviderContainer> 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();
});
}