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:
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user