feat(ui): floating pulse hint for booster targeting

Replaces the plain bottom SnackBar with a BoosterHint pill that floats in
the empty space above the board: season-accent coloured, a breathing glow
that pulses only while armed (idle otherwise — no wasted ticker), slides/
fades in, shows the booster icon + prompt, and cancels on tap. 3 widget
tests; full suite 234 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 20:53:52 +09:00
parent 42deeaf242
commit 410182cf7d
3 changed files with 252 additions and 35 deletions
+49 -35
View File
@@ -16,6 +16,7 @@ import '../widgets/board_geometry.dart';
import '../widgets/board_painter.dart';
import '../widgets/board_widget.dart';
import '../widgets/booster_bar.dart';
import '../widgets/booster_hint.dart';
import '../widgets/effects_overlay.dart';
import '../widgets/hud_widget.dart';
import '../widgets/piece_painter.dart';
@@ -143,18 +144,9 @@ class _GameScreenState extends ConsumerState<GameScreen>
await ref.read(gameSessionProvider.notifier).useShuffle();
return;
}
// hammer / lineBomb need a board target.
// hammer / lineBomb need a board target; the floating BoosterHint above
// the board shows the prompt (and lets the player cancel) while armed.
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.
@@ -410,32 +402,54 @@ class _GameScreenState extends ConsumerState<GameScreen>
),
HudWidget(view: view),
Expanded(
child: Center(
child: AnimatedBuilder(
animation: _shake,
builder: (context, child) {
final t = _shake.value;
final dx =
math.sin(t * math.pi * 10) * 6 * (1 - t);
return Transform.translate(
offset: Offset(dx, 0), child: child);
},
// 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,
child: Stack(
children: [
Positioned.fill(
child: Center(
child: AnimatedBuilder(
animation: _shake,
builder: (context, child) {
final t = _shake.value;
final dx = math.sin(t * math.pi * 10) *
6 *
(1 - t);
return Transform.translate(
offset: Offset(dx, 0), child: child);
},
// 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,
),
),
),
),
),
),
// Floating targeting prompt, in the empty space above
// the centered board so it never covers cells.
Positioned(
top: 4,
left: 0,
right: 0,
child: Center(
child: BoosterHint(
arming: _arming,
accent: ThemeColors(theme).accent,
onCancel: () =>
setState(() => _arming = null),
),
),
),
],
),
),
TrayWidget(