feat: juice kit - sparks, score popups, combo banners, confetti, haptics, shake

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 22:05:14 +09:00
parent d985d40f09
commit 8c3c2ae9a9
4 changed files with 330 additions and 27 deletions
+1
View File
@@ -47,3 +47,4 @@ app.*.map.json
# Generated localizations
lib/l10n/gen/
.superpowers/
CLAUDE.md
+54 -5
View File
@@ -1,4 +1,7 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../game/engine/game_engine.dart';
@@ -10,6 +13,7 @@ import '../theme/palette.dart';
import '../widgets/board_geometry.dart';
import '../widgets/board_painter.dart';
import '../widgets/board_widget.dart';
import '../widgets/effects_overlay.dart';
import '../widgets/hud_widget.dart';
import '../widgets/piece_painter.dart';
import '../widgets/season_background.dart';
@@ -24,9 +28,15 @@ class GameScreen extends ConsumerStatefulWidget {
ConsumerState<GameScreen> createState() => _GameScreenState();
}
class _GameScreenState extends ConsumerState<GameScreen> {
class _GameScreenState extends ConsumerState<GameScreen>
with TickerProviderStateMixin {
final _boardKey = GlobalKey();
final _stackKey = GlobalKey();
final _effectsKey = GlobalKey<EffectsOverlayState>();
late final AnimationController _shake = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
);
int? _dragIndex;
Offset? _dragGlobal;
@@ -94,6 +104,12 @@ class _GameScreenState extends ConsumerState<GameScreen> {
}
}
@override
void dispose() {
_shake.dispose();
super.dispose();
}
void _onSessionChange(GameViewState? prev, GameViewState? next) {
if (next == null) return;
final audio = ref.read(audioServiceProvider);
@@ -101,8 +117,25 @@ class _GameScreenState extends ConsumerState<GameScreen> {
final placement = next.lastPlacement!;
if (placement.linesCleared > 0) {
audio.play(placement.comboStreak >= 2 ? Sfx.combo : Sfx.clear);
HapticFeedback.mediumImpact();
if (placement.comboStreak >= 4) {
HapticFeedback.heavyImpact();
_shake.forward(from: 0);
}
} else {
audio.play(Sfx.place);
HapticFeedback.lightImpact();
}
final boardBox = _boardBox;
final stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (boardBox != null && stackBox != null) {
final topLeft =
stackBox.globalToLocal(boardBox.localToGlobal(Offset.zero));
_effectsKey.currentState?.onPlacement(
placement,
boardRect: topLeft & boardBox.size,
);
}
}
if (prev?.phase != next.phase) {
@@ -112,6 +145,11 @@ class _GameScreenState extends ConsumerState<GameScreen> {
ref
.read(seasonFlowProvider.notifier)
.recordWin(stars: next.starsEarned, score: next.score);
final stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackBox != null) {
_effectsKey.currentState?.onWin(stackBox.size);
}
}
if (next.phase == GamePhase.lost) audio.play(Sfx.lose);
if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
@@ -159,10 +197,20 @@ class _GameScreenState extends ConsumerState<GameScreen> {
HudWidget(view: view),
Expanded(
child: Center(
child: BoardWidget(
key: _boardKey,
view: view,
ghost: ghost,
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);
},
child: BoardWidget(
key: _boardKey,
view: view,
ghost: ghost,
),
),
),
),
@@ -185,6 +233,7 @@ class _GameScreenState extends ConsumerState<GameScreen> {
boardBox != null &&
_dragIndex! < view.tray.length)
_draggedPieceOverlay(view, draggedTopLeft, boardBox),
Positioned.fill(child: EffectsOverlay(key: _effectsKey)),
if (view.phase != GamePhase.playing) _resultOverlay(view),
if (Navigator.of(context).canPop())
Positioned(
-22
View File
@@ -24,7 +24,6 @@ class _BoardWidgetState extends State<BoardWidget>
List<int> _flashRows = const [];
List<int> _flashCols = const [];
int _comboStreak = 0;
@override
void didUpdateWidget(BoardWidget old) {
@@ -35,7 +34,6 @@ class _BoardWidgetState extends State<BoardWidget>
placement.linesCleared > 0) {
_flashRows = placement.clearedRows;
_flashCols = placement.clearedCols;
_comboStreak = placement.comboStreak;
_flash.forward(from: 0);
}
}
@@ -66,26 +64,6 @@ class _BoardWidgetState extends State<BoardWidget>
flashCols: _flashCols,
),
),
if (_flash.isAnimating && _comboStreak >= 2)
Center(
child: Transform.scale(
scale: 0.8 + 0.6 * _flash.value,
child: Opacity(
opacity: 1 - _flash.value,
child: Text(
'COMBO x$_comboStreak',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w900,
color: Colors.amber.shade300,
shadows: const [
Shadow(blurRadius: 12, color: Colors.black54),
],
),
),
),
),
),
],
);
},
+275
View File
@@ -0,0 +1,275 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../../game/engine/game_engine.dart';
import '../../game/engine/game_event.dart';
import '../../game/models/grid.dart';
import '../../game/models/piece.dart';
import '../theme/palette.dart';
import 'board_geometry.dart';
enum _FxType { spark, popup, combo, confetti, settle }
class _Fx {
_Fx(this.type, this.start, {this.pos = Offset.zero, this.data});
final _FxType type;
final Duration start;
final Offset pos;
final Object? data;
static const durations = {
_FxType.spark: Duration(milliseconds: 600),
_FxType.popup: Duration(milliseconds: 900),
_FxType.combo: Duration(milliseconds: 1000),
_FxType.confetti: Duration(milliseconds: 1800),
_FxType.settle: Duration(milliseconds: 140),
};
double progress(Duration now) {
final d = durations[type]!;
final p = (now - start).inMicroseconds / d.inMicroseconds;
return p.clamp(0.0, 1.0);
}
bool done(Duration now) => progress(now) >= 1;
}
/// Transient game-feel effects above the board: clear sparks, rising score
/// popups, combo banners, win confetti, placed-piece settle. The game screen
/// reports events; effects expire on their own and the ticker stops when the
/// list drains, so widget tests settle normally.
class EffectsOverlay extends StatefulWidget {
const EffectsOverlay({super.key});
@override
State<EffectsOverlay> createState() => EffectsOverlayState();
}
class EffectsOverlayState extends State<EffectsOverlay>
with SingleTickerProviderStateMixin {
late final Ticker _ticker;
final List<_Fx> _fx = [];
Duration _now = Duration.zero;
@override
void initState() {
super.initState();
_ticker = createTicker(_tick);
}
void _tick(Duration elapsed) {
setState(() {
_now = elapsed;
_fx.removeWhere((e) => e.done(elapsed));
if (_fx.isEmpty) _ticker.stop();
});
}
void _add(_Fx fx) {
_fx.add(fx);
if (!_ticker.isActive) _ticker.start();
}
/// [boardRect] is the board's rect in this overlay's coordinates.
void onPlacement(PlacementResult placement, {required Rect boardRect}) {
final geo = BoardGeometry(boardSize: boardRect.width);
final origin = boardRect.topLeft;
for (final event in placement.events) {
if (event is PiecePlaced) {
_add(_Fx(_FxType.settle, _now,
pos: origin +
Offset(event.x * geo.cellSize, event.y * geo.cellSize),
data: (event.piece, geo.cellSize)));
}
}
final cleared = <Offset>[];
for (final y in placement.clearedRows) {
for (var x = 0; x < GridState.size; x++) {
cleared.add(origin + geo.cellRect(x, y).center);
}
}
for (final x in placement.clearedCols) {
for (var y = 0; y < GridState.size; y++) {
cleared.add(origin + geo.cellRect(x, y).center);
}
}
for (final c in cleared) {
_add(_Fx(_FxType.spark, _now, pos: c, data: geo.cellSize));
}
if (placement.pointsGained > 0 && placement.linesCleared > 0) {
final at = cleared.isEmpty
? boardRect.center
: cleared[cleared.length ~/ 2];
_add(_Fx(_FxType.popup, _now,
pos: at, data: '+${placement.pointsGained}'));
}
if (placement.comboStreak >= 2) {
_add(_Fx(_FxType.combo, _now,
pos: boardRect.center, data: placement.comboStreak));
}
}
void onWin(Size screenSize) {
for (var i = 0; i < 36; i++) {
_add(_Fx(_FxType.confetti, _now,
pos: Offset(screenSize.width * hash(i, 1), -12), data: i));
}
}
/// Deterministic pseudo-random in [0, 1) from an index.
static double hash(int i, double salt) {
final v = math.sin(i * 12.9898 + salt) * 43758.5453;
return v - v.floorToDouble();
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: CustomPaint(
size: Size.infinite,
painter: _FxPainter(List.of(_fx), _now),
),
);
}
}
class _FxPainter extends CustomPainter {
const _FxPainter(this.fx, this.now);
final List<_Fx> fx;
final Duration now;
@override
void paint(Canvas canvas, Size size) {
for (final e in fx) {
final t = e.progress(now);
switch (e.type) {
case _FxType.spark:
_spark(canvas, e, t);
case _FxType.popup:
_popup(canvas, e, t);
case _FxType.combo:
_combo(canvas, e, t);
case _FxType.confetti:
_confetti(canvas, e, t, size);
case _FxType.settle:
_settle(canvas, e, t);
}
}
}
void _spark(Canvas canvas, _Fx e, double t) {
final cell = e.data as double;
for (var i = 0; i < 6; i++) {
final angle =
i * math.pi / 3 + EffectsOverlayState.hash(i, 7) * 0.8;
final dist = cell * (0.3 + 1.2 * Curves.easeOut.transform(t));
final pos = e.pos + Offset(math.cos(angle), math.sin(angle)) * dist;
canvas.drawCircle(
pos,
cell * 0.09 * (1 - t),
Paint()..color = Colors.white.withValues(alpha: (1 - t) * 0.9),
);
}
}
void _popup(Canvas canvas, _Fx e, double t) {
final rise = 44 * Curves.easeOut.transform(t);
_text(canvas, e.data as String, e.pos - Offset(0, rise),
fontSize: 22, color: Colors.white.withValues(alpha: 1 - t * t));
}
void _combo(Canvas canvas, _Fx e, double t) {
final streak = e.data as int;
final color = streak >= 6
? const Color(0xFFB980FF)
: streak >= 4
? const Color(0xFFFF8A4D)
: const Color(0xFFFFD166);
final scale =
t < 0.25 ? Curves.easeOutBack.transform(t / 0.25) : 1.0;
final alpha = t > 0.7 ? (1 - t) / 0.3 : 1.0;
canvas.save();
canvas.translate(e.pos.dx, e.pos.dy - 30);
canvas.scale(scale);
_text(canvas, 'COMBO ×$streak', Offset.zero,
fontSize: streak >= 4 ? 40 : 34,
color: color.withValues(alpha: alpha.clamp(0.0, 1.0)));
canvas.restore();
}
void _confetti(Canvas canvas, _Fx e, double t, Size size) {
final i = e.data as int;
final colors = GamePalette.tileColors;
final x = e.pos.dx + 28 * math.sin(t * 4 * math.pi + i);
final y = t * (size.height + 40);
canvas.save();
canvas.translate(x, y);
canvas.rotate(
t * 6 * math.pi * (EffectsOverlayState.hash(i, 3) - 0.5));
canvas.drawRect(
Rect.fromCenter(center: Offset.zero, width: 9, height: 5),
Paint()
..color = colors[i % colors.length]
.withValues(alpha: (1 - t * 0.6).clamp(0.0, 1.0)),
);
canvas.restore();
}
void _settle(Canvas canvas, _Fx e, double t) {
final (piece, cellSize) = e.data as (Piece, double);
final scale = 1.08 - 0.08 * Curves.easeOut.transform(t);
final alpha = 0.35 * (1 - t);
for (final (dx, dy) in piece.offsets) {
final rect = Rect.fromLTWH(
e.pos.dx + dx * cellSize,
e.pos.dy + dy * cellSize,
cellSize,
cellSize,
);
final scaled = Rect.fromCenter(
center: rect.center,
width: rect.width * scale,
height: rect.height * scale,
).deflate(cellSize * 0.05);
canvas.drawRRect(
RRect.fromRectAndRadius(scaled, Radius.circular(cellSize * 0.18)),
Paint()..color = Colors.white.withValues(alpha: alpha),
);
}
}
void _text(Canvas canvas, String s, Offset center,
{required double fontSize, required Color color}) {
final painter = TextPainter(
text: TextSpan(
text: s,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w900,
color: color,
shadows: const [Shadow(blurRadius: 14, color: Colors.black87)],
),
),
textDirection: TextDirection.ltr,
)..layout();
painter.paint(
canvas, center - Offset(painter.width / 2, painter.height / 2));
}
@override
bool shouldRepaint(_FxPainter old) => true;
}