Skip to content

Instantly share code, notes, and snippets.

@callmephil
Last active April 5, 2025 11:34
Show Gist options
  • Select an option

  • Save callmephil/f455d2f9fc6dfc319d79eaa3eb77b692 to your computer and use it in GitHub Desktop.

Select an option

Save callmephil/f455d2f9fc6dfc319d79eaa3eb77b692 to your computer and use it in GitHub Desktop.

Revisions

  1. callmephil revised this gist Apr 3, 2025. No changes.
  2. callmephil revised this gist Apr 3, 2025. 1 changed file with 69 additions and 177 deletions.
    246 changes: 69 additions & 177 deletions fireworks.dart
    Original file line number Diff line number Diff line change
    @@ -1,65 +1,43 @@
    import 'package:flutter/material.dart';
    import 'package:flutter/scheduler.dart'; // Import TickerProvider
    import 'package:flutter/scheduler.dart';
    import 'dart:math';
    import 'dart:async'; // Import Timer
    import 'dart:ui' as ui; // Import dart:ui with alias
    import 'dart:typed_data'; // Import typed data lists for drawRawPoints

    // ==========================================================================
    // Configuration Constants - Tweak settings here!
    // ==========================================================================

    // --- General ---
    const bool kDebugMode = false; // Set true to slow down for debugging

    // --- Launching ---
    const int kLaunchIntervalBaseMs =
    400; // Base time between launches (milliseconds)
    const int kLaunchIntervalRandomMs =
    300; // Random additional time (milliseconds)
    const double kLaunchHorizontalPaddingFactor =
    0.1; // Padding from screen edges (0.0 to 0.5)
    const double kLaunchTargetHeightMinFactor =
    0.15; // Min burst height (% of screen height)
    const double kLaunchTargetHeightMaxFactor =
    0.45; // Max burst height (% of screen height)

    // --- Rocket ---
    const double kRocketInitialVelocityYMin = 9.0; // Min initial upward speed
    const double kRocketInitialVelocityYRandom =
    3.0; // Random additional upward speed
    const double kRocketInitialVelocityXMax = 0.2; // Max horizontal deviation speed
    const double kRocketGravity = 0.18; // Gravity effect on rocket
    const double kRocketDamping = 0.99; // Speed reduction factor per frame
    const double kRocketAcceleration = 0.015; // Slight upward boost
    const int kRocketTrailMaxBaseLength = 10; // Base length of the trail
    const int kRocketTrailBrushDensity =
    1; // How many extra points per side for trail 'brush' (0 = thin line)
    const double kRocketTrailSpread = 1.5; // How wide the trail 'brush' spreads
    const double kRocketTrailStrokeWidth =
    0.8; // Painter stroke width for trail points (THINNER TRAIL)
    final Color kRocketTrailColor = Colors.orangeAccent.withValues(
    alpha: 0.4,
    ); // Trail color and base alpha

    // --- Burst ---
    const int kBurstParticleCountBase = 200; // Base number of particles per burst
    const int kBurstParticleCountRandom = 100; // Random additional particles
    const double kBurstParticleSpeedMin =
    1.5; // Min initial speed of burst particles
    const double kBurstParticleSpeedRandom = 5.5; // Random additional speed
    const double kBurstParticleSizeMin =
    1.0; // Min size of burst particles (for points)
    const double kBurstParticleSizeRandom = 2.0; // Random additional size
    const double kBurstParticleGravity = 0.15; // Gravity effect on burst particles
    const double kBurstParticleDamping = 0.97; // Speed reduction factor per frame
    const double kBurstParticleFadeRate = 0.965; // Alpha reduction factor per frame
    const double kBurstParticleInitialAlphaMin = 0.85; // Minimum starting alpha
    const double kBurstParticleInitialAlphaRandom =
    0.15; // Random additional starting alpha
    const double kBurstStrokeWidth = 1.8; // Painter stroke width for burst points

    // List of possible colors for each firework burst. A single color is chosen per burst.
    import 'dart:async';
    import 'dart:ui' as ui;
    import 'dart:typed_data';

    const bool kDebugMode = false;

    const int kLaunchIntervalBaseMs = 400;
    const int kLaunchIntervalRandomMs = 300;
    const double kLaunchHorizontalPaddingFactor = 0.1;
    const double kLaunchTargetHeightMinFactor = 0.05; // lower value
    const double kLaunchTargetHeightMaxFactor = 0.1; // lower value

    const double kRocketInitialVelocityYMin = 12.0;
    const double kRocketInitialVelocityYRandom = 4.0;
    const double kRocketInitialVelocityXMax = 0.2;
    const double kRocketGravity = 0.16;
    const double kRocketDamping = 0.99;
    const double kRocketAcceleration = 0.015;
    const int kRocketTrailMaxBaseLength = 10;
    const int kRocketTrailBrushDensity = 1;
    const double kRocketTrailSpread = 1.5;
    const double kRocketTrailStrokeWidth = 0.8;
    final Color kRocketTrailColor = Colors.orangeAccent.withValues(alpha: 0.4);

    const int kBurstParticleCountBase = 500;
    const int kBurstParticleCountRandom = 100;
    const double kBurstParticleSpeedMin = 1.5;
    const double kBurstParticleSpeedRandom = 5.5;
    const double kBurstParticleSizeMin = 1.0;
    const double kBurstParticleSizeRandom = 2.0;
    const double kBurstParticleGravity = 0.15;
    const double kBurstParticleDamping = 0.97;
    const double kBurstParticleFadeRate = 0.95;
    const double kBurstParticleInitialAlphaMin = 0.85;
    const double kBurstParticleInitialAlphaRandom = 0.15;
    const double kBurstStrokeWidth = 1.8;

    const List<Color> kBurstColors = [
    Colors.red,
    Colors.redAccent,
    @@ -73,10 +51,6 @@ const List<Color> kBurstColors = [
    Colors.cyan,
    ];

    // ==========================================================================
    // Application Code
    // ==========================================================================

    void main() {
    runApp(const MyApp());
    }
    @@ -94,15 +68,13 @@ class MyApp extends StatelessWidget {
    }
    }

    // Main Widget
    class FireworksDisplay extends StatefulWidget {
    const FireworksDisplay({Key? key}) : super(key: key);

    @override
    State<FireworksDisplay> createState() => _FireworksDisplayState();
    }

    // State using SingleTickerProviderStateMixin for the Ticker
    class _FireworksDisplayState extends State<FireworksDisplay>
    with SingleTickerProviderStateMixin {
    late Ticker _ticker;
    @@ -113,22 +85,17 @@ class _FireworksDisplayState extends State<FireworksDisplay>
    List<Rocket> _rockets = [];
    List<BurstParticle> _burstParticles = [];

    // Prepared lists/maps for rendering
    Map<ui.Color, Float32List>?
    _groupedBurstPoints; // Map for grouped burst points (Color -> Flat list of x,y coordinates)
    Float32List?
    _trailPointsList; // Flat list of x,y coordinates for all trail points
    Map<ui.Color, Float32List>? _groupedBurstPoints;
    Float32List? _trailPointsList;

    @override
    void initState() {
    super.initState();
    _ticker = createTicker(_updateAnimation)..start();
    _startLaunchTimer();

    // Get initial screen size after the first frame
    WidgetsBinding.instance.addPostFrameCallback((_) {
    if (mounted) {
    // Delay slightly to ensure layout is complete
    Future.delayed(const Duration(milliseconds: 50), () {
    if (mounted) {
    setState(() {
    @@ -141,12 +108,12 @@ class _FireworksDisplayState extends State<FireworksDisplay>
    }

    void _startLaunchTimer() {
    _launchTimer?.cancel(); // Cancel previous timer if exists
    _launchTimer?.cancel();
    _launchTimer = Timer.periodic(
    Duration(
    milliseconds:
    kLaunchIntervalBaseMs +
    _random.nextInt(kLaunchIntervalRandomMs + 1), // Use constants
    _random.nextInt(kLaunchIntervalRandomMs + 1),
    ),
    (timer) {
    if (mounted && _screenSize != Size.zero) {
    @@ -159,85 +126,69 @@ class _FireworksDisplayState extends State<FireworksDisplay>
    void _updateAnimation(Duration elapsed) {
    if (!mounted) return;

    // --- Update Rockets ---
    final List<Map<String, dynamic>> burstsToSpawn =
    []; // Store origin and color
    final List<Map<String, dynamic>> burstsToSpawn = [];

    _rockets.removeWhere((rocket) {
    rocket.update();
    if (rocket.hasReachedApex()) {
    // Store position and the rocket's assigned burst color
    burstsToSpawn.add({
    'position': rocket.position,
    'color': rocket.burstColor,
    });
    return true; // Remove rocket
    return true;
    }
    return false;
    });

    // --- Spawn New Bursts ---
    for (var burstData in burstsToSpawn) {
    _spawnBurst(burstData['position'], burstData['color']);
    }

    // --- Update Burst Particles ---
    _burstParticles.removeWhere((particle) {
    particle.update();
    return particle.isDead();
    });

    // --- Prepare data for efficient rendering ---
    _prepareRenderData();

    // Trigger repaint
    setState(() {});
    }

    void _launchFirework() {
    if (_screenSize == Size.zero) return;

    // Calculate launch position using constants
    final double horizontalPadding =
    _screenSize.width * kLaunchHorizontalPaddingFactor;
    final launchX =
    horizontalPadding +
    _random.nextDouble() * (_screenSize.width - 2 * horizontalPadding);
    final launchPosition = Offset(
    launchX,
    _screenSize.height + 10,
    ); // Start just below screen
    final launchPosition = Offset(launchX, _screenSize.height + 10);

    // Calculate target height using constants
    final minHeight = _screenSize.height * kLaunchTargetHeightMinFactor;
    final maxHeight = _screenSize.height * kLaunchTargetHeightMaxFactor;
    final targetHeight =
    minHeight + _random.nextDouble() * (maxHeight - minHeight);

    // *** Select a SINGLE random color for this firework's burst ***
    final Color selectedBurstColor =
    kBurstColors[_random.nextInt(kBurstColors.length)];

    _rockets.add(
    Rocket(
    startPosition: launchPosition,
    targetHeight: targetHeight,
    burstColor: selectedBurstColor, // Pass the chosen color
    burstColor: selectedBurstColor,
    random: _random,
    ),
    );
    }

    void _spawnBurst(Offset position, Color burstColor) {
    // Receive the color
    // Use constants for particle count
    int numberOfParticles =
    kBurstParticleCountBase +
    _random.nextInt(kBurstParticleCountRandom + 1);

    for (int i = 0; i < numberOfParticles; i++) {
    final angle = _random.nextDouble() * 2 * pi;
    // Use constants for speed and size
    final speed =
    kBurstParticleSpeedMin +
    _random.nextDouble() * kBurstParticleSpeedRandom;
    @@ -251,26 +202,22 @@ class _FireworksDisplayState extends State<FireworksDisplay>
    angle: angle,
    speed: speed,
    random: _random,
    color: burstColor, // Use the single color passed to this burst
    color: burstColor,
    size: size,
    ),
    );
    }
    }

    // Prepare data for drawRawPoints (grouping bursts by color, flattening trails)
    void _prepareRenderData() {
    // --- Prepare burst particle data (grouped by color) ---
    final Map<ui.Color, List<Offset>> pointsByColor = {};
    for (final particle in _burstParticles) {
    // Group by the color's integer value for efficient map lookup
    (pointsByColor[particle.color] ??= []).add(particle.position);
    }

    if (pointsByColor.isNotEmpty) {
    _groupedBurstPoints = {}; // Initialize the map for Float32Lists
    _groupedBurstPoints = {};
    pointsByColor.forEach((color, offsets) {
    // Optimization: Only create Float32List if there are points for this color
    if (offsets.isNotEmpty) {
    final pointsList = Float32List(offsets.length * 2);
    for (int i = 0; i < offsets.length; i++) {
    @@ -280,22 +227,18 @@ class _FireworksDisplayState extends State<FireworksDisplay>
    _groupedBurstPoints![color] = pointsList;
    }
    });
    // Remove the entry if list was empty (though the check above prevents this)
    _groupedBurstPoints!.removeWhere((key, value) => value.isEmpty);
    // If after filtering, the map is empty, set it to null
    if (_groupedBurstPoints!.isEmpty) _groupedBurstPoints = null;
    } else {
    _groupedBurstPoints = null;
    }

    // --- Prepare trail particle data (flat list for drawRawPoints) ---
    List<Offset> allTrailPoints = [];
    for (final rocket in _rockets) {
    allTrailPoints.addAll(rocket.trail);
    }

    if (allTrailPoints.isNotEmpty) {
    // Convert List<Offset> to Float32List for drawRawPoints
    _trailPointsList = Float32List(allTrailPoints.length * 2);
    for (int i = 0; i < allTrailPoints.length; i++) {
    _trailPointsList![i * 2] = allTrailPoints[i].dx;
    @@ -315,10 +258,8 @@ class _FireworksDisplayState extends State<FireworksDisplay>

    @override
    Widget build(BuildContext context) {
    // Update screen size if it changes (e.g., orientation change)
    final currentSize = MediaQuery.sizeOf(context);
    if (_screenSize != currentSize && currentSize != Size.zero) {
    // Use addPostFrameCallback to avoid setState during build
    WidgetsBinding.instance.addPostFrameCallback((_) {
    if (mounted) {
    setState(() {
    @@ -328,159 +269,122 @@ class _FireworksDisplayState extends State<FireworksDisplay>
    });
    }

    return Scaffold(
    backgroundColor: Colors.black,
    body: CustomPaint(
    size: Size.infinite, // Painter covers the whole screen
    return Material(
    color: Colors.transparent,
    child: CustomPaint(
    size: Size.infinite,
    painter: _FireworksPainter(
    groupedBurstPoints: _groupedBurstPoints,
    trailPoints: _trailPointsList, // Pass the Float32List
    trailPoints: _trailPointsList,
    ),
    ),
    );
    }
    }

    // --- Rocket Class ---
    class Rocket {
    Offset position;
    final double targetHeight;
    final Color burstColor; // Store the color for the eventual burst
    final Color burstColor;
    final Random random;
    List<Offset> trail = [];
    Offset velocity; // Mutable velocity
    Offset velocity;

    Rocket({
    required Offset startPosition,
    required this.targetHeight,
    required this.burstColor, // Receive burst color
    required this.burstColor,
    required this.random,
    }) : position = startPosition,
    velocity = Offset(
    // Use constants for initial velocity
    (random.nextDouble() - 0.5) *
    (kRocketInitialVelocityXMax * 2), // Centered around 0
    (random.nextDouble() - 0.5) * (kRocketInitialVelocityXMax * 2),
    -(kRocketInitialVelocityYMin +
    random.nextDouble() * kRocketInitialVelocityYRandom),
    );

    void update() {
    // --- Trail Brush Effect ---
    // Use constants for density and spread
    // Add center point
    trail.insert(0, position);
    // Add offset points for brush effect if density > 0
    if (kRocketTrailBrushDensity > 0) {
    for (int i = 0; i < kRocketTrailBrushDensity; i++) {
    double offsetX = (random.nextDouble() - 0.5) * kRocketTrailSpread * 2;
    double offsetY =
    (random.nextDouble() - 0.5) *
    kRocketTrailSpread *
    0.5; // Less vertical jitter
    double offsetY = (random.nextDouble() - 0.5) * kRocketTrailSpread * 0.5;
    trail.insert(0, position + Offset(offsetX, offsetY));
    trail.insert(
    0,
    position + Offset(-offsetX, -offsetY),
    ); // Mirrored point
    trail.insert(0, position + Offset(-offsetX, -offsetY));
    }
    }

    // --- Limit Trail Length ---
    // Adjust max length based on density: base + density*2 points per update
    int dynamicMaxLength =
    (kRocketTrailMaxBaseLength * (1 + kRocketTrailBrushDensity * 2));
    while (trail.length > dynamicMaxLength) {
    trail.removeLast();
    }

    // --- Update Velocity (Mutable) ---
    // Use constants for gravity, acceleration, damping
    velocity = Offset(
    velocity.dx * kRocketDamping,
    (velocity.dy - kRocketAcceleration + kRocketGravity) * kRocketDamping,
    );

    // --- Update Position ---
    position += velocity;
    }

    bool hasReachedApex() {
    // Apex is when upward velocity stops/reverses or target height is passed
    return velocity.dy >= -0.1 || position.dy <= targetHeight;
    }
    }

    // --- Burst Particle Class ---
    class BurstParticle {
    Offset position;
    Color color; // Color is now set fully at creation including alpha
    // final double size; // Size isn't used directly by drawRawPoints, only strokeWidth
    Color color;

    late double _vx;
    late double _vy;
    double _alpha; // Keep alpha for fading logic and isDead check
    double _alpha;

    BurstParticle({
    required this.position,
    required double angle,
    required double speed,
    required Random random,
    required this.color, // Receive the base color for the burst
    required double
    size, // Keep size if you might want varied strokeWidth later
    }) : // this.size = size, // Store if needed later
    _alpha =
    required this.color,
    required double size,
    }) : _alpha =
    kBurstParticleInitialAlphaMin +
    random.nextDouble() * kBurstParticleInitialAlphaRandom {
    _vx = speed * cos(angle);
    _vy = speed * sin(angle);
    // Set the initial color with calculated alpha
    // color = baseColor.withOpacity(_alpha);
    }

    void update() {
    // Use constants for gravity, damping, fade rate
    _vy += kBurstParticleGravity;
    _vx *= kBurstParticleDamping;
    _vy *= kBurstParticleDamping;

    position = Offset(position.dx + _vx, position.dy + _vy);

    // Fade the alpha value internally
    _alpha *= kBurstParticleFadeRate;
    if (_alpha < 0.01) _alpha = 0; // Clamp near zero
    if (_alpha < 0.01) _alpha = 0;

    // Update the particle's color with the new alpha
    // Optimization: Only update color if alpha changed significantly to avoid object creation
    // Note: withOpacity creates a new Color object. If performance critical,
    // consider passing alpha separately to the painter or using shaders.
    // For simplicity here, we update the color object.
    color = color.withValues(alpha: _alpha);
    }

    bool isDead() {
    return _alpha <= 0.01; // Check against internal alpha
    return _alpha <= 0.01;
    }
    }

    // --- Custom Painter ---
    class _FireworksPainter extends CustomPainter {
    final Map<ui.Color, Float32List>? groupedBurstPoints;
    final Float32List? trailPoints;

    // Use constants for stroke widths and trail color
    final Paint _burstPaint =
    Paint()
    ..strokeWidth =
    kBurstStrokeWidth // Use constant
    ..strokeWidth = kBurstStrokeWidth
    ..strokeCap = ui.StrokeCap.round;

    final Paint _trailPaint =
    Paint()
    ..strokeWidth =
    kRocketTrailStrokeWidth // Use constant (THINNER TRAIL)
    ..color =
    kRocketTrailColor // Use constant
    ..strokeWidth = kRocketTrailStrokeWidth
    ..color = kRocketTrailColor
    ..strokeCap = ui.StrokeCap.round;

    _FireworksPainter({
    @@ -490,37 +394,25 @@ class _FireworksPainter extends CustomPainter {

    @override
    void paint(Canvas canvas, Size size) {
    // --- Draw Rocket Trails using drawRawPoints ---
    if (trailPoints != null && trailPoints!.isNotEmpty) {
    // Trail uses a single base color defined in _trailPaint
    canvas.drawRawPoints(ui.PointMode.points, trailPoints!, _trailPaint);
    }

    // --- Draw Burst Particles using multiple drawRawPoints calls ---
    if (groupedBurstPoints != null && groupedBurstPoints!.isNotEmpty) {
    groupedBurstPoints!.forEach((color, points) {
    // The color object already includes alpha from the particle update
    _burstPaint.color = color;
    // Draw this group's points
    canvas.drawRawPoints(ui.PointMode.points, points, _burstPaint);
    });
    }
    }

    @override
    bool shouldRepaint(_FireworksPainter oldDelegate) {
    // Repaint whenever the points data changes.
    // Using identity check (is the map/list object itself different?) is usually
    // sufficient here because we recreate the maps/lists in _prepareRenderData.
    // For more complex scenarios, a deep comparison might be needed, but avoid if possible for performance.
    return oldDelegate.groupedBurstPoints != groupedBurstPoints ||
    oldDelegate.trailPoints != trailPoints;
    }
    }

    // Helper extension to modify Color alpha without creating new objects unnecessarily in BurstParticle update
    // NOTE: Color is immutable, so this still creates a new object, but it's a clear way to express the intent.
    // For extreme optimization, you might manage alpha separately.
    extension ColorAlpha on Color {
    Color withValues({double? alpha, int? r, int? g, int? b}) {
    return Color.fromARGB(
  3. callmephil revised this gist Apr 3, 2025. 1 changed file with 247 additions and 120 deletions.
    367 changes: 247 additions & 120 deletions fireworks.dart
    Original file line number Diff line number Diff line change
    @@ -5,6 +5,78 @@ import 'dart:async'; // Import Timer
    import 'dart:ui' as ui; // Import dart:ui with alias
    import 'dart:typed_data'; // Import typed data lists for drawRawPoints

    // ==========================================================================
    // Configuration Constants - Tweak settings here!
    // ==========================================================================

    // --- General ---
    const bool kDebugMode = false; // Set true to slow down for debugging

    // --- Launching ---
    const int kLaunchIntervalBaseMs =
    400; // Base time between launches (milliseconds)
    const int kLaunchIntervalRandomMs =
    300; // Random additional time (milliseconds)
    const double kLaunchHorizontalPaddingFactor =
    0.1; // Padding from screen edges (0.0 to 0.5)
    const double kLaunchTargetHeightMinFactor =
    0.15; // Min burst height (% of screen height)
    const double kLaunchTargetHeightMaxFactor =
    0.45; // Max burst height (% of screen height)

    // --- Rocket ---
    const double kRocketInitialVelocityYMin = 9.0; // Min initial upward speed
    const double kRocketInitialVelocityYRandom =
    3.0; // Random additional upward speed
    const double kRocketInitialVelocityXMax = 0.2; // Max horizontal deviation speed
    const double kRocketGravity = 0.18; // Gravity effect on rocket
    const double kRocketDamping = 0.99; // Speed reduction factor per frame
    const double kRocketAcceleration = 0.015; // Slight upward boost
    const int kRocketTrailMaxBaseLength = 10; // Base length of the trail
    const int kRocketTrailBrushDensity =
    1; // How many extra points per side for trail 'brush' (0 = thin line)
    const double kRocketTrailSpread = 1.5; // How wide the trail 'brush' spreads
    const double kRocketTrailStrokeWidth =
    0.8; // Painter stroke width for trail points (THINNER TRAIL)
    final Color kRocketTrailColor = Colors.orangeAccent.withValues(
    alpha: 0.4,
    ); // Trail color and base alpha

    // --- Burst ---
    const int kBurstParticleCountBase = 200; // Base number of particles per burst
    const int kBurstParticleCountRandom = 100; // Random additional particles
    const double kBurstParticleSpeedMin =
    1.5; // Min initial speed of burst particles
    const double kBurstParticleSpeedRandom = 5.5; // Random additional speed
    const double kBurstParticleSizeMin =
    1.0; // Min size of burst particles (for points)
    const double kBurstParticleSizeRandom = 2.0; // Random additional size
    const double kBurstParticleGravity = 0.15; // Gravity effect on burst particles
    const double kBurstParticleDamping = 0.97; // Speed reduction factor per frame
    const double kBurstParticleFadeRate = 0.965; // Alpha reduction factor per frame
    const double kBurstParticleInitialAlphaMin = 0.85; // Minimum starting alpha
    const double kBurstParticleInitialAlphaRandom =
    0.15; // Random additional starting alpha
    const double kBurstStrokeWidth = 1.8; // Painter stroke width for burst points

    // List of possible colors for each firework burst. A single color is chosen per burst.
    const List<Color> kBurstColors = [
    Colors.red,
    Colors.redAccent,
    Colors.orangeAccent,
    Colors.yellowAccent,
    Colors.lightGreenAccent,
    Colors.lightBlueAccent,
    Colors.purpleAccent,
    Colors.pinkAccent,
    Colors.white,
    Colors.cyan,
    ];

    // ==========================================================================
    // Application Code
    // ==========================================================================

    void main() {
    runApp(const MyApp());
    }
    @@ -17,7 +89,7 @@ class MyApp extends StatelessWidget {
    return MaterialApp(
    debugShowCheckedModeBanner: false,
    theme: ThemeData.dark(),
    home: FireworksDisplay(),
    home: const FireworksDisplay(),
    );
    }
    }
    @@ -42,19 +114,21 @@ class _FireworksDisplayState extends State<FireworksDisplay>
    List<BurstParticle> _burstParticles = [];

    // Prepared lists/maps for rendering
    // Map for grouped burst points (Color -> Flat list of x,y coordinates)
    Map<ui.Color, Float32List>? _groupedBurstPoints;
    // Flat list of x,y coordinates for all trail points
    Float32List? _trailPointsList;
    Map<ui.Color, Float32List>?
    _groupedBurstPoints; // Map for grouped burst points (Color -> Flat list of x,y coordinates)
    Float32List?
    _trailPointsList; // Flat list of x,y coordinates for all trail points

    @override
    void initState() {
    super.initState();
    _ticker = createTicker(_updateAnimation)..start();
    _startLaunchTimer();

    // Get initial screen size after the first frame
    WidgetsBinding.instance.addPostFrameCallback((_) {
    if (mounted) {
    // Delay getting size slightly
    // Delay slightly to ensure layout is complete
    Future.delayed(const Duration(milliseconds: 50), () {
    if (mounted) {
    setState(() {
    @@ -67,10 +141,13 @@ class _FireworksDisplayState extends State<FireworksDisplay>
    }

    void _startLaunchTimer() {
    _launchTimer?.cancel(); // Cancel previous timer if exists
    _launchTimer = Timer.periodic(
    Duration(
    milliseconds: 500 + _random.nextInt(10),
    ), // Launch more frequently?
    milliseconds:
    kLaunchIntervalBaseMs +
    _random.nextInt(kLaunchIntervalRandomMs + 1), // Use constants
    ),
    (timer) {
    if (mounted && _screenSize != Size.zero) {
    _launchFirework();
    @@ -82,116 +159,141 @@ class _FireworksDisplayState extends State<FireworksDisplay>
    void _updateAnimation(Duration elapsed) {
    if (!mounted) return;

    List<Offset> newBurstOrigins = [];
    // --- Update Rockets ---
    final List<Map<String, dynamic>> burstsToSpawn =
    []; // Store origin and color

    // Update Rockets
    _rockets.removeWhere((rocket) {
    rocket.update();
    if (rocket.hasReachedApex()) {
    newBurstOrigins.add(rocket.position);
    // Store position and the rocket's assigned burst color
    burstsToSpawn.add({
    'position': rocket.position,
    'color': rocket.burstColor,
    });
    return true; // Remove rocket
    }
    return false;
    });

    // Spawn New Bursts
    for (var origin in newBurstOrigins) {
    _spawnBurst(origin);
    // --- Spawn New Bursts ---
    for (var burstData in burstsToSpawn) {
    _spawnBurst(burstData['position'], burstData['color']);
    }

    // Update Burst Particles
    // --- Update Burst Particles ---
    _burstParticles.removeWhere((particle) {
    particle.update();
    return particle.isDead();
    });

    // Prepare data for efficient rendering
    // --- Prepare data for efficient rendering ---
    _prepareRenderData();

    // Trigger repaint
    setState(() {});
    }

    void _launchFirework() {
    if (_screenSize == Size.zero) return;

    // Calculate launch position using constants
    final double horizontalPadding =
    _screenSize.width * kLaunchHorizontalPaddingFactor;
    final launchX =
    _random.nextDouble() * _screenSize.width * 0.8 +
    _screenSize.width * 0.1;
    final launchPosition = Offset(launchX, _screenSize.height + 10);
    horizontalPadding +
    _random.nextDouble() * (_screenSize.width - 2 * horizontalPadding);
    final launchPosition = Offset(
    launchX,
    _screenSize.height + 10,
    ); // Start just below screen

    // Calculate target height using constants
    final minHeight = _screenSize.height * kLaunchTargetHeightMinFactor;
    final maxHeight = _screenSize.height * kLaunchTargetHeightMaxFactor;
    final targetHeight =
    _screenSize.height * (0.15 + _random.nextDouble() * 0.3); // Aim higher?
    minHeight + _random.nextDouble() * (maxHeight - minHeight);

    // *** Select a SINGLE random color for this firework's burst ***
    final Color selectedBurstColor =
    kBurstColors[_random.nextInt(kBurstColors.length)];

    _rockets.add(
    Rocket(
    startPosition: launchPosition,
    targetHeight: targetHeight,
    burstColor: selectedBurstColor, // Pass the chosen color
    random: _random,
    ),
    );
    }

    void _spawnBurst(Offset position) {
    int numberOfParticles = 200 + _random.nextInt(150); // More particles?
    List<Color> fireworkColors = [
    Colors.red,
    Colors.redAccent,
    Colors.orange,
    Colors.orangeAccent,
    Colors.yellow,
    Colors.yellowAccent,
    Colors.white,
    Colors.lightBlueAccent,
    Colors.blue.shade300,
    Colors.greenAccent,
    Colors.purpleAccent.shade100,
    ];
    void _spawnBurst(Offset position, Color burstColor) {
    // Receive the color
    // Use constants for particle count
    int numberOfParticles =
    kBurstParticleCountBase +
    _random.nextInt(kBurstParticleCountRandom + 1);

    for (int i = 0; i < numberOfParticles; i++) {
    final angle = _random.nextDouble() * 2 * pi;
    final speed = _random.nextDouble() * 6.0 + 2.0; // Increased max speed
    // Use constants for speed and size
    final speed =
    kBurstParticleSpeedMin +
    _random.nextDouble() * kBurstParticleSpeedRandom;
    final size =
    _random.nextDouble() * 3 + 1.0; // Smaller base size for points
    final color = fireworkColors[_random.nextInt(fireworkColors.length)];
    kBurstParticleSizeMin +
    _random.nextDouble() * kBurstParticleSizeRandom;

    _burstParticles.add(
    BurstParticle(
    position: position,
    angle: angle,
    speed: speed,
    random: _random,
    color: color,
    color: burstColor, // Use the single color passed to this burst
    size: size,
    ),
    );
    }
    }

    // Prepare data for drawRawPoints (grouping bursts by color)
    // Prepare data for drawRawPoints (grouping bursts by color, flattening trails)
    void _prepareRenderData() {
    // --- Prepare burst particle data (grouped by color) ---
    final Map<ui.Color, List<Offset>> pointsByColor = {};
    for (final particle in _burstParticles) {
    // Group by the integer value for efficient map lookup
    // Group by the color's integer value for efficient map lookup
    (pointsByColor[particle.color] ??= []).add(particle.position);
    }

    if (pointsByColor.isNotEmpty) {
    _groupedBurstPoints = {}; // Initialize the map for Float32Lists
    pointsByColor.forEach((color, offsets) {
    final pointsList = Float32List(offsets.length * 2);
    for (int i = 0; i < offsets.length; i++) {
    pointsList[i * 2] = offsets[i].dx;
    pointsList[i * 2 + 1] = offsets[i].dy;
    // Optimization: Only create Float32List if there are points for this color
    if (offsets.isNotEmpty) {
    final pointsList = Float32List(offsets.length * 2);
    for (int i = 0; i < offsets.length; i++) {
    pointsList[i * 2] = offsets[i].dx;
    pointsList[i * 2 + 1] = offsets[i].dy;
    }
    _groupedBurstPoints![color] = pointsList;
    }
    _groupedBurstPoints![color] = pointsList;
    });
    // Remove the entry if list was empty (though the check above prevents this)
    _groupedBurstPoints!.removeWhere((key, value) => value.isEmpty);
    // If after filtering, the map is empty, set it to null
    if (_groupedBurstPoints!.isEmpty) _groupedBurstPoints = null;
    } else {
    _groupedBurstPoints = null;
    }

    // --- Prepare trail particle data (flat list for drawPoints) ---
    // --- Prepare trail particle data (flat list for drawRawPoints) ---
    List<Offset> allTrailPoints = [];
    for (final rocket in _rockets) {
    allTrailPoints.addAll(rocket.trail);
    }

    if (allTrailPoints.isNotEmpty) {
    // Convert List<Offset> to Float32List for drawRawPoints
    _trailPointsList = Float32List(allTrailPoints.length * 2);
    @@ -213,8 +315,10 @@ class _FireworksDisplayState extends State<FireworksDisplay>

    @override
    Widget build(BuildContext context) {
    final currentSize = MediaQuery.of(context).size;
    // Update screen size if it changes (e.g., orientation change)
    final currentSize = MediaQuery.sizeOf(context);
    if (_screenSize != currentSize && currentSize != Size.zero) {
    // Use addPostFrameCallback to avoid setState during build
    WidgetsBinding.instance.addPostFrameCallback((_) {
    if (mounted) {
    setState(() {
    @@ -227,7 +331,7 @@ class _FireworksDisplayState extends State<FireworksDisplay>
    return Scaffold(
    backgroundColor: Colors.black,
    body: CustomPaint(
    size: Size.infinite,
    size: Size.infinite, // Painter covers the whole screen
    painter: _FireworksPainter(
    groupedBurstPoints: _groupedBurstPoints,
    trailPoints: _trailPointsList, // Pass the Float32List
    @@ -241,143 +345,149 @@ class _FireworksDisplayState extends State<FireworksDisplay>
    class Rocket {
    Offset position;
    final double targetHeight;
    final Color burstColor; // Store the color for the eventual burst
    final Random random;
    List<Offset> trail = [];
    // Increase trail length
    final int _maxTrailLengthBase = 25;
    final Color color = Colors.yellow.shade300; // Slightly richer yellow
    final double gravity = 0.20; // Reduced gravity effect
    final double damping = 1; // Less damping
    // Add upward acceleration
    final double acceleration = 0.01;

    // *** FIX: Make velocity mutable ***
    Offset velocity;
    Offset velocity; // Mutable velocity

    Rocket({
    required Offset startPosition,
    required this.targetHeight,
    required this.burstColor, // Receive burst color
    required this.random,
    }) : position = startPosition,
    // Start with a decent initial velocity
    velocity = Offset(
    random.nextDouble() * 1.0 - 0.5,
    -(9.0 + random.nextDouble() * 5.0), // Stronger initial push
    // Use constants for initial velocity
    (random.nextDouble() - 0.5) *
    (kRocketInitialVelocityXMax * 2), // Centered around 0
    -(kRocketInitialVelocityYMin +
    random.nextDouble() * kRocketInitialVelocityYRandom),
    );

    void update() {
    // --- Trail Brush Effect ---
    int brushDensity = 2; // How many points per side (+ center = 2*N+1)
    double spread = 3; // How wide the brush spreads

    // Use constants for density and spread
    // Add center point
    trail.insert(0, position);
    // Add offset points for brush effect
    for (int i = 0; i < brushDensity; i++) {
    double offsetX = (random.nextDouble() - 0.5) * spread * 2;
    double offsetY =
    (random.nextDouble() - 0.5) * 0.8; // Slight vertical jitter
    trail.insert(0, position + Offset(offsetX, offsetY));
    trail.insert(
    0,
    position + Offset(-offsetX, -offsetY),
    ); // Mirror for symmetry?
    // Add offset points for brush effect if density > 0
    if (kRocketTrailBrushDensity > 0) {
    for (int i = 0; i < kRocketTrailBrushDensity; i++) {
    double offsetX = (random.nextDouble() - 0.5) * kRocketTrailSpread * 2;
    double offsetY =
    (random.nextDouble() - 0.5) *
    kRocketTrailSpread *
    0.5; // Less vertical jitter
    trail.insert(0, position + Offset(offsetX, offsetY));
    trail.insert(
    0,
    position + Offset(-offsetX, -offsetY),
    ); // Mirrored point
    }
    }

    // --- Limit Trail Length ---
    // Adjust max length based on density
    int dynamicMaxLength = (_maxTrailLengthBase * (1 + brushDensity * 2));
    // Adjust max length based on density: base + density*2 points per update
    int dynamicMaxLength =
    (kRocketTrailMaxBaseLength * (1 + kRocketTrailBrushDensity * 2));
    while (trail.length > dynamicMaxLength) {
    trail.removeLast();
    }

    // --- Update Velocity (Mutable) ---
    // Use constants for gravity, acceleration, damping
    velocity = Offset(
    velocity.dx * damping,
    // Apply acceleration upward, counter gravity slightly
    (velocity.dy - acceleration + gravity) * damping,
    velocity.dx * kRocketDamping,
    (velocity.dy - kRocketAcceleration + kRocketGravity) * kRocketDamping,
    );

    // --- Update Position ---
    position += velocity;
    }

    bool hasReachedApex() {
    // Apex is when upward velocity stops (or when position is reached)
    // Apex is when upward velocity stops/reverses or target height is passed
    return velocity.dy >= -0.1 || position.dy <= targetHeight;
    }
    }

    // --- Burst Particle Class --- (Unchanged conceptually)
    // --- Burst Particle Class ---
    class BurstParticle {
    Offset position;
    double angle;
    double speed;
    Random random;
    Color color;
    double size;
    Color color; // Color is now set fully at creation including alpha
    // final double size; // Size isn't used directly by drawRawPoints, only strokeWidth

    late double _vx;
    late double _vy;
    final double _gravity = 0.10;
    double _alpha;
    final double _damping = 0.975;
    final double _fadeRate = 0.96;
    double _alpha; // Keep alpha for fading logic and isDead check

    BurstParticle({
    required this.position,
    required this.angle,
    required this.speed,
    required this.random,
    required this.color,
    required this.size,
    }) : _alpha = 0.9 + random.nextDouble() * 0.1 {
    required double angle,
    required double speed,
    required Random random,
    required this.color, // Receive the base color for the burst
    required double
    size, // Keep size if you might want varied strokeWidth later
    }) : // this.size = size, // Store if needed later
    _alpha =
    kBurstParticleInitialAlphaMin +
    random.nextDouble() * kBurstParticleInitialAlphaRandom {
    _vx = speed * cos(angle);
    _vy = speed * sin(angle);
    color = color.withValues(alpha: _alpha);
    // Set the initial color with calculated alpha
    // color = baseColor.withOpacity(_alpha);
    }

    void update() {
    _vy += _gravity;
    _vx *= _damping;
    _vy *= _damping;
    // Use constants for gravity, damping, fade rate
    _vy += kBurstParticleGravity;
    _vx *= kBurstParticleDamping;
    _vy *= kBurstParticleDamping;

    position = Offset(position.dx + _vx, position.dy + _vy);
    _alpha *= _fadeRate;
    if (_alpha < 0.01) _alpha = 0;

    // Fade the alpha value internally
    _alpha *= kBurstParticleFadeRate;
    if (_alpha < 0.01) _alpha = 0; // Clamp near zero

    // Update the particle's color with the new alpha
    // Optimization: Only update color if alpha changed significantly to avoid object creation
    // Note: withOpacity creates a new Color object. If performance critical,
    // consider passing alpha separately to the painter or using shaders.
    // For simplicity here, we update the color object.
    color = color.withValues(alpha: _alpha);
    }

    bool isDead() {
    return _alpha <= 0;
    return _alpha <= 0.01; // Check against internal alpha
    }
    }

    // --- Custom Painter ---
    class _FireworksPainter extends CustomPainter {
    // Receive grouped burst points and flat trail points
    final Map<ui.Color, Float32List>? groupedBurstPoints;
    final Float32List? trailPoints; // Now Float32List

    _FireworksPainter({
    required this.groupedBurstPoints,
    required this.trailPoints,
    });
    final Float32List? trailPoints;

    // Shared paint object for bursts (color will be overridden)
    // Use constants for stroke widths and trail color
    final Paint _burstPaint =
    Paint()
    ..strokeWidth =
    1.8 // Point size
    kBurstStrokeWidth // Use constant
    ..strokeCap = ui.StrokeCap.round;

    // Paint object for rocket trails (using drawRawPoints now too)
    final Paint _trailPaint =
    Paint()
    ..strokeWidth =
    1.5 // Smaller points for denser trail
    ..color = Colors.yellow.shade400.withValues(
    alpha: 0.5,
    ) // Base trail color+alpha
    kRocketTrailStrokeWidth // Use constant (THINNER TRAIL)
    ..color =
    kRocketTrailColor // Use constant
    ..strokeCap = ui.StrokeCap.round;

    _FireworksPainter({
    required this.groupedBurstPoints,
    required this.trailPoints,
    });

    @override
    void paint(Canvas canvas, Size size) {
    // --- Draw Rocket Trails using drawRawPoints ---
    @@ -389,7 +499,7 @@ class _FireworksPainter extends CustomPainter {
    // --- Draw Burst Particles using multiple drawRawPoints calls ---
    if (groupedBurstPoints != null && groupedBurstPoints!.isNotEmpty) {
    groupedBurstPoints!.forEach((color, points) {
    // Set the color for this group
    // The color object already includes alpha from the particle update
    _burstPaint.color = color;
    // Draw this group's points
    canvas.drawRawPoints(ui.PointMode.points, points, _burstPaint);
    @@ -399,8 +509,25 @@ class _FireworksPainter extends CustomPainter {

    @override
    bool shouldRepaint(_FireworksPainter oldDelegate) {
    // Basic check - repaint if the maps/lists are different objects
    // Repaint whenever the points data changes.
    // Using identity check (is the map/list object itself different?) is usually
    // sufficient here because we recreate the maps/lists in _prepareRenderData.
    // For more complex scenarios, a deep comparison might be needed, but avoid if possible for performance.
    return oldDelegate.groupedBurstPoints != groupedBurstPoints ||
    oldDelegate.trailPoints != trailPoints;
    }
    }

    // Helper extension to modify Color alpha without creating new objects unnecessarily in BurstParticle update
    // NOTE: Color is immutable, so this still creates a new object, but it's a clear way to express the intent.
    // For extreme optimization, you might manage alpha separately.
    extension ColorAlpha on Color {
    Color withValues({double? alpha, int? r, int? g, int? b}) {
    return Color.fromARGB(
    ((alpha ?? this.a / 255.0) * 255).clamp(0, 255).toInt(),
    r ?? this.r.toInt(),
    g ?? this.g.toInt(),
    b ?? this.b.toInt(),
    );
    }
    }
  4. callmephil created this gist Apr 3, 2025.
    406 changes: 406 additions & 0 deletions fireworks.dart
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,406 @@
    import 'package:flutter/material.dart';
    import 'package:flutter/scheduler.dart'; // Import TickerProvider
    import 'dart:math';
    import 'dart:async'; // Import Timer
    import 'dart:ui' as ui; // Import dart:ui with alias
    import 'dart:typed_data'; // Import typed data lists for drawRawPoints

    void main() {
    runApp(const MyApp());
    }

    class MyApp extends StatelessWidget {
    const MyApp({super.key});

    @override
    Widget build(BuildContext context) {
    return MaterialApp(
    debugShowCheckedModeBanner: false,
    theme: ThemeData.dark(),
    home: FireworksDisplay(),
    );
    }
    }

    // Main Widget
    class FireworksDisplay extends StatefulWidget {
    const FireworksDisplay({Key? key}) : super(key: key);

    @override
    State<FireworksDisplay> createState() => _FireworksDisplayState();
    }

    // State using SingleTickerProviderStateMixin for the Ticker
    class _FireworksDisplayState extends State<FireworksDisplay>
    with SingleTickerProviderStateMixin {
    late Ticker _ticker;
    Timer? _launchTimer;
    final Random _random = Random();
    Size _screenSize = Size.zero;

    List<Rocket> _rockets = [];
    List<BurstParticle> _burstParticles = [];

    // Prepared lists/maps for rendering
    // Map for grouped burst points (Color -> Flat list of x,y coordinates)
    Map<ui.Color, Float32List>? _groupedBurstPoints;
    // Flat list of x,y coordinates for all trail points
    Float32List? _trailPointsList;

    @override
    void initState() {
    super.initState();
    _ticker = createTicker(_updateAnimation)..start();
    _startLaunchTimer();
    WidgetsBinding.instance.addPostFrameCallback((_) {
    if (mounted) {
    // Delay getting size slightly
    Future.delayed(const Duration(milliseconds: 50), () {
    if (mounted) {
    setState(() {
    _screenSize = MediaQuery.sizeOf(context);
    });
    }
    });
    }
    });
    }

    void _startLaunchTimer() {
    _launchTimer = Timer.periodic(
    Duration(
    milliseconds: 500 + _random.nextInt(10),
    ), // Launch more frequently?
    (timer) {
    if (mounted && _screenSize != Size.zero) {
    _launchFirework();
    }
    },
    );
    }

    void _updateAnimation(Duration elapsed) {
    if (!mounted) return;

    List<Offset> newBurstOrigins = [];

    // Update Rockets
    _rockets.removeWhere((rocket) {
    rocket.update();
    if (rocket.hasReachedApex()) {
    newBurstOrigins.add(rocket.position);
    return true; // Remove rocket
    }
    return false;
    });

    // Spawn New Bursts
    for (var origin in newBurstOrigins) {
    _spawnBurst(origin);
    }

    // Update Burst Particles
    _burstParticles.removeWhere((particle) {
    particle.update();
    return particle.isDead();
    });

    // Prepare data for efficient rendering
    _prepareRenderData();

    setState(() {});
    }

    void _launchFirework() {
    if (_screenSize == Size.zero) return;
    final launchX =
    _random.nextDouble() * _screenSize.width * 0.8 +
    _screenSize.width * 0.1;
    final launchPosition = Offset(launchX, _screenSize.height + 10);
    final targetHeight =
    _screenSize.height * (0.15 + _random.nextDouble() * 0.3); // Aim higher?
    _rockets.add(
    Rocket(
    startPosition: launchPosition,
    targetHeight: targetHeight,
    random: _random,
    ),
    );
    }

    void _spawnBurst(Offset position) {
    int numberOfParticles = 200 + _random.nextInt(150); // More particles?
    List<Color> fireworkColors = [
    Colors.red,
    Colors.redAccent,
    Colors.orange,
    Colors.orangeAccent,
    Colors.yellow,
    Colors.yellowAccent,
    Colors.white,
    Colors.lightBlueAccent,
    Colors.blue.shade300,
    Colors.greenAccent,
    Colors.purpleAccent.shade100,
    ];

    for (int i = 0; i < numberOfParticles; i++) {
    final angle = _random.nextDouble() * 2 * pi;
    final speed = _random.nextDouble() * 6.0 + 2.0; // Increased max speed
    final size =
    _random.nextDouble() * 3 + 1.0; // Smaller base size for points
    final color = fireworkColors[_random.nextInt(fireworkColors.length)];

    _burstParticles.add(
    BurstParticle(
    position: position,
    angle: angle,
    speed: speed,
    random: _random,
    color: color,
    size: size,
    ),
    );
    }
    }

    // Prepare data for drawRawPoints (grouping bursts by color)
    void _prepareRenderData() {
    // --- Prepare burst particle data (grouped by color) ---
    final Map<ui.Color, List<Offset>> pointsByColor = {};
    for (final particle in _burstParticles) {
    // Group by the integer value for efficient map lookup
    (pointsByColor[particle.color] ??= []).add(particle.position);
    }

    if (pointsByColor.isNotEmpty) {
    _groupedBurstPoints = {}; // Initialize the map for Float32Lists
    pointsByColor.forEach((color, offsets) {
    final pointsList = Float32List(offsets.length * 2);
    for (int i = 0; i < offsets.length; i++) {
    pointsList[i * 2] = offsets[i].dx;
    pointsList[i * 2 + 1] = offsets[i].dy;
    }
    _groupedBurstPoints![color] = pointsList;
    });
    } else {
    _groupedBurstPoints = null;
    }

    // --- Prepare trail particle data (flat list for drawPoints) ---
    List<Offset> allTrailPoints = [];
    for (final rocket in _rockets) {
    allTrailPoints.addAll(rocket.trail);
    }
    if (allTrailPoints.isNotEmpty) {
    // Convert List<Offset> to Float32List for drawRawPoints
    _trailPointsList = Float32List(allTrailPoints.length * 2);
    for (int i = 0; i < allTrailPoints.length; i++) {
    _trailPointsList![i * 2] = allTrailPoints[i].dx;
    _trailPointsList![i * 2 + 1] = allTrailPoints[i].dy;
    }
    } else {
    _trailPointsList = null;
    }
    }

    @override
    void dispose() {
    _ticker.dispose();
    _launchTimer?.cancel();
    super.dispose();
    }

    @override
    Widget build(BuildContext context) {
    final currentSize = MediaQuery.of(context).size;
    if (_screenSize != currentSize && currentSize != Size.zero) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
    if (mounted) {
    setState(() {
    _screenSize = currentSize;
    });
    }
    });
    }

    return Scaffold(
    backgroundColor: Colors.black,
    body: CustomPaint(
    size: Size.infinite,
    painter: _FireworksPainter(
    groupedBurstPoints: _groupedBurstPoints,
    trailPoints: _trailPointsList, // Pass the Float32List
    ),
    ),
    );
    }
    }

    // --- Rocket Class ---
    class Rocket {
    Offset position;
    final double targetHeight;
    final Random random;
    List<Offset> trail = [];
    // Increase trail length
    final int _maxTrailLengthBase = 25;
    final Color color = Colors.yellow.shade300; // Slightly richer yellow
    final double gravity = 0.20; // Reduced gravity effect
    final double damping = 1; // Less damping
    // Add upward acceleration
    final double acceleration = 0.01;

    // *** FIX: Make velocity mutable ***
    Offset velocity;

    Rocket({
    required Offset startPosition,
    required this.targetHeight,
    required this.random,
    }) : position = startPosition,
    // Start with a decent initial velocity
    velocity = Offset(
    random.nextDouble() * 1.0 - 0.5,
    -(9.0 + random.nextDouble() * 5.0), // Stronger initial push
    );

    void update() {
    // --- Trail Brush Effect ---
    int brushDensity = 2; // How many points per side (+ center = 2*N+1)
    double spread = 3; // How wide the brush spreads

    // Add center point
    trail.insert(0, position);
    // Add offset points for brush effect
    for (int i = 0; i < brushDensity; i++) {
    double offsetX = (random.nextDouble() - 0.5) * spread * 2;
    double offsetY =
    (random.nextDouble() - 0.5) * 0.8; // Slight vertical jitter
    trail.insert(0, position + Offset(offsetX, offsetY));
    trail.insert(
    0,
    position + Offset(-offsetX, -offsetY),
    ); // Mirror for symmetry?
    }

    // --- Limit Trail Length ---
    // Adjust max length based on density
    int dynamicMaxLength = (_maxTrailLengthBase * (1 + brushDensity * 2));
    while (trail.length > dynamicMaxLength) {
    trail.removeLast();
    }

    // --- Update Velocity (Mutable) ---
    velocity = Offset(
    velocity.dx * damping,
    // Apply acceleration upward, counter gravity slightly
    (velocity.dy - acceleration + gravity) * damping,
    );

    // --- Update Position ---
    position += velocity;
    }

    bool hasReachedApex() {
    // Apex is when upward velocity stops (or when position is reached)
    return velocity.dy >= -0.1 || position.dy <= targetHeight;
    }
    }

    // --- Burst Particle Class --- (Unchanged conceptually)
    class BurstParticle {
    Offset position;
    double angle;
    double speed;
    Random random;
    Color color;
    double size;
    late double _vx;
    late double _vy;
    final double _gravity = 0.10;
    double _alpha;
    final double _damping = 0.975;
    final double _fadeRate = 0.96;

    BurstParticle({
    required this.position,
    required this.angle,
    required this.speed,
    required this.random,
    required this.color,
    required this.size,
    }) : _alpha = 0.9 + random.nextDouble() * 0.1 {
    _vx = speed * cos(angle);
    _vy = speed * sin(angle);
    color = color.withValues(alpha: _alpha);
    }
    void update() {
    _vy += _gravity;
    _vx *= _damping;
    _vy *= _damping;
    position = Offset(position.dx + _vx, position.dy + _vy);
    _alpha *= _fadeRate;
    if (_alpha < 0.01) _alpha = 0;
    color = color.withValues(alpha: _alpha);
    }

    bool isDead() {
    return _alpha <= 0;
    }
    }

    // --- Custom Painter ---
    class _FireworksPainter extends CustomPainter {
    // Receive grouped burst points and flat trail points
    final Map<ui.Color, Float32List>? groupedBurstPoints;
    final Float32List? trailPoints; // Now Float32List

    _FireworksPainter({
    required this.groupedBurstPoints,
    required this.trailPoints,
    });

    // Shared paint object for bursts (color will be overridden)
    final Paint _burstPaint =
    Paint()
    ..strokeWidth =
    1.8 // Point size
    ..strokeCap = ui.StrokeCap.round;

    // Paint object for rocket trails (using drawRawPoints now too)
    final Paint _trailPaint =
    Paint()
    ..strokeWidth =
    1.5 // Smaller points for denser trail
    ..color = Colors.yellow.shade400.withValues(
    alpha: 0.5,
    ) // Base trail color+alpha
    ..strokeCap = ui.StrokeCap.round;

    @override
    void paint(Canvas canvas, Size size) {
    // --- Draw Rocket Trails using drawRawPoints ---
    if (trailPoints != null && trailPoints!.isNotEmpty) {
    // Trail uses a single base color defined in _trailPaint
    canvas.drawRawPoints(ui.PointMode.points, trailPoints!, _trailPaint);
    }

    // --- Draw Burst Particles using multiple drawRawPoints calls ---
    if (groupedBurstPoints != null && groupedBurstPoints!.isNotEmpty) {
    groupedBurstPoints!.forEach((color, points) {
    // Set the color for this group
    _burstPaint.color = color;
    // Draw this group's points
    canvas.drawRawPoints(ui.PointMode.points, points, _burstPaint);
    });
    }
    }

    @override
    bool shouldRepaint(_FireworksPainter oldDelegate) {
    // Basic check - repaint if the maps/lists are different objects
    return oldDelegate.groupedBurstPoints != groupedBurstPoints ||
    oldDelegate.trailPoints != trailPoints;
    }
    }