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:
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user