From 8c3c2ae9a9eb1ca65063ff8f71de24046790063d Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 11 Jun 2026 22:05:14 +0900 Subject: [PATCH] feat: juice kit - sparks, score popups, combo banners, confetti, haptics, shake Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + lib/ui/screens/game_screen.dart | 59 +++++- lib/ui/widgets/board_widget.dart | 22 --- lib/ui/widgets/effects_overlay.dart | 275 ++++++++++++++++++++++++++++ 4 files changed, 330 insertions(+), 27 deletions(-) create mode 100644 lib/ui/widgets/effects_overlay.dart diff --git a/.gitignore b/.gitignore index aa6fef4..6b5629d 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ app.*.map.json # Generated localizations lib/l10n/gen/ .superpowers/ +CLAUDE.md diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index d4fa339..45daef4 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -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 createState() => _GameScreenState(); } -class _GameScreenState extends ConsumerState { +class _GameScreenState extends ConsumerState + with TickerProviderStateMixin { final _boardKey = GlobalKey(); final _stackKey = GlobalKey(); + final _effectsKey = GlobalKey(); + late final AnimationController _shake = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + ); int? _dragIndex; Offset? _dragGlobal; @@ -94,6 +104,12 @@ class _GameScreenState extends ConsumerState { } } + @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 { 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 { 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 { 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 { 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( diff --git a/lib/ui/widgets/board_widget.dart b/lib/ui/widgets/board_widget.dart index 106c7d6..b313d34 100644 --- a/lib/ui/widgets/board_widget.dart +++ b/lib/ui/widgets/board_widget.dart @@ -24,7 +24,6 @@ class _BoardWidgetState extends State List _flashRows = const []; List _flashCols = const []; - int _comboStreak = 0; @override void didUpdateWidget(BoardWidget old) { @@ -35,7 +34,6 @@ class _BoardWidgetState extends State placement.linesCleared > 0) { _flashRows = placement.clearedRows; _flashCols = placement.clearedCols; - _comboStreak = placement.comboStreak; _flash.forward(from: 0); } } @@ -66,26 +64,6 @@ class _BoardWidgetState extends State 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), - ], - ), - ), - ), - ), - ), ], ); }, diff --git a/lib/ui/widgets/effects_overlay.dart b/lib/ui/widgets/effects_overlay.dart new file mode 100644 index 0000000..b187bf9 --- /dev/null +++ b/lib/ui/widgets/effects_overlay.dart @@ -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 createState() => EffectsOverlayState(); +} + +class EffectsOverlayState extends State + 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 = []; + 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; +}