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); } @visibleForTesting Ticker get ticker => _ticker; @visibleForTesting Duration get now => _now; void _tick(Duration elapsed) { setState(() { _now = elapsed; _fx.removeWhere((e) => e.done(elapsed)); if (_fx.isEmpty) { _ticker.stop(); // Ticker elapsed restarts from zero after stop(); re-anchor so // effects added later don't inherit a stale clock. _now = Duration.zero; } }); } 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; }