flutterflame

Flutter Web: Flame loadSprite() Loads Incorrect Asset Path (Extra "assets/images/" Prefix)


I'm developing a Flutter game using the Flame Engine that runs on Flutter Web. I declare my assets in pubspec.yaml as follows:

assets:
    - assets/start_screen_ui/
    - assets/start_screen_ui/live_mode.png

My asset folder structure is:

project_root/
└── assets/
    └── start_screen_ui/
         ├── live_mode.png
         ├── career_mode.png
         ├── manual.png
         └── shop.gif

In my code (lib/screens/start_screen.dart), I load a sprite with:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flame/game.dart';
import 'package:flame/components.dart';
import 'package:flame/events.dart';

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

  @override
  Widget build(BuildContext context) {
    SystemChrome.setPreferredOrientations([
      DeviceOrientation.landscapeLeft,
      DeviceOrientation.landscapeRight,
    ]);

    return Scaffold(
      body: GameWidget(
        game: StartScreenGame(),
        overlayBuilderMap: {
          'settings': (context, game) =>
              SettingsOverlay(game: game as StartScreenGame),
          'support': (context, game) =>
              SupportOverlay(game: game as StartScreenGame),
        },
      ),
    );
  }
}

class StartScreenGame extends FlameGame with TapCallbacks {
  late TextComponent gameTitle;
  late Component
      liveModeButton; // Changed to Component to handle both SpriteComponent and RectangleComponent
  late Component careerModeButton;
  late Component gameManualButton;
  late RectangleComponent settingsButton;
  late RectangleComponent logoutButton;
  late Component shopButton;
  late RectangleComponent supportButton;
  late CircleComponent profileCircle;

  @override
  Future<void> onLoad() async {
    await super.onLoad();

    final backgroundGradient = GradientComponent(
      gradient: const LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [Color(0xFF0D47A1), Color(0xFF000000)],
      ),
      size: size,
    );
    add(backgroundGradient);

    gameTitle = TextComponent(
      text: 'The Flash',
      textRenderer: TextPaint(
        style: const TextStyle(
          color: Colors.white,
          fontSize: 32,
          fontWeight: FontWeight.bold,
          shadows: [
            Shadow(
              offset: Offset(2, 2),
              blurRadius: 3.0,
              color: Color.fromARGB(150, 0, 0, 0),
            ),
          ],
        ),
      ),
      anchor: Anchor.center,
      position: Vector2(size.x / 2, size.y * 0.2),
    );
    add(gameTitle);

    final centerX = size.x / 2;
    final buttonBaseY = size.y * 0.45;
    final buttonSize = Vector2(150, 80);
    final buttonSpacing = 160.0;

    // Live Mode button
    try {
      print(
          'Attempting to load live mode sprite from: start_screen_ui/live_mode.png');
      final liveModeSprite = await loadSprite('start_screen_ui/live_mode.png');
      print('Successfully loaded live mode sprite');
      liveModeButton = SpriteComponent(
        sprite: liveModeSprite,
        size: buttonSize,
        position: Vector2(centerX - buttonSpacing * 1.5, buttonBaseY),
        anchor: Anchor.center,
      );
    } catch (e) {
      print('Error loading live_mode.png: $e');
      print('Stack trace: ${StackTrace.current}');
      liveModeButton = RectangleComponent(
        size: buttonSize,
        position: Vector2(centerX - buttonSpacing * 1.5, buttonBaseY),
        paint: Paint()..color = Colors.red.withOpacity(0.7),
        anchor: Anchor.center,
      );
    }
    add(liveModeButton);
    add(TextComponent(
      text: 'Live Mode',
      textRenderer: TextPaint(
        style: const TextStyle(
          color: Colors.white,
          fontSize: 16,
          shadows: [
            Shadow(
              offset: Offset(1, 1),
              blurRadius: 2.0,
              color: Color.fromARGB(150, 0, 0, 0),
            ),
          ],
        ),
      ),
      position: Vector2(
          centerX - buttonSpacing * 1.5, buttonBaseY + buttonSize.y * 0.6),
      anchor: Anchor.center,
    ));

