Files
BlockSeasons/lib/ui/widgets/effects_overlay.dart
T

276 lines
7.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}