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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../game/engine/game_engine.dart';
|
import '../../game/engine/game_engine.dart';
|
||||||
|
import '../../game/models/booster.dart';
|
||||||
import '../../game/models/grid.dart';
|
import '../../game/models/grid.dart';
|
||||||
import '../../l10n/gen/app_localizations.dart';
|
import '../../l10n/gen/app_localizations.dart';
|
||||||
import '../../services/audio_service.dart';
|
import '../../services/audio_service.dart';
|
||||||
@@ -14,6 +15,7 @@ import '../theme/palette.dart';
|
|||||||
import '../widgets/board_geometry.dart';
|
import '../widgets/board_geometry.dart';
|
||||||
import '../widgets/board_painter.dart';
|
import '../widgets/board_painter.dart';
|
||||||
import '../widgets/board_widget.dart';
|
import '../widgets/board_widget.dart';
|
||||||
|
import '../widgets/booster_bar.dart';
|
||||||
import '../widgets/effects_overlay.dart';
|
import '../widgets/effects_overlay.dart';
|
||||||
import '../widgets/hud_widget.dart';
|
import '../widgets/hud_widget.dart';
|
||||||
import '../widgets/piece_painter.dart';
|
import '../widgets/piece_painter.dart';
|
||||||
@@ -21,6 +23,9 @@ import '../widgets/season_background.dart';
|
|||||||
import '../widgets/tray_widget.dart';
|
import '../widgets/tray_widget.dart';
|
||||||
import '../widgets/tutorial_overlay.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
|
/// Renders whatever session [gameSessionProvider] holds; callers start the
|
||||||
/// stage (via SeasonFlowNotifier) before navigating here.
|
/// stage (via SeasonFlowNotifier) before navigating here.
|
||||||
class GameScreen extends ConsumerStatefulWidget {
|
class GameScreen extends ConsumerStatefulWidget {
|
||||||
@@ -46,6 +51,10 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
int? _dragIndex;
|
int? _dragIndex;
|
||||||
Offset? _dragGlobal;
|
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.
|
/// How far the dragged piece floats above the finger so it stays visible.
|
||||||
static const double _lift = 70;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_shake.dispose();
|
_shake.dispose();
|
||||||
@@ -247,6 +366,13 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
final draggedTopLeft = _draggedPieceTopLeft(view);
|
final draggedTopLeft = _draggedPieceTopLeft(view);
|
||||||
final boardBox = _boardBox;
|
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);
|
final theme = ref.watch(activeThemeProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
@@ -286,10 +412,20 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
return Transform.translate(
|
return Transform.translate(
|
||||||
offset: Offset(dx, 0), child: child);
|
offset: Offset(dx, 0), child: child);
|
||||||
},
|
},
|
||||||
child: BoardWidget(
|
// While a targeted booster is armed, taps on the
|
||||||
key: _boardKey,
|
// board pick a cell. When not arming, onTapUp
|
||||||
view: view,
|
// returns immediately so it never steals the
|
||||||
ghost: ghost,
|
// 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),
|
setState(() => _dragGlobal = global),
|
||||||
onDragEnd: () => _onDragEnd(view),
|
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