    // Career Mode button
    try {
      print(
          'Attempting to load career mode sprite from: start_screen_ui/career_mode.png');
      final careerModeSprite =
          await loadSprite('start_screen_ui/career_mode.png');
      print('Successfully loaded career mode sprite');
      careerModeButton = SpriteComponent(
        sprite: careerModeSprite,
        size: buttonSize,
        position: Vector2(centerX - buttonSpacing * 0.5, buttonBaseY),
        anchor: Anchor.center,
      );
    } catch (e) {
      print('Error loading career_mode.png: $e');
      print('Stack trace: ${StackTrace.current}');
      careerModeButton = RectangleComponent(
        size: buttonSize,
        position: Vector2(centerX - buttonSpacing * 0.5, buttonBaseY),
        paint: Paint()..color = Colors.blue.withOpacity(0.7),
        anchor: Anchor.center,
      );
    }
    add(careerModeButton);
    add(TextComponent(
      text: 'Career Mode',
      textRenderer: TextPaint(
        style: const TextStyle(
          color: Colors.white,
          fontSize: 16,
          shadows: [
            Shadow(
              offset: Offset(1, 1),
              blurRadius: 2.0,
              color: Color.fromARGB(150, 0, 0, 0),
            ),
          ],
        ),
      ),
      position: Vector2(
          centerX - buttonSpacing * 0.5, buttonBaseY + buttonSize.y * 0.6),
      anchor: Anchor.center,
    ));

    // Game Manual button
    try {
      print(
          'Attempting to load manual sprite from: start_screen_ui/manual.png');
      final manualSprite = await loadSprite('start_screen_ui/manual.png');
      print('Successfully loaded manual sprite');
      gameManualButton = SpriteComponent(
        sprite: manualSprite,
        size: buttonSize,
        position: Vector2(centerX + buttonSpacing * 0.5, buttonBaseY),
        anchor: Anchor.center,
      );
    } catch (e) {
      print('Error loading manual.png: $e');
      print('Stack trace: ${StackTrace.current}');
      gameManualButton = RectangleComponent(
        size: buttonSize,
        position: Vector2(centerX + buttonSpacing * 0.5, buttonBaseY),
        paint: Paint()..color = Colors.green.withOpacity(0.7),
        anchor: Anchor.center,
      );
    }
    add(gameManualButton);
    add(TextComponent(
      text: 'Game Manual',
      textRenderer: TextPaint(
        style: const TextStyle(
          color: Colors.white,
          fontSize: 16,
          shadows: [
            Shadow(
              offset: Offset(1, 1),
              blurRadius: 2.0,
              color: Color.fromARGB(150, 0, 0, 0),
            ),
          ],
        ),
      ),
      position: Vector2(
          centerX + buttonSpacing * 0.5, buttonBaseY + buttonSize.y * 0.6),
      anchor: Anchor.center,
    ));

    // Settings button
    settingsButton = RectangleComponent(
      size: buttonSize,
      position: Vector2(centerX + buttonSpacing * 1.5, buttonBaseY),
      paint: Paint()..color = Colors.grey.withOpacity(0.7),
      anchor: Anchor.center,
    );
    add(settingsButton);
    add(TextComponent(
      text: '⚙️',
      textRenderer: TextPaint(style: const TextStyle(fontSize: 24)),
      position: Vector2(centerX + buttonSpacing * 1.5, buttonBaseY - 10),
      anchor: Anchor.center,
    ));
    add(TextComponent(
      text: 'Settings',
      textRenderer: TextPaint(
        style: const TextStyle(
          color: Colors.white,
          fontSize: 16,
          shadows: [
            Shadow(
              offset: Offset(1, 1),
              blurRadius: 2.0,
              color: Color.fromARGB(150, 0, 0, 0),
            ),
          ],
        ),
      ),
      position: Vector2(
          centerX + buttonSpacing * 1.5, buttonBaseY + buttonSize.y * 0.6),
      anchor: Anchor.center,
    ));

