
Flame Game Dev
- 124 installs
- 19 repo stars
- Updated January 20, 2026
- miles990/claude-software-skills
For integrating A developer tool for AI integration and automation
About
A developer tool for AI integration and automation. This is a developer tool for building and integrating AI-powered features.
- AI
- Developer tool
Flame Game Dev by the numbers
- 124 all-time installs (skills.sh)
- +4 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #3,744 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/miles990/claude-software-skills --skill flame-game-devAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 124 |
|---|---|
| repo stars | ★ 19 |
| Last updated | January 20, 2026 |
| Repository | miles990/claude-software-skills ↗ |
What it does
For integrating A developer tool for AI integration and automation
Files
Flame Core Fundamentals
Flame Engine 核心基礎,涵蓋組件系統、輸入處理、碰撞檢測、相機、動畫與場景管理。
Quick Start
flutter create my_game && cd my_game
flutter pub add flame
flutter pub add flame_audio # Optional
flutter pub add flame_tiled # Optionalimport 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() => runApp(GameWidget(game: MyGame()));
class MyGame extends FlameGame with HasCollisionDetection {
@override
Future<void> onLoad() async {
camera.viewfinder.anchor = Anchor.topLeft;
}
}Reference Index
| Topic | File | Description |
|---|---|---|
| Components | references/components.md | 組件生命週期、類型、最佳實踐 |
| Input | references/input.md | 觸控、鍵盤、搖桿輸入處理 |
| Collision | references/collision.md | 碰撞檢測、Hitbox 類型 |
| Camera | references/camera.md | 相機設置、跟隨、HUD |
| Animation | references/animation.md | 精靈動畫、Effects 系統 |
| Scenes | references/scenes.md | RouterComponent、Overlays、UI |
| Audio | references/audio.md | 音效、背景音樂、AudioPool |
| Particles | references/particles.md | 粒子系統、特效、爆炸效果 |
| Performance | references/performance.md | 效能優化、最佳實踐、常見問題 |
| Debug | references/debug.md | 除錯模式、日誌、效能監控 |
AI Usage Guide
需要了解組件系統? → Read references/components.md
需要處理輸入? → Read references/input.md
需要碰撞檢測? → Read references/collision.md
需要相機設置? → Read references/camera.md
需要動畫效果? → Read references/animation.md
需要場景管理/UI? → Read references/scenes.md
需要音效/音樂? → Read references/audio.md
需要粒子特效? → Read references/particles.md
需要效能優化? → Read references/performance.md
需要除錯/日誌? → Read references/debug.mdComponent Types Quick Reference
| Type | Use Case |
|---|---|
Component | Logic only |
PositionComponent | Has position/size |
SpriteComponent | Static image |
SpriteAnimationComponent | Animated sprite |
SpriteAnimationGroupComponent | Multiple states |
Related Skills
flame-systems- 14 個遊戲系統(任務、對話、背包等)flame-templates- 遊戲類型模板(RPG、平台、Roguelike)
Animation Reference
Sprite Animation
class Player extends SpriteAnimationComponent {
@override
Future<void> onLoad() async {
animation = await game.loadSpriteAnimation(
'player_run.png',
SpriteAnimationData.sequenced(
amount: 6, // Frame count
stepTime: 0.1, // Seconds per frame
textureSize: Vector2(32, 32),
loop: true,
),
);
size = Vector2(64, 64);
}
}Animation Group (Multi-state)
enum PlayerState { idle, run, jump, attack }
class Player extends SpriteAnimationGroupComponent<PlayerState>
with HasGameRef<MyGame> {
@override
Future<void> onLoad() async {
animations = {
PlayerState.idle: await _loadAnimation('idle.png', 4, 0.15),
PlayerState.run: await _loadAnimation('run.png', 6, 0.1),
PlayerState.jump: await _loadAnimation('jump.png', 2, 0.2),
PlayerState.attack: await _loadAnimation('attack.png', 4, 0.08),
};
current = PlayerState.idle;
}
Future<SpriteAnimation> _loadAnimation(
String src, int frames, double stepTime,
) async {
return game.loadSpriteAnimation(
src,
SpriteAnimationData.sequenced(
amount: frames,
stepTime: stepTime,
textureSize: Vector2(32, 32),
),
);
}
void run() => current = PlayerState.run;
void idle() => current = PlayerState.idle;
}Effects System
// Move effect
component.add(
MoveEffect.to(
Vector2(200, 100),
EffectController(duration: 1.0),
),
);
// Scale effect
component.add(
ScaleEffect.to(
Vector2.all(2.0),
EffectController(duration: 0.5),
),
);
// Rotate effect
component.add(
RotateEffect.by(
tau, // Full rotation
EffectController(duration: 2.0),
),
);
// Opacity effect
component.add(
OpacityEffect.fadeOut(
EffectController(duration: 0.3),
),
);
// Color effect (tint)
component.add(
ColorEffect(
Colors.red,
EffectController(duration: 0.2),
opacityTo: 0.5,
),
);Effect Controllers
// Linear
EffectController(duration: 1.0)
// Curved (ease in/out)
EffectController(
duration: 1.0,
curve: Curves.easeInOut,
)
// Infinite loop
EffectController(
duration: 1.0,
infinite: true,
)
// Repeat N times
EffectController(
duration: 0.5,
repeatCount: 3,
)
// Reverse (ping-pong)
EffectController(
duration: 0.5,
reverseDuration: 0.5,
)
// Sequence
SequenceEffectController([
EffectController(duration: 0.5),
EffectController(duration: 1.0),
])Chained Effects
// Sequential effects
component.add(
SequenceEffect([
MoveEffect.by(Vector2(100, 0), EffectController(duration: 0.5)),
ScaleEffect.to(Vector2.all(1.5), EffectController(duration: 0.3)),
OpacityEffect.fadeOut(EffectController(duration: 0.2)),
RemoveEffect(),
]),
);Effect Callbacks
component.add(
MoveEffect.to(
targetPosition,
EffectController(duration: 1.0),
onComplete: () {
// Called when effect finishes
print('Movement complete!');
},
),
);Sprite Sheet Loading
// From sprite sheet with custom frames
final spriteSheet = SpriteSheet(
image: await images.load('spritesheet.png'),
srcSize: Vector2(32, 32),
);
final animation = spriteSheet.createAnimation(
row: 0,
stepTime: 0.1,
from: 0,
to: 5,
);Audio System
Setup
# pubspec.yaml
dependencies:
flame_audio: ^2.1.0Basic Audio
FlameAudio (Static Methods)
import 'package:flame_audio/flame_audio.dart';
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
// Preload audio files
await FlameAudio.audioCache.loadAll([
'bgm.mp3',
'jump.wav',
'coin.wav',
'explosion.wav',
]);
}
}
// Play sound effect (fire and forget)
FlameAudio.play('coin.wav');
// Play with volume
FlameAudio.play('jump.wav', volume: 0.5);
// Play background music (loops)
FlameAudio.bgm.play('bgm.mp3');
// Stop background music
FlameAudio.bgm.stop();
// Pause/Resume BGM
FlameAudio.bgm.pause();
FlameAudio.bgm.resume();AudioPool (For Frequent Sounds)
class MyGame extends FlameGame {
late AudioPool shootPool;
late AudioPool hitPool;
@override
Future<void> onLoad() async {
// Create audio pools for frequently played sounds
shootPool = await FlameAudio.createPool(
'shoot.wav',
maxPlayers: 4, // Allow 4 simultaneous plays
);
hitPool = await FlameAudio.createPool(
'hit.wav',
maxPlayers: 8,
);
}
void shoot() {
shootPool.start(volume: 0.7);
}
void onHit() {
hitPool.start();
}
}Audio Component
AudioPlayerComponent
class AmbientSound extends PositionComponent with HasGameRef {
late AudioPlayerComponent audioPlayer;
@override
Future<void> onLoad() async {
audioPlayer = AudioPlayerComponent(
source: AssetSource('ambient_forest.mp3'),
volume: 0.3,
isLooping: true,
);
add(audioPlayer);
// Start playing
audioPlayer.player.play();
}
@override
void onRemove() {
audioPlayer.player.stop();
super.onRemove();
}
}Audio Manager Pattern
class AudioManager extends Component with HasGameRef {
static AudioManager? _instance;
static AudioManager get instance => _instance!;
late AudioPool sfxJump;
late AudioPool sfxCoin;
late AudioPool sfxHit;
late AudioPool sfxExplosion;
double _sfxVolume = 1.0;
double _bgmVolume = 0.7;
bool _isMuted = false;
double get sfxVolume => _isMuted ? 0 : _sfxVolume;
double get bgmVolume => _isMuted ? 0 : _bgmVolume;
@override
Future<void> onLoad() async {
_instance = this;
// Initialize audio pools
sfxJump = await FlameAudio.createPool('sfx/jump.wav', maxPlayers: 2);
sfxCoin = await FlameAudio.createPool('sfx/coin.wav', maxPlayers: 4);
sfxHit = await FlameAudio.createPool('sfx/hit.wav', maxPlayers: 4);
sfxExplosion = await FlameAudio.createPool('sfx/explosion.wav', maxPlayers: 3);
}
// Sound Effects
void playJump() => sfxJump.start(volume: sfxVolume);
void playCoin() => sfxCoin.start(volume: sfxVolume);
void playHit() => sfxHit.start(volume: sfxVolume * 0.8);
void playExplosion() => sfxExplosion.start(volume: sfxVolume);
// Background Music
void playBGM(String filename) {
FlameAudio.bgm.play(filename, volume: bgmVolume);
}
void stopBGM() => FlameAudio.bgm.stop();
void pauseBGM() => FlameAudio.bgm.pause();
void resumeBGM() => FlameAudio.bgm.resume();
// Volume Control
void setSfxVolume(double volume) {
_sfxVolume = volume.clamp(0.0, 1.0);
}
void setBgmVolume(double volume) {
_bgmVolume = volume.clamp(0.0, 1.0);
FlameAudio.bgm.audioPlayer?.setVolume(_bgmVolume);
}
void toggleMute() {
_isMuted = !_isMuted;
if (_isMuted) {
FlameAudio.bgm.audioPlayer?.setVolume(0);
} else {
FlameAudio.bgm.audioPlayer?.setVolume(_bgmVolume);
}
}
}
// Usage in game
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
add(AudioManager());
// Start BGM after manager is loaded
AudioManager.instance.playBGM('music/level1.mp3');
}
}
// Usage in components
class Player extends SpriteComponent {
void jump() {
AudioManager.instance.playJump();
// ... jump logic
}
void collectCoin() {
AudioManager.instance.playCoin();
// ... collect logic
}
}Positional Audio
Distance-Based Volume
class PositionalAudioSource extends PositionComponent with HasGameRef {
final String audioFile;
final double maxDistance;
final double baseVolume;
late AudioPlayerComponent _audioPlayer;
PositionalAudioSource({
required this.audioFile,
this.maxDistance = 300,
this.baseVolume = 1.0,
required super.position,
});
@override
Future<void> onLoad() async {
_audioPlayer = AudioPlayerComponent(
source: AssetSource(audioFile),
isLooping: true,
);
add(_audioPlayer);
_audioPlayer.player.play();
}
@override
void update(double dt) {
super.update(dt);
// Get player position
final player = game.children.whereType<Player>().firstOrNull;
if (player == null) return;
// Calculate distance
final distance = position.distanceTo(player.position);
// Calculate volume based on distance
final volume = distance < maxDistance
? baseVolume * (1 - distance / maxDistance)
: 0.0;
_audioPlayer.player.setVolume(volume);
}
}
// Usage: Ambient sound in world
world.add(PositionalAudioSource(
audioFile: 'ambient/waterfall.mp3',
position: Vector2(500, 300),
maxDistance: 200,
));Music Playlist
class MusicPlaylist extends Component {
final List<String> tracks;
int _currentIndex = 0;
bool _isPlaying = false;
MusicPlaylist({required this.tracks});
Future<void> play() async {
if (tracks.isEmpty) return;
_isPlaying = true;
await _playCurrentTrack();
}
Future<void> _playCurrentTrack() async {
if (!_isPlaying) return;
await FlameAudio.bgm.play(tracks[_currentIndex]);
// Listen for track end
FlameAudio.bgm.audioPlayer?.onPlayerComplete.listen((_) {
_nextTrack();
});
}
void _nextTrack() {
_currentIndex = (_currentIndex + 1) % tracks.length;
_playCurrentTrack();
}
void stop() {
_isPlaying = false;
FlameAudio.bgm.stop();
}
void shuffle() {
tracks.shuffle();
_currentIndex = 0;
}
}
// Usage
final playlist = MusicPlaylist(tracks: [
'music/theme1.mp3',
'music/theme2.mp3',
'music/theme3.mp3',
]);
playlist.shuffle();
playlist.play();Audio Settings UI
class AudioSettingsOverlay extends StatefulWidget {
final VoidCallback onClose;
const AudioSettingsOverlay({required this.onClose});
@override
State<AudioSettingsOverlay> createState() => _AudioSettingsOverlayState();
}
class _AudioSettingsOverlayState extends State<AudioSettingsOverlay> {
double _sfxVolume = AudioManager.instance._sfxVolume;
double _bgmVolume = AudioManager.instance._bgmVolume;
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black54,
child: Center(
child: Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Audio Settings', style: TextStyle(fontSize: 24)),
const SizedBox(height: 24),
// SFX Volume
Row(
children: [
const Icon(Icons.volume_up),
const SizedBox(width: 16),
const Text('Sound Effects'),
Expanded(
child: Slider(
value: _sfxVolume,
onChanged: (value) {
setState(() => _sfxVolume = value);
AudioManager.instance.setSfxVolume(value);
},
),
),
],
),
// BGM Volume
Row(
children: [
const Icon(Icons.music_note),
const SizedBox(width: 16),
const Text('Music'),
Expanded(
child: Slider(
value: _bgmVolume,
onChanged: (value) {
setState(() => _bgmVolume = value);
AudioManager.instance.setBgmVolume(value);
},
),
),
],
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: widget.onClose,
child: const Text('Close'),
),
],
),
),
),
),
);
}
}Best Practices
File Organization
assets/
└── audio/
├── sfx/ # Sound effects (.wav)
│ ├── jump.wav
│ ├── coin.wav
│ └── hit.wav
├── music/ # Background music (.mp3/.ogg)
│ ├── menu.mp3
│ └── level1.mp3
└── ambient/ # Ambient sounds (.mp3)
└── forest.mp3Format Recommendations
| Type | Format | Reason |
|---|---|---|
| SFX | .wav | Low latency, no decoding |
| Music | .mp3 / .ogg | Smaller file size |
| Ambient | .mp3 | Smaller file size |
Memory Management
// Preload frequently used sounds at game start
@override
Future<void> onLoad() async {
await FlameAudio.audioCache.loadAll([
'sfx/jump.wav',
'sfx/coin.wav',
// ... frequently used sounds
]);
}
// Clear cache when changing levels (optional)
void onLevelChange() {
FlameAudio.audioCache.clearAll();
// Preload new level sounds
}Platform Considerations
// Check platform for audio support
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
class AudioManager extends Component {
bool get isAudioSupported {
if (kIsWeb) {
// Web has autoplay restrictions
return true; // But may require user interaction first
}
return true;
}
void playWithFallback(String filename) {
try {
FlameAudio.play(filename);
} catch (e) {
debugPrint('Audio playback failed: $e');
}
}
}Camera Reference
Camera Setup
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
// Anchor viewfinder to top-left (default is center)
camera.viewfinder.anchor = Anchor.topLeft;
// Set zoom level
camera.viewfinder.zoom = 2.0;
// Add player to world, then follow
final player = Player();
world.add(player);
camera.follow(player);
}
}Camera Follow
// Basic follow
camera.follow(player);
// Follow with options
camera.follow(
player,
maxSpeed: 200, // Smooth follow speed
horizontalOnly: true, // Only follow X axis
verticalOnly: false, // Only follow Y axis
snap: false, // Instant vs smooth
);
// Stop following
camera.stop();
// Move to position
camera.moveTo(Vector2(500, 300), speed: 100);Viewport Types
| Type | Use Case |
|---|---|
MaxViewport | Fill available space (default) |
FixedResolutionViewport | Fixed game resolution |
FixedAspectRatioViewport | Maintain aspect ratio |
// Fixed resolution (pixel art games)
camera.viewport = FixedResolutionViewport(
resolution: Vector2(320, 180),
);
// Fixed aspect ratio
camera.viewport = FixedAspectRatioViewport(
aspectRatio: 16 / 9,
);HUD Layer (Viewport)
// Add HUD elements to viewport (stays fixed on screen)
@override
Future<void> onLoad() async {
// Health bar in top-left
camera.viewport.add(
HealthBar()
..position = Vector2(10, 10)
..priority = 100,
);
// Score in top-right
camera.viewport.add(
ScoreDisplay()
..anchor = Anchor.topRight
..position = Vector2(size.x - 10, 10),
);
// Joystick (mobile)
camera.viewport.add(joystick);
}Camera Bounds
// Limit camera movement to world bounds
camera.setBounds(
Rectangle.fromLTWH(0, 0, worldWidth, worldHeight),
);
// Remove bounds
camera.setBounds(null);Screen Shake
// Simple shake effect
void shakeCamera() {
camera.viewfinder.add(
MoveEffect.by(
Vector2(5, 5),
EffectController(
duration: 0.05,
reverseDuration: 0.05,
repeatCount: 5,
),
),
);
}Coordinate Conversion
// Screen position to world position
Vector2 screenToWorld(Vector2 screenPos) {
return camera.globalToLocal(screenPos);
}
// World position to screen position
Vector2 worldToScreen(Vector2 worldPos) {
return camera.localToGlobal(worldPos);
}Collision Detection Reference
Enable Collision System
class MyGame extends FlameGame with HasCollisionDetection {
// Collision detection now active
}Hitbox Types
| Type | Shape | Use Case |
|---|---|---|
CircleHitbox | Circle | Characters, balls |
RectangleHitbox | Rectangle | Boxes, platforms |
PolygonHitbox | Custom polygon | Complex shapes |
ScreenHitbox | Screen bounds | World boundaries |
Collision Types
// Active: Checks collisions with all hitboxes
add(CircleHitbox()..collisionType = CollisionType.active);
// Passive: Only collides with active (better for static objects)
add(RectangleHitbox()..collisionType = CollisionType.passive);
// Inactive: No collision
add(CircleHitbox()..collisionType = CollisionType.inactive);Collision Callbacks
class Player extends SpriteComponent with CollisionCallbacks {
@override
Future<void> onLoad() async {
add(CircleHitbox());
}
@override
void onCollisionStart(Set<Vector2> points, PositionComponent other) {
super.onCollisionStart(points, other);
// Called once when collision begins
if (other is Enemy) takeDamage();
if (other is Coin) collectCoin(other);
}
@override
void onCollision(Set<Vector2> points, PositionComponent other) {
super.onCollision(points, other);
// Called every frame while colliding
if (other is Platform) resolveCollision(points, other);
}
@override
void onCollisionEnd(PositionComponent other) {
super.onCollisionEnd(other);
// Called once when collision ends
}
}Platformer Collision Resolution
@override
void onCollision(Set<Vector2> points, PositionComponent other) {
if (other is Platform && points.length == 2) {
// Calculate collision normal
final mid = (points.elementAt(0) + points.elementAt(1)) / 2;
final collisionNormal = absoluteCenter - mid;
final separationDistance = (size.x / 2) - collisionNormal.length;
collisionNormal.normalize();
// Check if landing on top
if (Vector2(0, -1).dot(collisionNormal) > 0.9) {
isOnGround = true;
velocity.y = 0;
}
// Push out of collision
position += collisionNormal.scaled(separationDistance);
}
super.onCollision(points, other);
}Custom Hitbox Size
@override
Future<void> onLoad() async {
// Smaller hitbox than sprite
add(RectangleHitbox(
size: size * 0.8,
position: size * 0.1,
));
// Circle with offset
add(CircleHitbox(
radius: 20,
position: Vector2(size.x / 2, size.y / 2),
anchor: Anchor.center,
));
}Debug Hitboxes
// In game class
debugMode = true; // Shows all hitboxes
// Or per hitbox
add(CircleHitbox()
..renderShape = true
..paint = Paint()..color = Colors.red.withOpacity(0.3)
);Screen Boundary
class MyGame extends FlameGame with HasCollisionDetection {
@override
Future<void> onLoad() async {
// Add screen boundary
add(ScreenHitbox());
}
}
class Player extends SpriteComponent with CollisionCallbacks {
@override
void onCollisionStart(Set<Vector2> points, PositionComponent other) {
if (other is ScreenHitbox) {
// Hit screen edge
velocity = -velocity; // Bounce back
}
}
}Components Reference
Component Lifecycle
class MyComponent extends PositionComponent with HasGameRef<MyGame> {
@override
Future<void> onLoad() async {
// 1. Load assets, add children
await super.onLoad();
}
@override
void onMount() {
// 2. Component added to tree, game reference available
super.onMount();
}
@override
void update(double dt) {
super.update(dt);
// 3. Called every frame
}
@override
void render(Canvas canvas) {
super.render(canvas);
// 4. Draw to canvas
}
@override
void onRemove() {
// 5. Cleanup
super.onRemove();
}
}Component Types
| Type | Use Case | Key Features |
|---|---|---|
Component | Logic only | Lifecycle, children |
PositionComponent | Has position/size | Transform, anchor |
SpriteComponent | Single image | Static visuals |
SpriteAnimationComponent | Animated | Frame-based |
SpriteAnimationGroupComponent | Multi-state | State machine |
TextComponent | Text | Fonts, styles |
ParallaxComponent | Backgrounds | Multiple layers |
Best Practices
DO:
- Use
HasGameRef<MyGame>mixin to access game - Load assets in
onLoad(), not constructor - Clean up in
onRemove() - Use
anchorfor positioning pivot
DON'T:
- Store heavy assets in constructors
- Forget
super.update(dt) - Add components synchronously - use
await
Example: Player Component
class Player extends SpriteAnimationComponent
with HasGameRef<MyGame>, CollisionCallbacks {
Player({required Vector2 position})
: super(
position: position,
size: Vector2.all(64),
anchor: Anchor.center,
);
final double speed = 200;
final Vector2 velocity = Vector2.zero();
@override
Future<void> onLoad() async {
animation = await game.loadSpriteAnimation(
'player.png',
SpriteAnimationData.sequenced(
amount: 4,
stepTime: 0.15,
textureSize: Vector2(32, 32),
),
);
add(RectangleHitbox());
}
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
}Component Tree Query
// Register for efficient querying
@override
void onLoad() {
children.register<Enemy>();
}
// Query registered types
final enemies = children.query<Enemy>();
// Find ancestor
final game = findParent<MyGame>();Priority (Render Order)
add(Background()..priority = 0); // Render first (bottom)
add(Player()..priority = 10); // Middle
add(HUD()..priority = 100); // Render last (top)Debug Reference
Debug Mode
// Enable debug mode for entire game
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
debugMode = true; // Shows hitboxes, bounds, etc.
}
}
// Per-component debug
class Player extends SpriteComponent {
@override
bool get debugMode => true; // Only this component
}Debug Print (Flutter)
// Use debugPrint instead of print (throttled, safe)
debugPrint('Player position: $position');
debugPrint('Velocity: $velocity');
debugPrint('Collision with: ${other.runtimeType}');
// Conditional debug logging
void log(String message) {
if (kDebugMode) {
debugPrint('[MyGame] $message');
}
}Collision Debug
class Player extends SpriteComponent with CollisionCallbacks {
@override
void onCollisionStart(Set<Vector2> points, PositionComponent other) {
debugPrint('=== Collision Start ===');
debugPrint('Player: $position, size: $size');
debugPrint('Other: ${other.runtimeType} at ${other.position}');
debugPrint('Points: $points');
super.onCollisionStart(points, other);
}
}
// Visual hitbox debug
add(CircleHitbox()
..renderShape = true
..paint = (Paint()
..color = Colors.red.withOpacity(0.5)
..style = PaintingStyle.stroke
..strokeWidth = 2)
);Performance Monitoring
class MyGame extends FlameGame {
@override
void update(double dt) {
super.update(dt);
// FPS monitoring
if (kDebugMode) {
final fps = 1 / dt;
if (fps < 30) {
debugPrint('Warning: Low FPS: ${fps.toStringAsFixed(1)}');
}
}
}
// Component count
void logComponentCount() {
debugPrint('World children: ${world.children.length}');
debugPrint('Viewport children: ${camera.viewport.children.length}');
}
}State Logging
// Log state changes
enum PlayerState { idle, run, jump }
class Player extends SpriteAnimationGroupComponent<PlayerState> {
PlayerState _state = PlayerState.idle;
set state(PlayerState newState) {
if (_state != newState) {
debugPrint('Player state: $_state -> $newState');
_state = newState;
current = newState;
}
}
}Input Debug
class Player extends SpriteComponent with KeyboardHandler {
@override
bool onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keys) {
debugPrint('Key event: ${event.runtimeType}');
debugPrint('Active keys: ${keys.map((k) => k.keyLabel).join(", ")}');
return true;
}
}Visual Debug Overlay
class DebugOverlay extends PositionComponent with HasGameRef<MyGame> {
late TextComponent fpsText;
late TextComponent posText;
@override
Future<void> onLoad() async {
fpsText = TextComponent(
text: 'FPS: --',
textRenderer: TextPaint(
style: const TextStyle(color: Colors.yellow, fontSize: 12),
),
);
posText = TextComponent(
text: 'Pos: --',
position: Vector2(0, 15),
textRenderer: TextPaint(
style: const TextStyle(color: Colors.yellow, fontSize: 12),
),
);
addAll([fpsText, posText]);
}
@override
void update(double dt) {
super.update(dt);
fpsText.text = 'FPS: ${(1 / dt).toStringAsFixed(0)}';
final player = game.world.children.query<Player>().firstOrNull;
if (player != null) {
posText.text = 'Pos: ${player.position.x.toInt()}, ${player.position.y.toInt()}';
}
}
}
// Add to viewport
camera.viewport.add(DebugOverlay()..position = Vector2(10, 10));Common Debug Patterns
// Track component lifecycle
class MyComponent extends Component {
@override
Future<void> onLoad() async {
debugPrint('${runtimeType} onLoad');
}
@override
void onMount() {
debugPrint('${runtimeType} onMount');
super.onMount();
}
@override
void onRemove() {
debugPrint('${runtimeType} onRemove');
super.onRemove();
}
}
// Null safety debug
void collectItem(PositionComponent other) {
if (other is! Collectible) {
debugPrint('Warning: Expected Collectible, got ${other.runtimeType}');
return;
}
// Safe to use as Collectible
}Assertions (Dev Only)
@override
Future<void> onLoad() async {
assert(speed > 0, 'Speed must be positive');
assert(health <= maxHealth, 'Health exceeds max');
// Complex assertion with message
assert(() {
if (animation == null) {
debugPrint('Warning: No animation loaded for $runtimeType');
return false;
}
return true;
}());
}Input Reference
Touch & Mouse
Game-level Input
class MyGame extends FlameGame with TapCallbacks, DragCallbacks {
@override
void onTapDown(TapDownEvent event) {
// event.localPosition - relative to game
// event.canvasPosition - relative to canvas
}
@override
void onDragUpdate(DragUpdateEvent event) {
player.position += event.localDelta;
}
}Component-level Input
class Button extends SpriteComponent with TapCallbacks {
@override
void onTapDown(TapDownEvent event) {
// Only triggers if tap is within component bounds
onPressed?.call();
}
}Keyboard Input
class Player extends SpriteComponent with KeyboardHandler {
int horizontalDir = 0;
int verticalDir = 0;
bool isShooting = false;
@override
bool onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keys) {
horizontalDir = 0;
verticalDir = 0;
// Arrow keys
if (keys.contains(LogicalKeyboardKey.arrowLeft)) horizontalDir = -1;
if (keys.contains(LogicalKeyboardKey.arrowRight)) horizontalDir = 1;
if (keys.contains(LogicalKeyboardKey.arrowUp)) verticalDir = -1;
if (keys.contains(LogicalKeyboardKey.arrowDown)) verticalDir = 1;
// WASD alternative
if (keys.contains(LogicalKeyboardKey.keyA)) horizontalDir = -1;
if (keys.contains(LogicalKeyboardKey.keyD)) horizontalDir = 1;
if (keys.contains(LogicalKeyboardKey.keyW)) verticalDir = -1;
if (keys.contains(LogicalKeyboardKey.keyS)) verticalDir = 1;
// Action keys
isShooting = keys.contains(LogicalKeyboardKey.space);
return true; // Event handled
}
@override
void update(double dt) {
super.update(dt);
velocity.x = horizontalDir * speed;
velocity.y = verticalDir * speed;
}
}Virtual Joystick (Mobile)
class MyGame extends FlameGame {
late JoystickComponent joystick;
late Player player;
@override
Future<void> onLoad() async {
joystick = JoystickComponent(
knob: CircleComponent(
radius: 25,
paint: Paint()..color = Colors.blue,
),
background: CircleComponent(
radius: 60,
paint: Paint()..color = Colors.grey.withOpacity(0.5),
),
margin: const EdgeInsets.only(left: 40, bottom: 40),
);
// Add to viewport (HUD layer)
camera.viewport.add(joystick);
}
@override
void update(double dt) {
super.update(dt);
if (!joystick.delta.isZero()) {
player.velocity = joystick.relativeDelta * player.speed;
} else {
player.velocity = Vector2.zero();
}
}
}Input Mixins Summary
| Mixin | Use Case |
|---|---|
TapCallbacks | Tap events |
DragCallbacks | Drag/swipe |
DoubleTapCallbacks | Double tap |
LongPressCallbacks | Long press |
KeyboardHandler | Keyboard input |
HoverCallbacks | Mouse hover |
ScrollCallbacks | Mouse scroll |
Mobile vs Desktop Pattern
class Player extends PositionComponent with KeyboardHandler {
JoystickComponent? joystick;
void setJoystick(JoystickComponent js) => joystick = js;
@override
void update(double dt) {
// Check joystick first (mobile)
if (joystick != null && !joystick!.delta.isZero()) {
velocity = joystick!.relativeDelta * speed;
}
// Keyboard handled via onKeyEvent (desktop)
position += velocity * dt;
}
}Particle System
Built-in ParticleSystemComponent
import 'package:flame/particles.dart';
import 'package:flame/components.dart';
class MyGame extends FlameGame {
void spawnExplosion(Vector2 position) {
final particle = ParticleSystemComponent(
particle: CircleParticle(
radius: 5,
paint: Paint()..color = Colors.orange,
),
position: position,
);
add(particle);
}
}Basic Particle Types
CircleParticle
final particle = CircleParticle(
radius: 10,
paint: Paint()
..color = Colors.red
..style = PaintingStyle.fill,
);ImageParticle
final sprite = await Sprite.load('particle.png');
final particle = ImageParticle(
sprite: sprite,
size: Vector2.all(16),
);SpriteParticle
final sprite = await Sprite.load('spark.png');
final particle = SpriteParticle(
sprite: sprite,
size: Vector2.all(8),
);ComponentParticle
// Use any component as a particle
final particle = ComponentParticle(
component: SpriteComponent(
sprite: await Sprite.load('star.png'),
size: Vector2.all(16),
),
);Particle Behaviors
MovingParticle
final particle = MovingParticle(
from: Vector2.zero(),
to: Vector2(100, -50), // Move right and up
child: CircleParticle(
radius: 5,
paint: Paint()..color = Colors.yellow,
),
);AcceleratedParticle
final particle = AcceleratedParticle(
acceleration: Vector2(0, 100), // Gravity effect
speed: Vector2(50, -100), // Initial velocity
child: CircleParticle(
radius: 4,
paint: Paint()..color = Colors.orange,
),
);ScalingParticle
final particle = ScalingParticle(
to: 0, // Scale from 1 to 0
child: CircleParticle(
radius: 10,
paint: Paint()..color = Colors.blue,
),
);RotatingParticle
final particle = RotatingParticle(
from: 0,
to: pi * 2, // Full rotation
child: SpriteParticle(
sprite: await Sprite.load('star.png'),
size: Vector2.all(16),
),
);FadingParticle (OpacityParticle)
// Using ComputedParticle for opacity
final particle = ComputedParticle(
renderer: (canvas, particle) {
final opacity = 1 - particle.progress;
canvas.drawCircle(
Offset.zero,
10,
Paint()
..color = Colors.white.withOpacity(opacity),
);
},
);Particle Generators
RandomGenerator
final random = Random();
Particle randomParticle() {
return AcceleratedParticle(
acceleration: Vector2(0, 200),
speed: Vector2(
random.nextDouble() * 200 - 100, // -100 to 100
random.nextDouble() * -200 - 50, // -250 to -50
),
child: CircleParticle(
radius: random.nextDouble() * 3 + 2,
paint: Paint()..color = [
Colors.red,
Colors.orange,
Colors.yellow,
][random.nextInt(3)],
),
);
}ComposedParticle
// Combine multiple particles
final particle = ComposedParticle(
children: [
CircleParticle(radius: 10, paint: Paint()..color = Colors.red),
TranslatedParticle(
offset: Vector2(20, 0),
child: CircleParticle(radius: 5, paint: Paint()..color = Colors.blue),
),
],
);Common Effect Patterns
Explosion Effect
class ExplosionEffect extends Component with HasGameRef {
final Vector2 position;
final Random _random = Random();
ExplosionEffect({required this.position});
@override
Future<void> onLoad() async {
// Main burst
add(ParticleSystemComponent(
position: position,
particle: Particle.generate(
count: 30,
lifespan: 0.8,
generator: (i) => AcceleratedParticle(
acceleration: Vector2(0, 150),
speed: Vector2(
_random.nextDouble() * 300 - 150,
_random.nextDouble() * -200 - 100,
),
child: ScalingParticle(
to: 0,
child: CircleParticle(
radius: _random.nextDouble() * 4 + 2,
paint: Paint()..color = [
Colors.orange,
Colors.red,
Colors.yellow,
][_random.nextInt(3)],
),
),
),
),
));
// Smoke
add(ParticleSystemComponent(
position: position,
particle: Particle.generate(
count: 15,
lifespan: 1.2,
generator: (i) => AcceleratedParticle(
acceleration: Vector2(0, -20),
speed: Vector2(
_random.nextDouble() * 60 - 30,
_random.nextDouble() * -50 - 20,
),
child: ComputedParticle(
renderer: (canvas, particle) {
final opacity = 0.5 * (1 - particle.progress);
final radius = 8 + particle.progress * 15;
canvas.drawCircle(
Offset.zero,
radius,
Paint()..color = Colors.grey.withOpacity(opacity),
);
},
),
),
),
));
// Auto remove
Future.delayed(const Duration(seconds: 2), removeFromParent);
}
}
// Usage
world.add(ExplosionEffect(position: enemy.position));Coin Collect Effect
class CoinCollectEffect extends Component {
final Vector2 position;
CoinCollectEffect({required this.position});
@override
Future<void> onLoad() async {
final random = Random();
add(ParticleSystemComponent(
position: position,
particle: Particle.generate(
count: 10,
lifespan: 0.5,
generator: (i) => AcceleratedParticle(
acceleration: Vector2(0, 100),
speed: Vector2(
random.nextDouble() * 100 - 50,
random.nextDouble() * -150 - 50,
),
child: ScalingParticle(
to: 0,
child: CircleParticle(
radius: 3,
paint: Paint()..color = Colors.yellow,
),
),
),
),
));
Future.delayed(const Duration(milliseconds: 600), removeFromParent);
}
}Dust Trail Effect
class DustTrail extends Component with HasGameRef {
final PositionComponent target;
double _spawnTimer = 0;
final double spawnInterval = 0.05;
final Random _random = Random();
DustTrail({required this.target});
@override
void update(double dt) {
super.update(dt);
_spawnTimer += dt;
if (_spawnTimer >= spawnInterval) {
_spawnTimer = 0;
_spawnDust();
}
}
void _spawnDust() {
gameRef.add(ParticleSystemComponent(
position: target.position + Vector2(0, target.size.y / 2),
particle: Particle.generate(
count: 3,
lifespan: 0.3,
generator: (i) => AcceleratedParticle(
acceleration: Vector2(0, -30),
speed: Vector2(
_random.nextDouble() * 20 - 10,
_random.nextDouble() * -20,
),
child: ComputedParticle(
renderer: (canvas, particle) {
final opacity = 0.4 * (1 - particle.progress);
canvas.drawCircle(
Offset.zero,
3 + particle.progress * 4,
Paint()..color = Colors.brown.withOpacity(opacity),
);
},
),
),
),
));
}
}Fire Effect (Continuous)
class FireEffect extends Component with HasGameRef {
final Vector2 position;
double _timer = 0;
final Random _random = Random();
FireEffect({required this.position});
@override
void update(double dt) {
super.update(dt);
_timer += dt;
if (_timer >= 0.03) {
_timer = 0;
_spawnFlame();
}
}
void _spawnFlame() {
gameRef.add(ParticleSystemComponent(
position: position + Vector2(
_random.nextDouble() * 10 - 5,
0,
),
particle: AcceleratedParticle(
acceleration: Vector2(0, -50),
speed: Vector2(
_random.nextDouble() * 20 - 10,
_random.nextDouble() * -80 - 40,
),
lifespan: 0.6,
child: ComputedParticle(
renderer: (canvas, particle) {
final progress = particle.progress;
final color = Color.lerp(
Colors.yellow,
Colors.red.withOpacity(0),
progress,
)!;
final radius = (1 - progress) * 8 + 2;
canvas.drawCircle(
Offset.zero,
radius,
Paint()..color = color,
);
},
),
),
));
}
}Hit Impact Effect
class HitImpact extends Component {
final Vector2 position;
final Vector2 direction;
HitImpact({required this.position, required this.direction});
@override
Future<void> onLoad() async {
final random = Random();
final normalized = direction.normalized();
// Sparks in hit direction
add(ParticleSystemComponent(
position: position,
particle: Particle.generate(
count: 8,
lifespan: 0.3,
generator: (i) {
final spread = (random.nextDouble() - 0.5) * 1.0;
final angle = atan2(normalized.y, normalized.x) + spread;
final speed = 100 + random.nextDouble() * 100;
return MovingParticle(
from: Vector2.zero(),
to: Vector2(cos(angle), sin(angle)) * speed * 0.3,
child: ScalingParticle(
to: 0,
child: CircleParticle(
radius: 2,
paint: Paint()..color = Colors.white,
),
),
);
},
),
));
Future.delayed(const Duration(milliseconds: 400), removeFromParent);
}
}Particle Manager
class ParticleManager extends Component with HasGameRef {
static ParticleManager? _instance;
static ParticleManager get instance => _instance!;
// Preloaded sprites for particle effects
late Sprite sparkSprite;
late Sprite smokeSprite;
late Sprite starSprite;
@override
Future<void> onLoad() async {
_instance = this;
sparkSprite = await Sprite.load('particles/spark.png');
smokeSprite = await Sprite.load('particles/smoke.png');
starSprite = await Sprite.load('particles/star.png');
}
void explosion(Vector2 position) {
gameRef.add(ExplosionEffect(position: position));
}
void coinCollect(Vector2 position) {
gameRef.add(CoinCollectEffect(position: position));
}
void hit(Vector2 position, Vector2 direction) {
gameRef.add(HitImpact(position: position, direction: direction));
}
void spawnSpriteParticles(Vector2 position, Sprite sprite, {int count = 5}) {
final random = Random();
gameRef.add(ParticleSystemComponent(
position: position,
particle: Particle.generate(
count: count,
lifespan: 0.5,
generator: (i) => AcceleratedParticle(
acceleration: Vector2(0, 200),
speed: Vector2(
random.nextDouble() * 100 - 50,
random.nextDouble() * -150 - 50,
),
child: RotatingParticle(
from: 0,
to: random.nextDouble() * pi * 2,
child: ScalingParticle(
to: 0,
child: SpriteParticle(
sprite: sprite,
size: Vector2.all(12),
),
),
),
),
),
));
}
}
// Usage
ParticleManager.instance.explosion(enemy.position);
ParticleManager.instance.coinCollect(coin.position);Advanced: Custom Particle
class CustomParticle extends Particle {
final Paint paint;
final List<Vector2> trail = [];
Vector2 position = Vector2.zero();
Vector2 velocity;
CustomParticle({
required this.velocity,
required Color color,
super.lifespan,
}) : paint = Paint()..color = color;
@override
void update(double dt) {
super.update(dt);
// Update position
position += velocity * dt;
velocity.y += 200 * dt; // gravity
// Store trail
trail.add(position.clone());
if (trail.length > 10) {
trail.removeAt(0);
}
}
@override
void render(Canvas canvas) {
// Draw trail
for (int i = 0; i < trail.length; i++) {
final opacity = i / trail.length * (1 - progress);
final radius = (i / trail.length) * 3;
canvas.drawCircle(
trail[i].toOffset(),
radius,
Paint()..color = paint.color.withOpacity(opacity),
);
}
// Draw main particle
canvas.drawCircle(
position.toOffset(),
4 * (1 - progress),
paint,
);
}
}Performance Tips
Object Pooling
class ParticlePool {
final List<ParticleSystemComponent> _pool = [];
final int maxSize;
ParticlePool({this.maxSize = 50});
ParticleSystemComponent acquire(Particle particle, Vector2 position) {
if (_pool.isNotEmpty) {
final component = _pool.removeLast();
component.particle = particle;
component.position = position;
return component;
}
return ParticleSystemComponent(
particle: particle,
position: position,
);
}
void release(ParticleSystemComponent component) {
if (_pool.length < maxSize) {
_pool.add(component);
}
}
}Particle Count Limits
class LimitedParticleManager extends Component {
static const int maxActiveParticles = 100;
final List<ParticleSystemComponent> _activeParticles = [];
void spawn(ParticleSystemComponent particle) {
// Remove oldest if at limit
while (_activeParticles.length >= maxActiveParticles) {
final oldest = _activeParticles.removeAt(0);
oldest.removeFromParent();
}
_activeParticles.add(particle);
add(particle);
}
@override
void update(double dt) {
super.update(dt);
// Clean up finished particles
_activeParticles.removeWhere((p) => p.isRemoved);
}
}Best Practices
| Tip | Description |
|---|---|
| Limit count | Keep particle count reasonable (< 100 active) |
| Short lifespan | Use 0.3-1.0 second lifespans |
| Simple shapes | CircleParticle is faster than SpriteParticle |
| Pool objects | Reuse ParticleSystemComponent when possible |
| Batch similar | Group similar particles in one system |
| Auto cleanup | Always remove effects after completion |
Performance & Best Practices
Memory Management
Avoid Object Creation Per Frame
// ❌ BAD - Creates new objects every frame
class BadComponent extends PositionComponent {
@override
void update(double dt) {
position += Vector2(10, 20) * dt; // Creates new Vector2
}
@override
void render(Canvas canvas) {
canvas.drawRect(size.toRect(), Paint()); // Creates new Paint
}
}
// ✅ GOOD - Reuse objects
class GoodComponent extends PositionComponent {
final _direction = Vector2(10, 20); // Reuse
final _paint = Paint(); // Reuse
final _tempVector = Vector2.zero(); // Temp for calculations
@override
void update(double dt) {
_tempVector.setFrom(_direction);
_tempVector.scale(dt);
position.add(_tempVector);
}
@override
void render(Canvas canvas) {
canvas.drawRect(size.toRect(), _paint);
}
}Image Cache Management
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
// Preload all images at once
await images.loadAll([
'player.png',
'enemy.png',
'background.png',
'tileset.png',
]);
}
// Clear cache when changing levels
Future<void> clearMemory() async {
images.clearCache();
Flame.assets.clearCache();
}
// Selective cache clear
void unloadLevel(String levelId) {
final levelAssets = getLevelAssets(levelId);
for (final asset in levelAssets) {
images.clear(asset);
}
}
}Component Pooling
class BulletPool {
final List<Bullet> _pool = [];
final int maxSize;
BulletPool({this.maxSize = 100});
Bullet acquire(Vector2 position, Vector2 velocity) {
final bullet = _pool.isNotEmpty
? _pool.removeLast()
: Bullet();
bullet
..position = position
..velocity = velocity
..isActive = true;
return bullet;
}
void release(Bullet bullet) {
bullet.isActive = false;
if (_pool.length < maxSize) {
_pool.add(bullet);
}
}
}
class Bullet extends PositionComponent {
Vector2 velocity = Vector2.zero();
bool isActive = false;
@override
void update(double dt) {
if (!isActive) return;
position += velocity * dt;
}
void returnToPool() {
(gameRef as MyGame).bulletPool.release(this);
removeFromParent();
}
}Component Best Practices
Component Lifecycle
class MyComponent extends PositionComponent with HasGameRef {
late Sprite _sprite;
Timer? _timer;
// 1. Constructor - minimal work only
MyComponent({required super.position});
// 2. onLoad - async initialization (called once)
@override
Future<void> onLoad() async {
_sprite = await Sprite.load('sprite.png');
size = Vector2.all(64);
anchor = Anchor.center;
}
// 3. onMount - when added to component tree
@override
void onMount() {
super.onMount();
_timer = Timer.periodic(Duration(seconds: 1), (_) => doSomething());
}
// 4. update - called every frame
@override
void update(double dt) {
super.update(dt);
// Game logic
}
// 5. render - called every frame after update
@override
void render(Canvas canvas) {
_sprite.render(canvas, size: size);
}
// 6. onRemove - cleanup
@override
void onRemove() {
_timer?.cancel();
super.onRemove();
}
}Priority (Z-Order)
// Lower priority = rendered first (behind)
// Higher priority = rendered last (in front)
class Background extends SpriteComponent {
Background() : super(priority: 0);
}
class GameEntity extends PositionComponent {
GameEntity() : super(priority: 10);
}
class Player extends SpriteComponent {
Player() : super(priority: 20);
}
class UI extends PositionComponent {
UI() : super(priority: 100);
}
// Dynamic priority change
class Item extends SpriteComponent with TapCallbacks {
@override
void onTapDown(TapDownEvent event) {
priority = 50; // Bring to front when tapped
}
}Visibility Optimization
class Enemy extends PositionComponent with HasVisibility {
@override
void update(double dt) {
if (!isVisible) return; // Skip update if not visible
super.update(dt);
// Update logic
}
}
// Cull off-screen components
class CullableComponent extends PositionComponent with HasGameRef {
@override
void update(double dt) {
final camera = gameRef.camera;
final viewport = camera.visibleWorldRect;
// Check if in view
isVisible = viewport.overlaps(toRect());
if (!isVisible) return;
super.update(dt);
}
}Rendering Optimization
Batch Rendering
class TileMap extends Component {
late SpriteBatch _batch;
late Image _tilesetImage;
@override
Future<void> onLoad() async {
_tilesetImage = await Flame.images.load('tileset.png');
_batch = SpriteBatch(_tilesetImage);
// Pre-calculate all tile positions
_buildBatch();
}
void _buildBatch() {
_batch.clear();
for (int y = 0; y < mapHeight; y++) {
for (int x = 0; x < mapWidth; x++) {
final tileId = getTileAt(x, y);
final srcRect = getTileRect(tileId);
_batch.add(
source: srcRect,
offset: Vector2(x * tileSize, y * tileSize),
);
}
}
}
@override
void render(Canvas canvas) {
_batch.render(canvas);
}
}Reduce Draw Calls
// ❌ BAD - Many small draw calls
class BadParticles extends Component {
final List<Particle> particles = [];
@override
void render(Canvas canvas) {
for (final p in particles) {
canvas.drawCircle(p.offset, p.radius, p.paint);
}
}
}
// ✅ GOOD - Batch into single path
class GoodParticles extends Component {
final List<Particle> particles = [];
final _paint = Paint()..color = Colors.yellow;
final _path = Path();
@override
void render(Canvas canvas) {
_path.reset();
for (final p in particles) {
_path.addOval(Rect.fromCircle(center: p.offset, radius: p.radius));
}
canvas.drawPath(_path, _paint);
}
}Performance Monitoring
HasPerformanceTracker
class MyGame extends FlameGame with HasPerformanceTracker {
@override
void update(double dt) {
super.update(dt);
// Monitor performance
if (updateTime > 16) { // > 16ms = below 60fps
debugPrint('Slow update: ${updateTime}ms');
}
}
@override
void render(Canvas canvas) {
super.render(canvas);
if (renderTime > 16) {
debugPrint('Slow render: ${renderTime}ms');
}
}
}FPS Counter
class FpsCounter extends TextComponent with HasGameRef {
int _frameCount = 0;
double _elapsed = 0;
double _fps = 0;
FpsCounter() : super(
position: Vector2(10, 10),
textRenderer: TextPaint(
style: const TextStyle(color: Colors.green, fontSize: 16),
),
);
@override
void update(double dt) {
super.update(dt);
_frameCount++;
_elapsed += dt;
if (_elapsed >= 1.0) {
_fps = _frameCount / _elapsed;
_frameCount = 0;
_elapsed = 0;
text = 'FPS: ${_fps.toStringAsFixed(1)}';
}
}
}Component Count Monitor
class DebugInfo extends TextComponent with HasGameRef {
@override
void update(double dt) {
super.update(dt);
final totalComponents = _countComponents(gameRef);
text = 'Components: $totalComponents';
}
int _countComponents(Component component) {
int count = 1;
for (final child in component.children) {
count += _countComponents(child);
}
return count;
}
}Common Pitfalls
1. Expensive Operations in update()
// ❌ BAD - Heavy computation every frame
@override
void update(double dt) {
final nearestEnemy = findNearestEnemy(); // O(n) search
final path = calculatePath(position, nearestEnemy.position); // Heavy
}
// ✅ GOOD - Cache and throttle
Timer? _pathTimer;
Enemy? _cachedTarget;
List<Vector2>? _cachedPath;
@override
void onMount() {
_pathTimer = Timer.periodic(Duration(milliseconds: 500), (_) {
_cachedTarget = findNearestEnemy();
_cachedPath = calculatePath(position, _cachedTarget?.position);
});
}
@override
void update(double dt) {
// Use cached values
if (_cachedPath != null) {
followPath(_cachedPath!);
}
}2. Too Many Collision Checks
// ❌ BAD - All vs all collision
class MyGame extends FlameGame with HasCollisionDetection {
// Default: every hitbox checks against every other hitbox
}
// ✅ GOOD - Use collision types wisely
class Bullet extends PositionComponent {
@override
Future<void> onLoad() async {
add(RectangleHitbox()
..collisionType = CollisionType.active); // Actively checks
}
}
class Wall extends PositionComponent {
@override
Future<void> onLoad() async {
add(RectangleHitbox()
..collisionType = CollisionType.passive); // Only receives checks
}
}
class Decoration extends PositionComponent {
@override
Future<void> onLoad() async {
add(RectangleHitbox()
..collisionType = CollisionType.inactive); // No collision
}
}3. Memory Leaks
// ❌ BAD - Event listeners not cleaned up
class BadComponent extends Component {
late StreamSubscription _subscription;
@override
void onMount() {
_subscription = eventBus.listen((event) => handleEvent(event));
}
// Missing onRemove - memory leak!
}
// ✅ GOOD - Always cleanup
class GoodComponent extends Component {
late StreamSubscription _subscription;
Timer? _timer;
@override
void onMount() {
_subscription = eventBus.listen((event) => handleEvent(event));
_timer = Timer.periodic(Duration(seconds: 1), tick);
}
@override
void onRemove() {
_subscription.cancel();
_timer?.cancel();
super.onRemove();
}
}Checklist
| Item | Check |
|---|---|
No object creation in update() or render() | ⬜ |
| Images preloaded at startup | ⬜ |
| Object pooling for frequent spawn/destroy | ⬜ |
| Off-screen components culled | ⬜ |
| CollisionType set appropriately | ⬜ |
Event listeners cleaned up in onRemove() | ⬜ |
| Expensive operations throttled/cached | ⬜ |
| FPS monitored during development | ⬜ |
| Batch rendering for many similar objects | ⬜ |
| Priority set for proper render order | ⬜ |
---
Benchmark 數據
根據 Filip Hráček 的 Benchmark(Flutter/Dart 團隊成員),比較 Flutter、Flame、Unity、Godot:
測試環境
- 測試項目:「The Bench」- 模擬真實遊戲場景
- 包含:動畫背景、移動精靈、多個 UI 元素、背景音樂
- 平台:iOS 和 Web
效能比較
| 指標 | Flutter/Flame | Unity | Godot |
|---|---|---|---|
| 啟動時間 | 最快 | 較慢 | 較慢 |
| 最大實體數 | ~數百個 | 數千個 | 數千個 |
| CPU 使用率 | ~35-40% | ~35-40% | ~35-40% |
| 記憶體 (Web) | 較高 | 較低 | 較低 |
| 記憶體 (iOS) | 較低 | 中等 | 中等 |
效能瓶頸
Flame 的主要限制在於實體數量:
// ⚠️ 超過 200-300 個活躍實體時效能明顯下降
// 原因:Dart/Flutter 的渲染管線非為遊戲優化
// ✅ 對策
// 1. 物件池化 - 減少 GC 壓力
// 2. 視野剔除 - 只更新可見物件
// 3. 批次渲染 - 減少 draw calls
// 4. 降低更新頻率 - 非關鍵物件使用 TimerComponent適用場景
基於 Benchmark 結果,Flame 適合:
| 適合 ✅ | 不適合 ❌ |
|---|---|
| 休閒遊戲 (卡牌、解謎) | 彈幕射擊 |
| Hyper-casual | RTS / 大規模戰鬥 |
| 視覺小說 / 互動故事 | 物理模擬密集 |
| 回合制 RPG / 戰棋 | 粒子效果密集 |
| 2D 平台遊戲 (適量敵人) | 3D 遊戲 |
| Flutter App 內嵌小遊戲 | AAA 級遊戲 |
優化目標
| 實體數量 | 預期 FPS | 建議 |
|---|---|---|
| < 50 | 60 FPS | 無需特別優化 |
| 50-150 | 60 FPS | 基本優化 (物件池) |
| 150-300 | 30-60 FPS | 完整優化 (池化+剔除+批次) |
| > 300 | < 30 FPS | 考慮使用 Unity/Godot |
Scenes & UI Reference
RouterComponent (Scene Management)
class MyGame extends FlameGame {
late final RouterComponent router;
@override
Future<void> onLoad() async {
router = RouterComponent(
initialRoute: 'menu',
routes: {
'menu': Route(MenuPage.new),
'game': Route(GamePage.new),
'settings': Route(SettingsPage.new),
'pause': Route(PausePage.new, transparent: true),
},
);
add(router);
}
}
// Navigate between routes
game.router.pushNamed('game');
game.router.pushNamed('pause'); // Overlay on current
game.router.pop(); // Go back
game.router.pushReplacementNamed('menu'); // Replace currentRoute Pages
class MenuPage extends Component with HasGameRef<MyGame> {
@override
Future<void> onLoad() async {
add(TextComponent(
text: 'Main Menu',
position: Vector2(100, 50),
));
add(ButtonComponent(
button: RectangleComponent(size: Vector2(120, 40)),
onPressed: () => game.router.pushNamed('game'),
position: Vector2(100, 150),
));
}
}Overlays (Flutter UI Integration)
// 1. Define overlay widgets
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
// Show overlay
overlays.add('pauseMenu');
// Hide overlay
overlays.remove('pauseMenu');
}
}
// 2. Register in GameWidget
void main() {
runApp(
GameWidget<MyGame>(
game: MyGame(),
overlayBuilderMap: {
'pauseMenu': (context, game) => PauseMenu(game: game),
'gameOver': (context, game) => GameOverScreen(game: game),
'hud': (context, game) => GameHUD(game: game),
},
initialActiveOverlays: const ['hud'],
),
);
}
// 3. Create Flutter widget overlays
class PauseMenu extends StatelessWidget {
final MyGame game;
const PauseMenu({required this.game});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(10),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('PAUSED', style: TextStyle(fontSize: 32, color: Colors.white)),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
game.overlays.remove('pauseMenu');
game.resumeEngine();
},
child: const Text('Resume'),
),
ElevatedButton(
onPressed: () {
game.overlays.remove('pauseMenu');
game.router.pushReplacementNamed('menu');
},
child: const Text('Main Menu'),
),
],
),
),
);
}
}In-Game UI Components
// Text display
class ScoreDisplay extends TextComponent with HasGameRef {
int _score = 0;
@override
Future<void> onLoad() async {
text = 'Score: 0';
textRenderer = TextPaint(
style: const TextStyle(
fontSize: 24,
color: Colors.white,
fontFamily: 'PressStart2P',
),
);
}
void updateScore(int score) {
_score = score;
text = 'Score: $_score';
}
}
// Health bar
class HealthBar extends PositionComponent {
double maxHealth = 100;
double currentHealth = 100;
@override
void render(Canvas canvas) {
// Background
canvas.drawRect(
Rect.fromLTWH(0, 0, 100, 10),
Paint()..color = Colors.grey,
);
// Health
canvas.drawRect(
Rect.fromLTWH(0, 0, 100 * (currentHealth / maxHealth), 10),
Paint()..color = Colors.green,
);
}
}
// Button component
class GameButton extends PositionComponent with TapCallbacks {
final VoidCallback onPressed;
final String label;
GameButton({required this.label, required this.onPressed});
@override
Future<void> onLoad() async {
size = Vector2(120, 40);
add(RectangleComponent(
size: size,
paint: Paint()..color = Colors.blue,
));
add(TextComponent(
text: label,
position: size / 2,
anchor: Anchor.center,
));
}
@override
void onTapDown(TapDownEvent event) => onPressed();
}NineTileBox (Scalable UI)
class DialogBox extends NineTileBoxComponent {
@override
Future<void> onLoad() async {
nineTileBox = NineTileBox(
await Sprite.load('dialog_box.png'),
tileSize: 16, // Corner/edge tile size
destTileSize: 32, // Scaled size
);
size = Vector2(300, 150);
}
}Scene Transitions
// Fade transition
game.router.pushNamed(
'game',
// Custom transition (requires extension)
);
// Manual transition with effects
class FadeTransition extends Component {
@override
Future<void> onLoad() async {
final overlay = RectangleComponent(
size: game.size,
paint: Paint()..color = Colors.black.withOpacity(0),
);
add(overlay);
overlay.add(
OpacityEffect.to(
1.0,
EffectController(duration: 0.5),
onComplete: () {
// Switch scene
game.router.pushReplacementNamed('nextScene');
// Fade out
overlay.add(OpacityEffect.to(
0.0,
EffectController(duration: 0.5),
onComplete: () => removeFromParent(),
));
},
),
);
}
}Pause/Resume Game
// Pause game loop
game.pauseEngine();
// Resume game loop
game.resumeEngine();
// Check if paused
if (game.paused) { ... }Achievement System Reference
Data Structure
enum AchievementCategory { combat, exploration, collection, story, social }
enum AchievementRarity { bronze, silver, gold, platinum }
class AchievementData {
final String id;
final String title;
final String description;
final String iconPath;
final AchievementCategory category;
final AchievementRarity rarity;
final int points;
final bool isHidden;
final AchievementCondition condition;
final AchievementReward? reward;
}
class AchievementCondition {
final String type; // 'count', 'flag', 'multi'
final String targetId; // What to track
final int requiredValue; // Target count
final List<AchievementCondition>? subConditions; // For 'multi' type
}
class AchievementReward {
final int gold;
final int exp;
final List<String> itemIds;
final String? titleId; // Unlocked title/badge
}
class AchievementProgress {
final AchievementData data;
int currentValue = 0;
bool isUnlocked = false;
DateTime? unlockedAt;
double get progress => currentValue / data.condition.requiredValue;
bool get isComplete => currentValue >= data.condition.requiredValue;
}Achievement Manager
class AchievementManager extends Component {
final Map<String, AchievementProgress> _achievements = {};
int totalPoints = 0;
List<AchievementProgress> get unlocked =>
_achievements.values.where((a) => a.isUnlocked).toList();
List<AchievementProgress> get inProgress =>
_achievements.values.where((a) => !a.isUnlocked && !a.data.isHidden).toList();
void loadAchievements(List<AchievementData> achievements) {
for (final achievement in achievements) {
_achievements[achievement.id] = AchievementProgress(data: achievement);
}
}
void updateProgress(String type, String targetId, int amount) {
for (final progress in _achievements.values) {
if (progress.isUnlocked) continue;
final condition = progress.data.condition;
if (condition.type == type && condition.targetId == targetId) {
progress.currentValue += amount;
if (progress.isComplete) {
_unlock(progress);
} else {
onProgressUpdated?.call(progress);
}
}
}
}
void setFlag(String flagId) {
for (final progress in _achievements.values) {
if (progress.isUnlocked) continue;
final condition = progress.data.condition;
if (condition.type == 'flag' && condition.targetId == flagId) {
progress.currentValue = 1;
_unlock(progress);
}
}
}
void _unlock(AchievementProgress progress) {
progress.isUnlocked = true;
progress.unlockedAt = DateTime.now();
totalPoints += progress.data.points;
_grantReward(progress.data.reward);
onAchievementUnlocked?.call(progress);
}
void _grantReward(AchievementReward? reward) {
if (reward == null) return;
// Grant gold, exp, items via game reference
}
// Callbacks
void Function(AchievementProgress)? onAchievementUnlocked;
void Function(AchievementProgress)? onProgressUpdated;
}JSON Data Format
{
"achievements": [
{
"id": "first_kill",
"title": "First Blood",
"description": "Defeat your first enemy",
"iconPath": "achievements/first_kill.png",
"category": "combat",
"rarity": "bronze",
"points": 10,
"isHidden": false,
"condition": { "type": "count", "targetId": "enemy_killed", "requiredValue": 1 },
"reward": { "gold": 100, "exp": 50 }
},
{
"id": "monster_slayer",
"title": "Monster Slayer",
"description": "Defeat 100 enemies",
"iconPath": "achievements/monster_slayer.png",
"category": "combat",
"rarity": "silver",
"points": 50,
"isHidden": false,
"condition": { "type": "count", "targetId": "enemy_killed", "requiredValue": 100 },
"reward": { "gold": 500, "exp": 200, "items": ["trophy_sword"] }
},
{
"id": "secret_area",
"title": "???",
"description": "???",
"iconPath": "achievements/secret.png",
"category": "exploration",
"rarity": "gold",
"points": 100,
"isHidden": true,
"condition": { "type": "flag", "targetId": "found_secret_area", "requiredValue": 1 }
}
]
}Achievement Popup
class AchievementPopup extends PositionComponent {
final AchievementProgress achievement;
double displayTime = 0;
static const displayDuration = 3.0;
@override
Future<void> onLoad() async {
// Slide in from right
position = Vector2(game.size.x, 20);
add(MoveEffect.to(
Vector2(game.size.x - 320, 20),
EffectController(duration: 0.3, curve: Curves.easeOut),
));
}
@override
void update(double dt) {
super.update(dt);
displayTime += dt;
if (displayTime >= displayDuration) {
// Slide out
add(MoveEffect.to(
Vector2(game.size.x, 20),
EffectController(duration: 0.3, curve: Curves.easeIn),
onComplete: removeFromParent,
));
}
}
@override
void render(Canvas canvas) {
// Background
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, 300, 80),
Radius.circular(10),
),
Paint()..color = _getRarityColor(achievement.data.rarity),
);
// Icon
// ...
// Text
_drawText(canvas, 'Achievement Unlocked!', Vector2(80, 10), size: 12);
_drawText(canvas, achievement.data.title, Vector2(80, 30), size: 16);
_drawText(canvas, '+${achievement.data.points} points', Vector2(80, 55), size: 12);
}
Color _getRarityColor(AchievementRarity rarity) {
return switch (rarity) {
AchievementRarity.bronze => Color(0xFFCD7F32),
AchievementRarity.silver => Color(0xFFC0C0C0),
AchievementRarity.gold => Color(0xFFFFD700),
AchievementRarity.platinum => Color(0xFFE5E4E2),
};
}
}Achievement List UI
class AchievementListUI extends PositionComponent {
final AchievementManager manager;
AchievementCategory? selectedCategory;
@override
void render(Canvas canvas) {
// Category tabs
double x = 0;
for (final category in AchievementCategory.values) {
final isSelected = category == selectedCategory;
_drawTab(canvas, category.name, Vector2(x, 0), isSelected);
x += 100;
}
// Achievement list
final achievements = _getFilteredAchievements();
double y = 50;
for (final achievement in achievements) {
_drawAchievementRow(canvas, achievement, Vector2(0, y));
y += 70;
}
// Total points
_drawText(canvas, 'Total: ${manager.totalPoints} points', Vector2(0, size.y - 30));
}
void _drawAchievementRow(Canvas canvas, AchievementProgress progress, Vector2 pos) {
final data = progress.data;
// Background
final color = progress.isUnlocked ? Colors.green.withOpacity(0.3) : Colors.grey.withOpacity(0.3);
canvas.drawRect(Rect.fromLTWH(pos.x, pos.y, size.x, 60), Paint()..color = color);
// Icon (greyed out if locked)
// ...
// Title & description
final title = data.isHidden && !progress.isUnlocked ? '???' : data.title;
final desc = data.isHidden && !progress.isUnlocked ? 'Hidden achievement' : data.description;
_drawText(canvas, title, pos + Vector2(70, 10));
_drawText(canvas, desc, pos + Vector2(70, 30), size: 12, color: Colors.grey);
// Progress bar (if not unlocked)
if (!progress.isUnlocked && !data.isHidden) {
_drawProgressBar(canvas, progress.progress, pos + Vector2(70, 45), 200);
}
}
List<AchievementProgress> _getFilteredAchievements() {
if (selectedCategory == null) {
return manager._achievements.values.toList();
}
return manager._achievements.values
.where((a) => a.data.category == selectedCategory)
.toList();
}
}Integration
// When enemy is killed
void onEnemyKilled(Enemy enemy) {
achievementManager.updateProgress('count', 'enemy_killed', 1);
achievementManager.updateProgress('count', 'enemy_${enemy.type}_killed', 1);
}
// When area is discovered
void onAreaDiscovered(String areaId) {
achievementManager.setFlag('discovered_$areaId');
}
// When item is collected
void onItemCollected(Item item) {
achievementManager.updateProgress('count', 'item_collected', 1);
achievementManager.updateProgress('count', 'item_${item.type}_collected', 1);
}Combat System Reference
Data Structure
class CombatStats {
int hp;
int maxHp;
int mp;
int maxMp;
int attack;
int defense;
int speed;
int critRate; // Percentage
int critDamage; // Percentage bonus
CombatStats({
required this.maxHp,
required this.maxMp,
required this.attack,
required this.defense,
this.speed = 100,
this.critRate = 5,
this.critDamage = 150,
}) : hp = maxHp, mp = maxMp;
}
class DamageResult {
final int damage;
final bool isCrit;
final bool isMiss;
final DamageType type;
}
enum DamageType { physical, magical, pure }
enum CombatState { idle, attacking, defending, stunned, dead }Damage Calculation
class DamageCalculator {
static DamageResult calculate({
required CombatStats attacker,
required CombatStats defender,
required int baseDamage,
DamageType type = DamageType.physical,
}) {
// Miss check (based on speed difference)
final hitChance = 95 + (attacker.speed - defender.speed) ~/ 10;
if (Random().nextInt(100) >= hitChance) {
return DamageResult(damage: 0, isCrit: false, isMiss: true, type: type);
}
// Base damage
int damage = baseDamage + attacker.attack;
// Defense reduction (physical only)
if (type == DamageType.physical) {
damage = (damage * (100 / (100 + defender.defense))).round();
}
// Critical hit
final isCrit = Random().nextInt(100) < attacker.critRate;
if (isCrit) {
damage = (damage * attacker.critDamage / 100).round();
}
// Minimum damage
damage = damage.clamp(1, 9999);
return DamageResult(damage: damage, isCrit: isCrit, isMiss: false, type: type);
}
}Combat Entity (Enemy/Player)
abstract class CombatEntity extends SpriteAnimationGroupComponent<CombatState>
with HasGameRef {
late CombatStats stats;
CombatState state = CombatState.idle;
bool get isAlive => stats.hp > 0;
bool get isDead => !isAlive;
void takeDamage(DamageResult result) {
if (result.isMiss) {
showFloatingText('MISS', Colors.grey);
return;
}
stats.hp -= result.damage;
stats.hp = stats.hp.clamp(0, stats.maxHp);
// Visual feedback
showFloatingText(
result.isCrit ? '${result.damage}!' : '${result.damage}',
result.isCrit ? Colors.orange : Colors.white,
);
// Flash red
add(ColorEffect(
Colors.red,
EffectController(duration: 0.1),
opacityTo: 0.5,
));
if (isDead) {
onDeath();
}
}
void heal(int amount) {
stats.hp += amount;
stats.hp = stats.hp.clamp(0, stats.maxHp);
showFloatingText('+$amount', Colors.green);
}
void showFloatingText(String text, Color color) {
add(FloatingDamageText(text, color));
}
void onDeath() {
state = CombatState.dead;
current = CombatState.dead;
}
}Turn-Based Combat
class TurnBasedCombat extends Component {
final List<CombatEntity> participants = [];
int currentTurnIndex = 0;
bool isPlayerTurn = false;
CombatEntity get currentTurn => participants[currentTurnIndex];
void startCombat(List<CombatEntity> entities) {
participants.clear();
participants.addAll(entities);
// Sort by speed
participants.sort((a, b) => b.stats.speed.compareTo(a.stats.speed));
currentTurnIndex = 0;
_startTurn();
}
void _startTurn() {
final entity = currentTurn;
if (entity.isDead) {
nextTurn();
return;
}
isPlayerTurn = entity is Player;
onTurnStarted?.call(entity);
if (!isPlayerTurn) {
// AI takes action
_executeAI(entity as Enemy);
}
}
void playerAction(CombatAction action) {
if (!isPlayerTurn) return;
_executeAction(currentTurn, action);
}
void _executeAction(CombatEntity actor, CombatAction action) {
switch (action.type) {
case ActionType.attack:
final result = DamageCalculator.calculate(
attacker: actor.stats,
defender: action.target!.stats,
baseDamage: action.baseDamage,
);
action.target!.takeDamage(result);
break;
case ActionType.skill:
// Execute skill effect
action.skill!.execute(actor, action.target);
break;
case ActionType.item:
// Use item
action.item!.use(action.target ?? actor);
break;
case ActionType.defend:
actor.state = CombatState.defending;
// Defense buff until next turn
break;
case ActionType.flee:
if (Random().nextInt(100) < 50) {
endCombat(CombatResult.fled);
}
break;
}
// Check combat end
if (_checkCombatEnd()) return;
nextTurn();
}
void nextTurn() {
do {
currentTurnIndex = (currentTurnIndex + 1) % participants.length;
} while (currentTurn.isDead);
_startTurn();
}
bool _checkCombatEnd() {
final enemies = participants.whereType<Enemy>();
final players = participants.whereType<Player>();
if (enemies.every((e) => e.isDead)) {
endCombat(CombatResult.victory);
return true;
}
if (players.every((p) => p.isDead)) {
endCombat(CombatResult.defeat);
return true;
}
return false;
}
void Function(CombatEntity)? onTurnStarted;
void Function(CombatResult)? onCombatEnded;
}Action-Based Combat (Real-time)
class ActionCombat extends Component {
final Player player;
final List<Enemy> enemies = [];
double attackCooldown = 0;
static const attackCooldownTime = 0.5;
@override
void update(double dt) {
super.update(dt);
if (attackCooldown > 0) {
attackCooldown -= dt;
}
// Check player attack input
if (player.isAttacking && attackCooldown <= 0) {
_performAttack();
attackCooldown = attackCooldownTime;
}
}
void _performAttack() {
// Find enemies in attack range
final attackRange = 50.0;
for (final enemy in enemies) {
if (player.position.distanceTo(enemy.position) < attackRange) {
final result = DamageCalculator.calculate(
attacker: player.stats,
defender: enemy.stats,
baseDamage: player.weapon?.damage ?? 10,
);
enemy.takeDamage(result);
if (enemy.isDead) {
_onEnemyKilled(enemy);
}
}
}
}
void _onEnemyKilled(Enemy enemy) {
// Drop loot
enemy.dropLoot();
// Grant exp
player.gainExp(enemy.expReward);
enemies.remove(enemy);
}
}Floating Damage Text
class FloatingDamageText extends TextComponent {
FloatingDamageText(String text, Color color)
: super(
text: text,
textRenderer: TextPaint(
style: TextStyle(fontSize: 20, color: color, fontWeight: FontWeight.bold),
),
anchor: Anchor.center,
);
@override
Future<void> onLoad() async {
add(MoveEffect.by(
Vector2(0, -50),
EffectController(duration: 1.0, curve: Curves.easeOut),
));
add(OpacityEffect.fadeOut(
EffectController(duration: 1.0),
onComplete: removeFromParent,
));
}
}Combat UI
class CombatUI extends PositionComponent {
final TurnBasedCombat combat;
@override
void render(Canvas canvas) {
// Draw action menu when player turn
if (combat.isPlayerTurn) {
_drawActionMenu(canvas);
}
// Draw turn order
_drawTurnOrder(canvas);
}
void _drawActionMenu(Canvas canvas) {
final actions = ['Attack', 'Skill', 'Item', 'Defend', 'Flee'];
double y = 0;
for (final action in actions) {
_drawButton(canvas, action, Vector2(0, y));
y += 40;
}
}
}Crafting System Reference
Data Structure
class Recipe {
final String id;
final String resultItemId;
final int resultQuantity;
final List<RecipeIngredient> ingredients;
final int? requiredLevel;
final String? requiredStation; // Crafting station ID
final double craftTime; // Seconds
final int exp; // Crafting exp gained
}
class RecipeIngredient {
final String itemId;
final int quantity;
}
class CraftingStation {
final String id;
final String name;
final List<String> availableRecipes;
}Crafting Manager
class CraftingManager extends Component {
final Map<String, Recipe> _recipes = {};
final Map<String, CraftingStation> _stations = {};
final InventoryManager inventory;
List<String> _unlockedRecipes = [];
Recipe? _currentCraft;
double _craftProgress = 0;
bool get isCrafting => _currentCraft != null;
void loadRecipes(List<Recipe> recipes) {
for (final recipe in recipes) {
_recipes[recipe.id] = recipe;
}
}
void loadStations(List<CraftingStation> stations) {
for (final station in stations) {
_stations[station.id] = station;
}
}
void unlockRecipe(String recipeId) {
if (!_unlockedRecipes.contains(recipeId)) {
_unlockedRecipes.add(recipeId);
onRecipeUnlocked?.call(_recipes[recipeId]!);
}
}
List<Recipe> getAvailableRecipes([String? stationId]) {
if (stationId != null) {
final station = _stations[stationId];
return station?.availableRecipes
.map((id) => _recipes[id])
.whereType<Recipe>()
.where((r) => _unlockedRecipes.contains(r.id))
.toList() ?? [];
}
return _recipes.values
.where((r) => _unlockedRecipes.contains(r.id))
.toList();
}
CraftResult canCraft(String recipeId) {
final recipe = _recipes[recipeId];
if (recipe == null) return CraftResult.recipeNotFound;
if (!_unlockedRecipes.contains(recipeId)) {
return CraftResult.recipeLocked;
}
// Check ingredients
for (final ingredient in recipe.ingredients) {
if (!inventory.hasItem(ingredient.itemId, ingredient.quantity)) {
return CraftResult.missingIngredients;
}
}
return CraftResult.canCraft;
}
void startCraft(String recipeId) {
if (isCrafting) return;
final result = canCraft(recipeId);
if (result != CraftResult.canCraft) {
onCraftFailed?.call(result);
return;
}
final recipe = _recipes[recipeId]!;
// Consume ingredients
for (final ingredient in recipe.ingredients) {
inventory.removeItem(ingredient.itemId, ingredient.quantity);
}
_currentCraft = recipe;
_craftProgress = 0;
onCraftStarted?.call(recipe);
}
void cancelCraft() {
if (!isCrafting) return;
// Return ingredients
for (final ingredient in _currentCraft!.ingredients) {
final item = ItemDatabase.get(ingredient.itemId);
if (item != null) {
inventory.addItem(item, ingredient.quantity);
}
}
_currentCraft = null;
_craftProgress = 0;
}
@override
void update(double dt) {
if (!isCrafting) return;
_craftProgress += dt;
if (_craftProgress >= _currentCraft!.craftTime) {
_completeCraft();
}
}
void _completeCraft() {
final recipe = _currentCraft!;
final resultItem = ItemDatabase.get(recipe.resultItemId);
if (resultItem != null) {
inventory.addItem(resultItem, recipe.resultQuantity);
}
onCraftCompleted?.call(recipe);
_currentCraft = null;
_craftProgress = 0;
}
double get craftProgress => isCrafting
? (_craftProgress / _currentCraft!.craftTime).clamp(0, 1)
: 0;
// Callbacks
void Function(Recipe)? onRecipeUnlocked;
void Function(Recipe)? onCraftStarted;
void Function(Recipe)? onCraftCompleted;
void Function(CraftResult)? onCraftFailed;
}
enum CraftResult { canCraft, recipeNotFound, recipeLocked, missingIngredients, alreadyCrafting }JSON Data Format
{
"recipes": [
{
"id": "recipe_health_potion",
"resultItemId": "potion_health",
"resultQuantity": 1,
"ingredients": [
{ "itemId": "herb_red", "quantity": 2 },
{ "itemId": "bottle_empty", "quantity": 1 }
],
"craftTime": 3.0,
"exp": 10
},
{
"id": "recipe_iron_sword",
"resultItemId": "sword_iron",
"resultQuantity": 1,
"ingredients": [
{ "itemId": "iron_ingot", "quantity": 3 },
{ "itemId": "wood_handle", "quantity": 1 }
],
"requiredStation": "station_forge",
"craftTime": 10.0,
"exp": 25
}
],
"stations": [
{
"id": "station_alchemy",
"name": "Alchemy Table",
"availableRecipes": ["recipe_health_potion", "recipe_mana_potion"]
},
{
"id": "station_forge",
"name": "Forge",
"availableRecipes": ["recipe_iron_sword", "recipe_iron_armor"]
}
]
}Crafting UI
class CraftingUI extends PositionComponent with TapCallbacks {
final CraftingManager craftingManager;
final String? stationId;
String selectedRecipeId = '';
@override
void render(Canvas canvas) {
// Recipe list
final recipes = craftingManager.getAvailableRecipes(stationId);
double y = 10;
for (final recipe in recipes) {
_drawRecipeRow(canvas, recipe, Vector2(10, y));
y += 70;
}
// Selected recipe details
if (selectedRecipeId.isNotEmpty) {
_drawRecipeDetails(canvas, Vector2(size.x - 280, 10));
}
// Crafting progress
if (craftingManager.isCrafting) {
_drawCraftingProgress(canvas, Vector2(size.x / 2 - 100, size.y - 50));
}
}
void _drawRecipeRow(Canvas canvas, Recipe recipe, Vector2 pos) {
final canCraft = craftingManager.canCraft(recipe.id) == CraftResult.canCraft;
final bg = canCraft ? Colors.green.withOpacity(0.2) : Colors.grey.withOpacity(0.2);
canvas.drawRect(Rect.fromLTWH(pos.x, pos.y, 300, 60), Paint()..color = bg);
final resultItem = ItemDatabase.get(recipe.resultItemId)!;
_drawText(canvas, resultItem.name, pos + Vector2(70, 10));
_drawText(canvas, 'x${recipe.resultQuantity}', pos + Vector2(70, 30), size: 12);
// Ingredient icons (small)
double x = 200;
for (final ing in recipe.ingredients) {
_drawIngredientIcon(canvas, ing, pos + Vector2(x, 20));
x += 30;
}
}
void _drawRecipeDetails(Canvas canvas, Vector2 pos) {
final recipe = craftingManager._recipes[selectedRecipeId]!;
final resultItem = ItemDatabase.get(recipe.resultItemId)!;
// Panel background
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(pos.x, pos.y, 260, 350), Radius.circular(8)),
Paint()..color = Colors.black.withOpacity(0.8),
);
// Result item
_drawText(canvas, resultItem.name, pos + Vector2(10, 10), size: 18);
_drawText(canvas, resultItem.description, pos + Vector2(10, 35), size: 11);
// Ingredients
_drawText(canvas, 'Materials:', pos + Vector2(10, 100), size: 14);
double y = 120;
for (final ing in recipe.ingredients) {
final item = ItemDatabase.get(ing.itemId)!;
final hasEnough = inventory.hasItem(ing.itemId, ing.quantity);
final owned = inventory.getItemCount(ing.itemId);
final color = hasEnough ? Colors.green : Colors.red;
_drawText(
canvas,
'${item.name}: $owned/${ing.quantity}',
pos + Vector2(10, y),
size: 12,
color: color,
);
y += 20;
}
// Craft time
_drawText(canvas, 'Time: ${recipe.craftTime}s', pos + Vector2(10, 250), size: 12);
// Craft button
final canCraft = craftingManager.canCraft(selectedRecipeId) == CraftResult.canCraft;
_drawButton(canvas, 'Craft', pos + Vector2(80, 290), enabled: canCraft);
}
void _drawCraftingProgress(Canvas canvas, Vector2 pos) {
// Progress bar
canvas.drawRect(
Rect.fromLTWH(pos.x, pos.y, 200, 20),
Paint()..color = Colors.grey,
);
canvas.drawRect(
Rect.fromLTWH(pos.x, pos.y, 200 * craftingManager.craftProgress, 20),
Paint()..color = Colors.green,
);
// Cancel button
_drawButton(canvas, 'Cancel', pos + Vector2(210, 0));
}
}Recipe Discovery
class RecipeDiscovery {
final CraftingManager craftingManager;
// Discover recipe by trying combinations
Recipe? tryDiscover(List<String> itemIds) {
for (final recipe in craftingManager._recipes.values) {
if (_matchesIngredients(recipe, itemIds)) {
if (!craftingManager._unlockedRecipes.contains(recipe.id)) {
craftingManager.unlockRecipe(recipe.id);
return recipe;
}
}
}
return null;
}
bool _matchesIngredients(Recipe recipe, List<String> itemIds) {
final recipeItems = recipe.ingredients.map((i) => i.itemId).toSet();
return recipeItems.containsAll(itemIds) && itemIds.toSet().containsAll(recipeItems);
}
}Batch Crafting
extension BatchCrafting on CraftingManager {
int getMaxCraftable(String recipeId) {
final recipe = _recipes[recipeId];
if (recipe == null) return 0;
int maxAmount = 999;
for (final ingredient in recipe.ingredients) {
final available = inventory.getItemCount(ingredient.itemId);
final possible = available ~/ ingredient.quantity;
maxAmount = min(maxAmount, possible);
}
return maxAmount;
}
void craftMultiple(String recipeId, int count) {
final max = getMaxCraftable(recipeId);
final toCraft = min(count, max);
for (int i = 0; i < toCraft; i++) {
startCraft(recipeId);
// In practice, queue these or process instantly
}
}
}Dialogue System Reference
Data Structure
class DialogueNode {
final String id;
final String speakerId;
final String text;
final List<DialogueChoice> choices;
final String? nextNodeId; // For linear dialogue
final DialogueAction? action;
bool get hasChoices => choices.isNotEmpty;
}
class DialogueChoice {
final String text;
final String nextNodeId;
final String? condition; // Optional requirement
}
class DialogueAction {
final String type; // 'give_quest', 'give_item', 'set_flag'
final Map<String, dynamic> params;
}
class Dialogue {
final String id;
final Map<String, DialogueNode> nodes;
final String startNodeId;
}Dialogue Manager
class DialogueManager extends Component {
final Map<String, Dialogue> _dialogues = {};
Dialogue? _currentDialogue;
DialogueNode? _currentNode;
bool get isActive => _currentDialogue != null;
void loadDialogues(Map<String, Dialogue> dialogues) {
_dialogues.addAll(dialogues);
}
void startDialogue(String dialogueId) {
_currentDialogue = _dialogues[dialogueId];
if (_currentDialogue != null) {
_showNode(_currentDialogue!.startNodeId);
onDialogueStarted?.call(_currentDialogue!);
}
}
void _showNode(String nodeId) {
_currentNode = _currentDialogue?.nodes[nodeId];
if (_currentNode != null) {
_executeAction(_currentNode!.action);
onNodeChanged?.call(_currentNode!);
}
}
void selectChoice(int index) {
if (_currentNode == null || index >= _currentNode!.choices.length) return;
final choice = _currentNode!.choices[index];
if (choice.nextNodeId == 'end') {
endDialogue();
} else {
_showNode(choice.nextNodeId);
}
}
void advance() {
if (_currentNode?.hasChoices == true) return; // Wait for choice
if (_currentNode?.nextNodeId == null || _currentNode?.nextNodeId == 'end') {
endDialogue();
} else {
_showNode(_currentNode!.nextNodeId!);
}
}
void endDialogue() {
final dialogue = _currentDialogue;
_currentDialogue = null;
_currentNode = null;
onDialogueEnded?.call(dialogue!);
}
void _executeAction(DialogueAction? action) {
if (action == null) return;
onActionTriggered?.call(action);
}
// Callbacks
void Function(Dialogue)? onDialogueStarted;
void Function(Dialogue)? onDialogueEnded;
void Function(DialogueNode)? onNodeChanged;
void Function(DialogueAction)? onActionTriggered;
}JSON Data Format
{
"dialogues": {
"npc_elder_intro": {
"startNodeId": "node_1",
"nodes": {
"node_1": {
"speakerId": "elder",
"text": "Welcome, young adventurer! Our village needs your help.",
"nextNodeId": "node_2"
},
"node_2": {
"speakerId": "elder",
"text": "Will you help us?",
"choices": [
{ "text": "Of course!", "nextNodeId": "node_accept" },
{ "text": "What's in it for me?", "nextNodeId": "node_reward" },
{ "text": "Not interested.", "nextNodeId": "end" }
]
},
"node_accept": {
"speakerId": "elder",
"text": "Wonderful! Please clear the rats from the cellar.",
"action": { "type": "give_quest", "params": { "questId": "main_002" } },
"nextNodeId": "end"
},
"node_reward": {
"speakerId": "elder",
"text": "I can offer 200 gold and a fine sword.",
"nextNodeId": "node_2"
}
}
}
}
}Dialogue UI
class DialogueBox extends PositionComponent with TapCallbacks {
final DialogueManager manager;
DialogueNode? _node;
@override
Future<void> onLoad() async {
manager.onNodeChanged = (node) {
_node = node;
};
}
@override
void render(Canvas canvas) {
if (_node == null) return;
// Draw box background
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.x, size.y),
const Radius.circular(10),
),
Paint()..color = Colors.black.withOpacity(0.8),
);
// Draw speaker name
_drawText(canvas, _node!.speakerId, Vector2(20, 10));
// Draw text
_drawText(canvas, _node!.text, Vector2(20, 40));
// Draw choices
if (_node!.hasChoices) {
double y = 100;
for (int i = 0; i < _node!.choices.length; i++) {
_drawText(canvas, '${i + 1}. ${_node!.choices[i].text}', Vector2(20, y));
y += 25;
}
} else {
_drawText(canvas, '[Click to continue]', Vector2(20, size.y - 30));
}
}
@override
void onTapDown(TapDownEvent event) {
if (!_node!.hasChoices) {
manager.advance();
}
}
}Typewriter Effect
class TypewriterText extends PositionComponent {
final String fullText;
final double charDelay;
String _displayedText = '';
double _timer = 0;
int _charIndex = 0;
TypewriterText({
required this.fullText,
this.charDelay = 0.03,
});
bool get isComplete => _charIndex >= fullText.length;
void skipToEnd() {
_displayedText = fullText;
_charIndex = fullText.length;
}
@override
void update(double dt) {
if (isComplete) return;
_timer += dt;
while (_timer >= charDelay && _charIndex < fullText.length) {
_displayedText += fullText[_charIndex];
_charIndex++;
_timer -= charDelay;
}
}
}NPC Integration
class Npc extends SpriteComponent with TapCallbacks {
final String dialogueId;
final DialogueManager dialogueManager;
@override
void onTapDown(TapDownEvent event) {
dialogueManager.startDialogue(dialogueId);
}
}Inventory System Reference
Data Structure
enum ItemType { weapon, armor, consumable, material, quest, misc }
enum ItemRarity { common, uncommon, rare, epic, legendary }
class ItemData {
final String id;
final String name;
final String description;
final ItemType type;
final ItemRarity rarity;
final String iconPath;
final int maxStack;
final int buyPrice;
final int sellPrice;
final Map<String, dynamic> stats; // For equipment
final Map<String, dynamic> effects; // For consumables
bool get isStackable => maxStack > 1;
}
class InventorySlot {
ItemData? item;
int quantity = 0;
bool get isEmpty => item == null;
bool get isFull => quantity >= (item?.maxStack ?? 0);
bool canAdd(ItemData newItem, int amount) {
if (isEmpty) return true;
if (item!.id != newItem.id) return false;
return quantity + amount <= item!.maxStack;
}
}Inventory Manager
class InventoryManager extends Component {
final int slotCount;
late List<InventorySlot> slots;
int gold = 0;
InventoryManager({this.slotCount = 20}) {
slots = List.generate(slotCount, (_) => InventorySlot());
}
bool addItem(ItemData item, [int amount = 1]) {
// Try to stack with existing
if (item.isStackable) {
for (final slot in slots) {
if (slot.item?.id == item.id && !slot.isFull) {
final canFit = item.maxStack - slot.quantity;
final toAdd = amount.clamp(0, canFit);
slot.quantity += toAdd;
amount -= toAdd;
if (amount <= 0) {
onItemAdded?.call(item, toAdd);
return true;
}
}
}
}
// Find empty slot
for (final slot in slots) {
if (slot.isEmpty) {
slot.item = item;
slot.quantity = amount.clamp(1, item.maxStack);
onItemAdded?.call(item, slot.quantity);
return true;
}
}
onInventoryFull?.call(item);
return false;
}
bool removeItem(String itemId, [int amount = 1]) {
for (final slot in slots) {
if (slot.item?.id == itemId) {
if (slot.quantity >= amount) {
slot.quantity -= amount;
if (slot.quantity <= 0) {
slot.item = null;
}
onItemRemoved?.call(itemId, amount);
return true;
}
}
}
return false;
}
int getItemCount(String itemId) {
return slots
.where((s) => s.item?.id == itemId)
.fold(0, (sum, s) => sum + s.quantity);
}
bool hasItem(String itemId, [int amount = 1]) {
return getItemCount(itemId) >= amount;
}
void swapSlots(int fromIndex, int toIndex) {
final temp = slots[fromIndex];
slots[fromIndex] = slots[toIndex];
slots[toIndex] = temp;
onSlotsSwapped?.call(fromIndex, toIndex);
}
// Callbacks
void Function(ItemData, int)? onItemAdded;
void Function(String, int)? onItemRemoved;
void Function(ItemData)? onInventoryFull;
void Function(int, int)? onSlotsSwapped;
}Item Database
class ItemDatabase {
static final Map<String, ItemData> _items = {};
static void loadFromJson(String json) {
final data = jsonDecode(json) as Map<String, dynamic>;
for (final entry in data['items']) {
final item = ItemData.fromJson(entry);
_items[item.id] = item;
}
}
static ItemData? get(String id) => _items[id];
static List<ItemData> getByType(ItemType type) =>
_items.values.where((i) => i.type == type).toList();
}JSON Data Format
{
"items": [
{
"id": "potion_health",
"name": "Health Potion",
"description": "Restores 50 HP",
"type": "consumable",
"rarity": "common",
"iconPath": "items/potion_red.png",
"maxStack": 99,
"buyPrice": 50,
"sellPrice": 25,
"effects": { "heal": 50 }
},
{
"id": "sword_iron",
"name": "Iron Sword",
"description": "A basic iron sword",
"type": "weapon",
"rarity": "common",
"iconPath": "items/sword_iron.png",
"maxStack": 1,
"buyPrice": 100,
"sellPrice": 50,
"stats": { "attack": 10 }
}
]
}Inventory UI
class InventoryUI extends PositionComponent with DragCallbacks {
final InventoryManager inventory;
final int columns = 5;
final double slotSize = 64;
final double padding = 4;
int? _dragFromIndex;
@override
void render(Canvas canvas) {
for (int i = 0; i < inventory.slots.length; i++) {
final slot = inventory.slots[i];
final pos = _getSlotPosition(i);
// Slot background
final color = slot.isEmpty ? Colors.grey : _getRarityColor(slot.item!.rarity);
canvas.drawRect(
Rect.fromLTWH(pos.x, pos.y, slotSize, slotSize),
Paint()..color = color.withOpacity(0.5),
);
// Item icon & quantity
if (!slot.isEmpty) {
// Draw icon (sprite)
// Draw quantity
if (slot.quantity > 1) {
_drawText(canvas, '${slot.quantity}', pos + Vector2(slotSize - 16, slotSize - 16));
}
}
}
}
Vector2 _getSlotPosition(int index) {
final x = (index % columns) * (slotSize + padding);
final y = (index ~/ columns) * (slotSize + padding);
return Vector2(x, y);
}
int? _getSlotAtPosition(Vector2 pos) {
final col = (pos.x / (slotSize + padding)).floor();
final row = (pos.y / (slotSize + padding)).floor();
final index = row * columns + col;
return (index >= 0 && index < inventory.slots.length) ? index : null;
}
@override
void onDragStart(DragStartEvent event) {
_dragFromIndex = _getSlotAtPosition(event.localPosition);
}
@override
void onDragEnd(DragEndEvent event) {
if (_dragFromIndex != null) {
final toIndex = _getSlotAtPosition(event.localEndPosition);
if (toIndex != null && toIndex != _dragFromIndex) {
inventory.swapSlots(_dragFromIndex!, toIndex);
}
}
_dragFromIndex = null;
}
Color _getRarityColor(ItemRarity rarity) {
return switch (rarity) {
ItemRarity.common => Colors.grey,
ItemRarity.uncommon => Colors.green,
ItemRarity.rare => Colors.blue,
ItemRarity.epic => Colors.purple,
ItemRarity.legendary => Colors.orange,
};
}
}Level Editor System Reference
Data Structure
class LevelData {
final String id;
final String name;
final int width;
final int height;
final List<List<int>> tileGrid;
final List<PlacedEntity> entities;
final Map<String, dynamic> properties;
DateTime lastModified;
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'width': width,
'height': height,
'tileGrid': tileGrid,
'entities': entities.map((e) => e.toJson()).toList(),
'properties': properties,
'lastModified': lastModified.toIso8601String(),
};
}
class PlacedEntity {
final String id;
final String typeId;
final Vector2 position;
final Map<String, dynamic> properties;
PlacedEntity({
required this.id,
required this.typeId,
required this.position,
this.properties = const {},
});
}
class TileDef {
final int id;
final String name;
final String spritePath;
final bool isSolid;
final Map<String, dynamic> properties;
}
class EntityDef {
final String typeId;
final String name;
final String category;
final String iconPath;
final Vector2 defaultSize;
final List<PropertyDef> editableProperties;
}
class PropertyDef {
final String name;
final String type; // 'int', 'double', 'string', 'bool', 'vector2'
final dynamic defaultValue;
}Level Editor
class LevelEditor extends Component with HasGameRef, DragCallbacks, TapCallbacks {
LevelData? currentLevel;
// Editor state
EditorTool currentTool = EditorTool.paint;
int selectedTileId = 0;
String? selectedEntityType;
PlacedEntity? selectedEntity;
// View state
Vector2 cameraOffset = Vector2.zero();
double zoom = 1.0;
// Undo/Redo
final List<EditorAction> undoStack = [];
final List<EditorAction> redoStack = [];
void newLevel(int width, int height) {
currentLevel = LevelData(
id: 'level_${DateTime.now().millisecondsSinceEpoch}',
name: 'New Level',
width: width,
height: height,
tileGrid: List.generate(height, (_) => List.filled(width, 0)),
entities: [],
properties: {},
lastModified: DateTime.now(),
);
}
void loadLevel(LevelData level) {
currentLevel = level;
undoStack.clear();
redoStack.clear();
}
Future<void> saveLevel() async {
if (currentLevel == null) return;
currentLevel!.lastModified = DateTime.now();
final json = jsonEncode(currentLevel!.toJson());
final file = File('levels/${currentLevel!.id}.json');
await file.writeAsString(json);
}
// Tile operations
void setTile(int x, int y, int tileId) {
if (currentLevel == null) return;
if (x < 0 || x >= currentLevel!.width) return;
if (y < 0 || y >= currentLevel!.height) return;
final oldTile = currentLevel!.tileGrid[y][x];
if (oldTile == tileId) return;
_recordAction(TileChangeAction(x, y, oldTile, tileId));
currentLevel!.tileGrid[y][x] = tileId;
}
// Entity operations
void placeEntity(String typeId, Vector2 position) {
if (currentLevel == null) return;
final entity = PlacedEntity(
id: 'entity_${DateTime.now().millisecondsSinceEpoch}',
typeId: typeId,
position: position,
);
_recordAction(EntityAddAction(entity));
currentLevel!.entities.add(entity);
}
void removeEntity(PlacedEntity entity) {
_recordAction(EntityRemoveAction(entity));
currentLevel!.entities.remove(entity);
}
void moveEntity(PlacedEntity entity, Vector2 newPosition) {
final oldPosition = entity.position.clone();
_recordAction(EntityMoveAction(entity, oldPosition, newPosition));
entity.position.setFrom(newPosition);
}
// Undo/Redo
void _recordAction(EditorAction action) {
undoStack.add(action);
redoStack.clear();
}
void undo() {
if (undoStack.isEmpty) return;
final action = undoStack.removeLast();
action.undo(this);
redoStack.add(action);
}
void redo() {
if (redoStack.isEmpty) return;
final action = redoStack.removeLast();
action.execute(this);
undoStack.add(action);
}
}
enum EditorTool { paint, erase, fill, select, entity, pan }Editor Actions (Undo/Redo)
abstract class EditorAction {
void execute(LevelEditor editor);
void undo(LevelEditor editor);
}
class TileChangeAction extends EditorAction {
final int x, y;
final int oldTile, newTile;
TileChangeAction(this.x, this.y, this.oldTile, this.newTile);
@override
void execute(LevelEditor editor) {
editor.currentLevel!.tileGrid[y][x] = newTile;
}
@override
void undo(LevelEditor editor) {
editor.currentLevel!.tileGrid[y][x] = oldTile;
}
}
class EntityAddAction extends EditorAction {
final PlacedEntity entity;
EntityAddAction(this.entity);
@override
void execute(LevelEditor editor) {
editor.currentLevel!.entities.add(entity);
}
@override
void undo(LevelEditor editor) {
editor.currentLevel!.entities.remove(entity);
}
}
class EntityMoveAction extends EditorAction {
final PlacedEntity entity;
final Vector2 oldPosition, newPosition;
EntityMoveAction(this.entity, this.oldPosition, this.newPosition);
@override
void execute(LevelEditor editor) {
entity.position.setFrom(newPosition);
}
@override
void undo(LevelEditor editor) {
entity.position.setFrom(oldPosition);
}
}Editor UI
class EditorUI extends PositionComponent {
final LevelEditor editor;
@override
void render(Canvas canvas) {
// Tool palette
_drawToolPalette(canvas, Vector2(10, 10));
// Tile palette
_drawTilePalette(canvas, Vector2(10, 100));
// Entity palette
_drawEntityPalette(canvas, Vector2(10, 300));
// Properties panel (when entity selected)
if (editor.selectedEntity != null) {
_drawPropertiesPanel(canvas, Vector2(size.x - 250, 10));
}
// Menu bar
_drawMenuBar(canvas);
}
void _drawToolPalette(Canvas canvas, Vector2 pos) {
final tools = [
('Paint', EditorTool.paint),
('Erase', EditorTool.erase),
('Fill', EditorTool.fill),
('Select', EditorTool.select),
('Entity', EditorTool.entity),
('Pan', EditorTool.pan),
];
double x = pos.x;
for (final (name, tool) in tools) {
final selected = editor.currentTool == tool;
_drawToolButton(canvas, name, Vector2(x, pos.y), selected);
x += 50;
}
}
void _drawTilePalette(Canvas canvas, Vector2 pos) {
_drawText(canvas, 'Tiles', pos, size: 14);
double x = pos.x;
double y = pos.y + 25;
for (final tile in tileDefinitions) {
final selected = editor.selectedTileId == tile.id;
_drawTileThumbnail(canvas, tile, Vector2(x, y), selected);
x += 36;
if (x > 150) {
x = pos.x;
y += 36;
}
}
}
void _drawPropertiesPanel(Canvas canvas, Vector2 pos) {
final entity = editor.selectedEntity!;
final def = getEntityDef(entity.typeId);
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(pos.x, pos.y, 240, 300), Radius.circular(8)),
Paint()..color = Colors.black.withOpacity(0.8),
);
_drawText(canvas, def.name, pos + Vector2(10, 10), size: 16);
double y = 40;
for (final prop in def.editableProperties) {
_drawText(canvas, prop.name, pos + Vector2(10, y), size: 12);
_drawPropertyInput(canvas, entity, prop, pos + Vector2(100, y));
y += 30;
}
_drawButton(canvas, 'Delete', pos + Vector2(10, 250));
}
void _drawMenuBar(Canvas canvas) {
final items = ['File', 'Edit', 'View', 'Test'];
double x = 0;
for (final item in items) {
_drawMenuItem(canvas, item, Vector2(x, 0));
x += 80;
}
}
}Grid Renderer
class EditorGridRenderer extends Component {
final LevelEditor editor;
final double tileSize;
@override
void render(Canvas canvas) {
if (editor.currentLevel == null) return;
final level = editor.currentLevel!;
// Draw tiles
for (int y = 0; y < level.height; y++) {
for (int x = 0; x < level.width; x++) {
final tileId = level.tileGrid[y][x];
_drawTile(canvas, x, y, tileId);
}
}
// Draw grid lines
_drawGrid(canvas, level.width, level.height);
// Draw entities
for (final entity in level.entities) {
_drawEntity(canvas, entity);
}
// Draw selection highlight
if (editor.selectedEntity != null) {
_drawSelectionBox(canvas, editor.selectedEntity!);
}
}
void _drawGrid(Canvas canvas, int width, int height) {
final paint = Paint()
..color = Colors.white.withOpacity(0.2)
..strokeWidth = 1;
for (int x = 0; x <= width; x++) {
canvas.drawLine(
Offset(x * tileSize, 0),
Offset(x * tileSize, height * tileSize),
paint,
);
}
for (int y = 0; y <= height; y++) {
canvas.drawLine(
Offset(0, y * tileSize),
Offset(width * tileSize, y * tileSize),
paint,
);
}
}
}Level Loader (Runtime)
class LevelLoader {
final Map<int, TileDef> tileDefinitions;
final Map<String, EntityFactory> entityFactories;
Future<void> loadLevelIntoGame(String levelId, FlameGame game) async {
final file = File('levels/$levelId.json');
final json = jsonDecode(await file.readAsString());
final levelData = LevelData.fromJson(json);
// Create tile components
for (int y = 0; y < levelData.height; y++) {
for (int x = 0; x < levelData.width; x++) {
final tileId = levelData.tileGrid[y][x];
final tileDef = tileDefinitions[tileId];
if (tileDef != null) {
game.world.add(TileComponent(
tileDef: tileDef,
position: Vector2(x * 32.0, y * 32.0),
));
}
}
}
// Create entity components
for (final entity in levelData.entities) {
final factory = entityFactories[entity.typeId];
if (factory != null) {
game.world.add(factory.create(entity));
}
}
}
}
abstract class EntityFactory {
Component create(PlacedEntity data);
}
class EnemyFactory extends EntityFactory {
@override
Component create(PlacedEntity data) {
return Enemy(
position: data.position,
enemyType: data.properties['enemyType'] ?? 'default',
);
}
}JSON Level Format
{
"id": "level_001",
"name": "Tutorial Level",
"width": 20,
"height": 15,
"tileGrid": [
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]
],
"entities": [
{
"id": "entity_001",
"typeId": "player_spawn",
"position": {"x": 64, "y": 64},
"properties": {}
},
{
"id": "entity_002",
"typeId": "enemy",
"position": {"x": 320, "y": 128},
"properties": {"enemyType": "slime", "patrol": true}
}
],
"properties": {
"backgroundMusic": "level1_bgm",
"timeLimit": 300
}
}Localization System Reference
Data Structure
class LocalizationManager {
String _currentLocale = 'en';
final Map<String, Map<String, String>> _translations = {};
String get currentLocale => _currentLocale;
List<String> get availableLocales => _translations.keys.toList();
void loadTranslations(String locale, Map<String, String> strings) {
_translations[locale] = strings;
}
void setLocale(String locale) {
if (_translations.containsKey(locale)) {
_currentLocale = locale;
onLocaleChanged?.call(locale);
}
}
String tr(String key, [Map<String, dynamic>? params]) {
final text = _translations[_currentLocale]?[key] ?? key;
if (params == null) return text;
// Replace placeholders: {name}, {count}
return text.replaceAllMapped(
RegExp(r'\{(\w+)\}'),
(match) => params[match.group(1)]?.toString() ?? match.group(0)!,
);
}
void Function(String)? onLocaleChanged;
}
// Global accessor
late LocalizationManager l10n;JSON Translation Files
// assets/i18n/en.json
{
"game_title": "Epic Adventure",
"menu_start": "Start Game",
"menu_settings": "Settings",
"menu_quit": "Quit",
"dialog_hello": "Hello, {name}!",
"quest_kill": "Kill {count} {enemy}",
"item_gold": "{amount} Gold",
"achievement_unlocked": "Achievement Unlocked: {title}"
}
// assets/i18n/zh_TW.json
{
"game_title": "史詩冒險",
"menu_start": "開始遊戲",
"menu_settings": "設定",
"menu_quit": "離開",
"dialog_hello": "你好,{name}!",
"quest_kill": "擊敗 {count} 隻 {enemy}",
"item_gold": "{amount} 金幣",
"achievement_unlocked": "成就解鎖:{title}"
}Loading Translations
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
l10n = LocalizationManager();
// Load from JSON
final enJson = await rootBundle.loadString('assets/i18n/en.json');
final zhJson = await rootBundle.loadString('assets/i18n/zh_TW.json');
l10n.loadTranslations('en', Map<String, String>.from(jsonDecode(enJson)));
l10n.loadTranslations('zh_TW', Map<String, String>.from(jsonDecode(zhJson)));
// Set default or saved preference
final savedLocale = prefs.getString('locale') ?? 'en';
l10n.setLocale(savedLocale);
}
}Usage in Game
// Simple text
final title = l10n.tr('game_title'); // "Epic Adventure" or "史詩冒險"
// With parameters
final greeting = l10n.tr('dialog_hello', {'name': 'Hero'});
// "Hello, Hero!" or "你好,Hero!"
final questText = l10n.tr('quest_kill', {'count': 5, 'enemy': 'Rats'});
// "Kill 5 Rats" or "擊敗 5 隻 Rats"
// In components
class MenuButton extends TextComponent {
final String textKey;
@override
Future<void> onLoad() async {
text = l10n.tr(textKey);
l10n.onLocaleChanged = (_) {
text = l10n.tr(textKey);
};
}
}Pluralization
extension LocalizationExtension on LocalizationManager {
String plural(String key, int count, {Map<String, dynamic>? params}) {
final pluralKey = count == 1 ? '${key}_one' : '${key}_other';
final finalParams = {...?params, 'count': count};
return tr(pluralKey, finalParams);
}
}
// JSON
{
"enemy_killed_one": "Killed 1 enemy",
"enemy_killed_other": "Killed {count} enemies"
}
// Usage
l10n.plural('enemy_killed', 1); // "Killed 1 enemy"
l10n.plural('enemy_killed', 5); // "Killed 5 enemies"Language Selector UI
class LanguageSelector extends PositionComponent with TapCallbacks {
final List<Map<String, String>> languages = [
{'code': 'en', 'name': 'English'},
{'code': 'zh_TW', 'name': '繁體中文'},
{'code': 'ja', 'name': '日本語'},
];
@override
void render(Canvas canvas) {
double y = 0;
for (final lang in languages) {
final isSelected = lang['code'] == l10n.currentLocale;
_drawText(
canvas,
'${isSelected ? "► " : " "}${lang['name']}',
Vector2(0, y),
);
y += 30;
}
}
@override
void onTapDown(TapDownEvent event) {
final index = (event.localPosition.y / 30).floor();
if (index < languages.length) {
l10n.setLocale(languages[index]['code']!);
}
}
}Font Support
// For CJK characters, use appropriate fonts
final chineseRenderer = TextPaint(
style: const TextStyle(
fontFamily: 'NotoSansTC', // Supports Chinese
fontSize: 16,
),
);
// Load in pubspec.yaml
// fonts:
// - family: NotoSansTC
// fonts:
// - asset: assets/fonts/NotoSansTC-Regular.otfMultiplayer System Reference
Architecture Overview
// Client-Server model
// Server: Authoritative game state
// Client: Input sending + state interpolation
enum NetworkRole { server, client }
enum ConnectionState { disconnected, connecting, connected, error }Network Message
abstract class NetworkMessage {
final String type;
final int timestamp;
NetworkMessage(this.type) : timestamp = DateTime.now().millisecondsSinceEpoch;
Map<String, dynamic> toJson();
factory NetworkMessage.fromJson(Map<String, dynamic> json);
}
class PlayerInputMessage extends NetworkMessage {
final String playerId;
final Vector2 moveDirection;
final bool isShooting;
PlayerInputMessage({
required this.playerId,
required this.moveDirection,
this.isShooting = false,
}) : super('player_input');
@override
Map<String, dynamic> toJson() => {
'type': type,
'timestamp': timestamp,
'playerId': playerId,
'moveDirection': {'x': moveDirection.x, 'y': moveDirection.y},
'isShooting': isShooting,
};
}
class GameStateMessage extends NetworkMessage {
final List<PlayerState> players;
final List<EntityState> entities;
GameStateMessage({
required this.players,
required this.entities,
}) : super('game_state');
}
class PlayerState {
final String id;
final Vector2 position;
final int health;
final String animation;
}WebSocket Client
class NetworkClient {
WebSocketChannel? _channel;
ConnectionState state = ConnectionState.disconnected;
String? playerId;
final _messageController = StreamController<NetworkMessage>.broadcast();
Stream<NetworkMessage> get messages => _messageController.stream;
Future<void> connect(String serverUrl) async {
state = ConnectionState.connecting;
try {
_channel = WebSocketChannel.connect(Uri.parse(serverUrl));
_channel!.stream.listen(
(data) {
final json = jsonDecode(data);
final message = NetworkMessage.fromJson(json);
_messageController.add(message);
},
onDone: () {
state = ConnectionState.disconnected;
onDisconnected?.call();
},
onError: (error) {
state = ConnectionState.error;
onError?.call(error.toString());
},
);
state = ConnectionState.connected;
onConnected?.call();
} catch (e) {
state = ConnectionState.error;
onError?.call(e.toString());
}
}
void send(NetworkMessage message) {
if (state != ConnectionState.connected) return;
_channel?.sink.add(jsonEncode(message.toJson()));
}
void disconnect() {
_channel?.sink.close();
state = ConnectionState.disconnected;
}
void Function()? onConnected;
void Function()? onDisconnected;
void Function(String)? onError;
}State Synchronization
class NetworkSync extends Component with HasGameRef {
final NetworkClient client;
final Map<String, NetworkPlayer> remotePlayers = {};
// Interpolation buffer
final Map<String, List<PlayerState>> stateBuffer = {};
static const interpolationDelay = 100; // ms
@override
void onMount() {
client.messages.listen(_handleMessage);
super.onMount();
}
void _handleMessage(NetworkMessage message) {
switch (message.type) {
case 'game_state':
_handleGameState(message as GameStateMessage);
break;
case 'player_joined':
_handlePlayerJoined(message);
break;
case 'player_left':
_handlePlayerLeft(message);
break;
}
}
void _handleGameState(GameStateMessage state) {
for (final playerState in state.players) {
if (playerState.id == client.playerId) {
// Local player - reconcile
_reconcileLocalPlayer(playerState);
} else {
// Remote player - buffer for interpolation
stateBuffer.putIfAbsent(playerState.id, () => []);
stateBuffer[playerState.id]!.add(playerState);
// Keep buffer size limited
if (stateBuffer[playerState.id]!.length > 10) {
stateBuffer[playerState.id]!.removeAt(0);
}
}
}
}
@override
void update(double dt) {
// Interpolate remote players
final renderTime = DateTime.now().millisecondsSinceEpoch - interpolationDelay;
for (final entry in stateBuffer.entries) {
final playerId = entry.key;
final buffer = entry.value;
if (buffer.length < 2) continue;
// Find states to interpolate between
PlayerState? from, to;
for (int i = 0; i < buffer.length - 1; i++) {
if (buffer[i].timestamp <= renderTime && buffer[i + 1].timestamp >= renderTime) {
from = buffer[i];
to = buffer[i + 1];
break;
}
}
if (from != null && to != null) {
final t = (renderTime - from.timestamp) / (to.timestamp - from.timestamp);
final interpolatedPos = Vector2(
from.position.x + (to.position.x - from.position.x) * t,
from.position.y + (to.position.y - from.position.y) * t,
);
remotePlayers[playerId]?.position = interpolatedPos;
}
}
}
}Input Prediction (Client-side)
class InputPredictor {
final List<PendingInput> pendingInputs = [];
int inputSequence = 0;
void sendInput(Vector2 moveDirection, NetworkClient client) {
final input = PendingInput(
sequence: inputSequence++,
moveDirection: moveDirection,
timestamp: DateTime.now().millisecondsSinceEpoch,
);
pendingInputs.add(input);
// Send to server
client.send(PlayerInputMessage(
playerId: client.playerId!,
moveDirection: moveDirection,
));
// Apply locally (prediction)
localPlayer.applyInput(moveDirection);
}
void reconcile(PlayerState serverState, int lastProcessedInput) {
// Remove acknowledged inputs
pendingInputs.removeWhere((i) => i.sequence <= lastProcessedInput);
// Reset to server state
localPlayer.position = serverState.position;
// Reapply pending inputs
for (final input in pendingInputs) {
localPlayer.applyInput(input.moveDirection);
}
}
}
class PendingInput {
final int sequence;
final Vector2 moveDirection;
final int timestamp;
PendingInput({
required this.sequence,
required this.moveDirection,
required this.timestamp,
});
}Lobby System
class LobbyManager {
final NetworkClient client;
final List<LobbyRoom> rooms = [];
LobbyRoom? currentRoom;
Future<void> refreshRooms() async {
client.send(RequestRoomsMessage());
}
Future<void> createRoom(String name, int maxPlayers) async {
client.send(CreateRoomMessage(name: name, maxPlayers: maxPlayers));
}
Future<void> joinRoom(String roomId) async {
client.send(JoinRoomMessage(roomId: roomId));
}
Future<void> leaveRoom() async {
client.send(LeaveRoomMessage());
currentRoom = null;
}
void setReady(bool ready) {
client.send(SetReadyMessage(ready: ready));
}
}
class LobbyRoom {
final String id;
final String name;
final String hostId;
final int maxPlayers;
final List<LobbyPlayer> players;
final bool isStarted;
bool get isFull => players.length >= maxPlayers;
bool get canStart => players.every((p) => p.isReady) && players.length >= 2;
}
class LobbyPlayer {
final String id;
final String name;
final bool isReady;
final bool isHost;
}Lobby UI
class LobbyUI extends PositionComponent {
final LobbyManager lobbyManager;
@override
void render(Canvas canvas) {
if (lobbyManager.currentRoom == null) {
_drawRoomList(canvas);
} else {
_drawRoomLobby(canvas);
}
}
void _drawRoomList(Canvas canvas) {
_drawText(canvas, 'Available Rooms', Vector2(10, 10), size: 24);
double y = 50;
for (final room in lobbyManager.rooms) {
final status = room.isFull ? '(Full)' : '${room.players.length}/${room.maxPlayers}';
_drawText(canvas, '${room.name} $status', Vector2(10, y));
_drawButton(canvas, 'Join', Vector2(300, y - 5), enabled: !room.isFull);
y += 40;
}
_drawButton(canvas, 'Create Room', Vector2(10, size.y - 50));
_drawButton(canvas, 'Refresh', Vector2(150, size.y - 50));
}
void _drawRoomLobby(Canvas canvas) {
final room = lobbyManager.currentRoom!;
_drawText(canvas, room.name, Vector2(10, 10), size: 24);
double y = 50;
for (final player in room.players) {
final status = player.isReady ? '[Ready]' : '[Not Ready]';
final host = player.isHost ? '(Host)' : '';
_drawText(canvas, '${player.name} $host $status', Vector2(10, y));
y += 30;
}
_drawButton(canvas, 'Ready', Vector2(10, size.y - 50));
_drawButton(canvas, 'Leave', Vector2(150, size.y - 50));
if (room.canStart && _isHost()) {
_drawButton(canvas, 'Start Game', Vector2(size.x - 150, size.y - 50));
}
}
}Simple Firebase Realtime Database
class FirebaseMultiplayer {
final DatabaseReference _db = FirebaseDatabase.instance.ref();
String? roomId;
String? playerId;
Future<void> createRoom() async {
final roomRef = _db.child('rooms').push();
roomId = roomRef.key;
await roomRef.set({
'hostId': playerId,
'state': 'waiting',
'players': {playerId: {'x': 0, 'y': 0, 'ready': false}},
});
}
void listenToRoom() {
_db.child('rooms/$roomId').onValue.listen((event) {
final data = event.snapshot.value as Map?;
if (data != null) {
_updateFromServerState(data);
}
});
}
void updatePosition(Vector2 position) {
_db.child('rooms/$roomId/players/$playerId').update({
'x': position.x,
'y': position.y,
});
}
}Related skills
AI & Agent Buildingagents