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
+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(