    // Logout button
    logoutButton = RectangleComponent(
      size: Vector2(100, 40),
      position: Vector2(
          centerX + buttonSpacing * 1.5, buttonBaseY + buttonSize.y + 20),
      paint: Paint()..color = Colors.redAccent.withOpacity(0.7),
      anchor: Anchor.center,
    );
    add(logoutButton);
    add(TextComponent(
      text: 'Logout',
      textRenderer: TextPaint(
        style: const TextStyle(
          color: Colors.white,
          fontSize: 14,
          shadows: [
            Shadow(
              offset: Offset(1, 1),
              blurRadius: 2.0,
              color: Color.fromARGB(150, 0, 0, 0),
            ),
          ],
        ),
      ),
      position: Vector2(
          centerX + buttonSpacing * 1.5, buttonBaseY + buttonSize.y + 20),
      anchor: Anchor.center,
    ));

    // Profile circle
    profileCircle = CircleComponent(
      radius: 25,
      position: Vector2(size.x - 40, 40),
      paint: Paint()..color = Colors.white.withOpacity(0.5),
    );
    add(profileCircle);
    add(TextComponent(
      text: '👤',
      textRenderer: TextPaint(style: const TextStyle(fontSize: 24)),
      position: Vector2(size.x - 40, 40),
      anchor: Anchor.center,
    ));

    // Shop button
    try {
      print('Attempting to load shop sprite from: start_screen_ui/shop.gif');
      final shopSprite = await loadSprite('start_screen_ui/shop.gif');
      print('Successfully loaded shop sprite');
      shopButton = SpriteComponent(
        sprite: shopSprite,
        size: Vector2(80, 80),
        position: Vector2(centerX, size.y - 50),
        anchor: Anchor.center,
      );
    } catch (e) {
      print('Error loading shop.gif: $e');
      print('Stack trace: ${StackTrace.current}');
      shopButton = RectangleComponent(
        size: Vector2(80, 80),
        position: Vector2(centerX, size.y - 50),
        paint: Paint()..color = Colors.amber.withOpacity(0.7),
        anchor: Anchor.center,
      );
    }
    add(shopButton);
    add(TextComponent(
      text: '🛒',
      textRenderer: TextPaint(style: const TextStyle(fontSize: 24)),
      position: Vector2(centerX, size.y - 50),
      anchor: Anchor.center,
    ));
    add(TextComponent(
      text: 'Shop',
      textRenderer: TextPaint(
        style: const TextStyle(
          color: Colors.white,
          fontSize: 16,
          shadows: [
            Shadow(
              offset: Offset(1, 1),
              blurRadius: 2.0,
              color: Color.fromARGB(150, 0, 0, 0),
            ),
          ],
        ),
      ),
      position: Vector2(centerX, size.y - 15),
      anchor: Anchor.center,
    ));

    // Support button
    supportButton = RectangleComponent(
      size: Vector2(60, 60),
      position: Vector2(size.x - 40, size.y - 40),
      paint: Paint()..color = Colors.teal.withOpacity(0.7),
      anchor: Anchor.center,
    );
    add(supportButton);
    add(TextComponent(
      text: '❓',
      textRenderer: TextPaint(style: const TextStyle(fontSize: 24)),
      position: Vector2(size.x - 40, size.y - 40),
      anchor: Anchor.center,
    ));
  }

  @override
  void onTapDown(TapDownEvent event) {
    super.onTapDown(event);

    final touchPosition = event.canvasPosition;

    if (liveModeButton.containsPoint(touchPosition)) {
      print('Live Mode pressed');
    } else if (careerModeButton.containsPoint(touchPosition)) {
      print('Career Mode pressed');
    } else if (gameManualButton.containsPoint(touchPosition)) {
      print('Game Manual pressed');
    } else if (settingsButton.containsPoint(touchPosition)) {
      print('Settings pressed');
      overlays.add('settings');
    } else if (logoutButton.containsPoint(touchPosition)) {
      print('Logout pressed');
    } else if (shopButton.containsPoint(touchPosition)) {
      print('Shop pressed');
    } else if (supportButton.containsPoint(touchPosition)) {
      print('Support pressed');
      overlays.add('support');
    } else if (profileCircle.containsPoint(touchPosition)) {
      print('Profile pressed');
    }
  }
}

