f1b8052f77
After Ticker.stop(), elapsed resets to zero on the next start(). _now was left frozen at the old elapsed, so effects added after a drain captured a stale start time and their progress() clamped to 0 forever — ticker never stopped, second batch broken. Fix: reset _now = Duration.zero on drain. Adds @visibleForTesting getters and a regression test that catches the stale value directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
287 lines
8.2 KiB
Dart
287 lines
8.2 KiB
Dart
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);
|
||
}
|
||
|
||
@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 = <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;
|
||
}
|