class GradientComponent extends Component {
  final LinearGradient gradient;
  final Vector2 size;

  GradientComponent({required this.gradient, required this.size});

  @override
  void render(Canvas canvas) {
    final rect = Rect.fromLTWH(0, 0, size.x, size.y);
    final paint = Paint()..shader = gradient.createShader(rect);
    canvas.drawRect(rect, paint);
  }
}

class SettingsOverlay extends StatelessWidget {
  final StartScreenGame game;

  const SettingsOverlay({super.key, required this.game});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        width: 400,
        height: 300,
        padding: const EdgeInsets.all(20),
        decoration: BoxDecoration(
          color: Colors.black.withOpacity(0.8),
          borderRadius: BorderRadius.circular(20),
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Text('Settings',
                style: TextStyle(
                    color: Colors.white,
                    fontSize: 24,
                    fontWeight: FontWeight.bold)),
            const SizedBox(height: 20),
            const ListTile(
              leading: Icon(Icons.volume_up, color: Colors.white),
              title: Text('Sound', style: TextStyle(color: Colors.white)),
              trailing: Icon(Icons.toggle_on, color: Colors.green, size: 40),
            ),
            const ListTile(
              leading: Icon(Icons.vibration, color: Colors.white),
              title: Text('Vibration', style: TextStyle(color: Colors.white)),
              trailing: Icon(Icons.toggle_off, color: Colors.grey, size: 40),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () => game.overlays.remove('settings'),
              child: const Text('Close'),
            ),
          ],
        ),
      ),
    );
  }
}

class SupportOverlay extends StatelessWidget {
  final StartScreenGame game;

  const SupportOverlay({super.key, required this.game});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        width: 400,
        height: 300,
        padding: const EdgeInsets.all(20),
        decoration: BoxDecoration(
          color: Colors.black.withOpacity(0.8),
          borderRadius: BorderRadius.circular(20),
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Text('Support',
                style: TextStyle(
                    color: Colors.white,
                    fontSize: 24,
                    fontWeight: FontWeight.bold)),
            const SizedBox(height: 20),
            const Text('Need help with the game? Contact our support team:',
                style: TextStyle(color: Colors.white),
                textAlign: TextAlign.center),
            const SizedBox(height: 10),
            const Text('support@myproject.com',
                style:
                    TextStyle(color: Colors.blue, fontWeight: FontWeight.bold)),
            const SizedBox(height: 30),
            ElevatedButton(
              onPressed: () => game.overlays.remove('support'),
              child: const Text('Close'),
            ),
          ],
        ),
      ),
    );
  }
}

However, when running on Flutter Web, the asset fails to load with an error like:

Error while trying to load an asset: Flutter Web engine failed to fetch "assets/assets/images/start_screen_ui/manual.png". HTTP request succeeded, but the server responded with HTTP status 404.

It appears that a duplicate path segment (assets/images/) is being prepended. I have verified that my assets are indeed under assets/start_screen_ui/ and my pubspec.yaml doesn't mention an extra assets/images/ folder.

My questions are:

Where could this extra "assets/images/" prefix be coming from? How can I fix my configuration/code so that the correct path (assets/start_screen_ui/manual.png) is used both on mobile and Flutter Web?

build/flutter_assets/AssetManifest.json:

{"assets/start_screen_ui/career_mode.png":["assets/start_screen_ui/career_mode.png"],"assets/start_screen_ui/live_mode.png":["assets/start_screen_ui/live_mode.png"],"assets/start_screen_ui/manual.png":["assets/start_screen_ui/manual.png"],"assets/start_screen_ui/shop.gif":["assets/start_screen_ui/shop.gif"],"packages/cupertino_icons/assets/CupertinoIcons.ttf":["packages/cupertino_icons/assets/CupertinoIcons.ttf"]}

Solution

  • The default setting for the image cache in Flame is to use assets/images/.
    You can change the static image cache prefix by doing Flame.images.prefix = assets/'; or if you're using a custom cache, either set it in the constructor of the cache when you create it or do game.images.prefix = assets/.