Flutter与Flame:打造卓越平台游戏的强强联合
导读:Flutter 作为跨平台 UI 框架,在移动端和桌面端都有出色表现;而 Flame 是基于 Flutter 的 2D 游戏引擎,专注于高性能渲染与游戏开发模式。二者结合,可以快速打造出流畅的、充满活力的 平台(Platformer)游戏。本文将从最基础的环境搭建,到 角色移动、动画渲染、碰撞检测、相机跟随 等核心功能,一步步展示如何利用 Flutter + Flame 实现一款简单却“不卡帧”、可扩展的横版平台游戏。文中配有丰富的 代码示例、ASCII 图解 与详细说明,帮助你快速上手并加深理解。
目录
- 前言:为何选择 Flutter + Flame 打造平台游戏
- 2.1 新建 Flutter 项目
- 2.2 添加 Flame 依赖
- 2.3 基本目录结构
- 3.1 Flame 的组件(Component)体系
- 3.2 游戏循环(Game Loop)与渲染流程
- 3.3 坐标系与世界(World)概念
- 4.1 定义
MyGame
类 - 4.2 在 Flutter 的
Widget
中嵌入GameWidget
- 4.3 运行并验证“空白游戏”
- 4.1 定义
- 5.1 加载并渲染静态背景
- 5.2 添加平台(Platform)组件
- 5.3 ASCII 图解:关卡布局与碰撞区域
- 6.1 精灵(Sprite)与动画(Animation)加载
- 6.2 定义
Player
Component - 6.3 模拟重力与竖直运动
- 6.4 处理左右移动输入
- 6.5 ASCII 图解:力学流程与速度向量
- 7.1 简单 AABB(轴对齐包围盒)碰撞
- 7.2 Flame 内置的
HasCollisionDetection
与CollisionComponent
- 7.3 角色与平台的碰撞回调与响应
- 7.4 ASCII 图解:碰撞检测流程
- 8.1 跳跃力实现与跳跃高度控制
- 8.2 “二段跳”与“可变跳跃”机制
- 8.3 代码示例:跳跃逻辑与按键监听
- 9.1 Flame 的
camera
概念 - 9.2 设置相机追踪角色
- 9.3 ASCII 图解:视口(Viewport)与实体坐标映射
- 9.1 Flame 的
- 10.1 角色动作动画(Run/Idle/Jump)
- 10.2 收集道具特效(ParticleComponent)
- 10.3 碰撞反馈:粒子爆炸与震屏效果
- 11.1 精灵纹理图集(Sprite Sheets)打包
- 11.2 异步资源加载与
FutureBuilder
- 11.3 简单性能调试:FPS 监测与调优建议
- 总结与后续拓展方向
一、前言:为何选择 Flutter + Flame 打造平台游戏
- 跨平台一致性:Flutter 可以在 iOS、Android、Web、桌面(Windows/macOS/Linux)等平台“一次编码,多端运行”,极大降低游戏适配成本。
- 高性能渲染:虽然 Flutter 主要用于 UI,但其底层是基于 Skia 引擎的高效绘制,非常适合 2D 游戏。Flame 则对 Flutter 的渲染与更新循环进行了封装,让我们无需关心原生渲染细节。
- 丰富的生态与便捷开发:Flame 提供了常用的游戏功能模块,如 精灵管理、碰撞检测、粒子效果、相机控制等,减少重复造轮子;同时还可与 Flutter 生态中已有的包(如 Bloc、GetX、Provider 等)无缝集成,方便管理游戏状态与业务逻辑。
- 学习曲线低:如果你已经熟悉 Flutter,那么上手 Flame 只需几分钟。Flame 的文档清晰,社区活跃,还有大量示例和教程。
综上所述,Flutter + Flame 是一个兼具生产力与性能的理想组合,尤其适合像横版平台游戏这样需求频繁动画、碰撞检测、物理模拟的场景。
二、环境准备与依赖配置
2.1 新建 Flutter 项目
请确保已安装 Flutter SDK(≥2.0)并配置好相关环境变量,如果尚未安装,可参考官方文档:
https://flutter.dev/docs/get-started/install
在命令行中执行:
flutter create platformer_game cd platformer_game
- 打开 IDE(如 Android Studio / VSCode)加载该项目。
2.2 添加 Flame 依赖
在项目根目录下的 pubspec.yaml
中,找到 dependencies
部分,添加如下内容(请根据最新版本号替换):
dependencies:
flutter:
sdk: flutter
flame: ^1.6.0 # Flame 核心包
flame_forge2d: ^0.10.0 # 如需物理引擎,可根据需要添加
说明:
flame
包含常用的游戏组件,适合大部分 2D 游戏需求;flame_forge2d
是基于 Box2D 的 Flutter 端物理引擎,如果你的平台游戏需要更真实的物理模拟(如斜坡、碰撞反弹、关节等),可以引入该包。本文示例以简单 AABB 和重力为主,不强制依赖flame_forge2d
。
添加完成后,执行:
flutter pub get
2.3 基本目录结构
为了保持项目整洁,建议如下组织目录(位于 lib/
下):
lib/
├── main.dart # 应用入口
├── game/ # 存放与游戏核心相关的代码
│ ├── my_game.dart # 自定义 Game 类
│ ├── components/ # 存放所有 Component(角色、平台、道具等)
│ │ ├── player.dart
│ │ ├── platform.dart
│ │ └── ...
│ └── utils/ # 辅助工具类(碰撞检测、常量定义等)
│ └── constants.dart
└── assets/ # 资源目录
├── images/ # 精灵图、背景图
│ ├── player_run.png
│ ├── player_idle.png
│ ├── platform.png
│ └── background.png
└── fonts/ # 字体(如果需要 UI)
同时,在 pubspec.yaml
中声明资源引用:
flutter:
assets:
- assets/images/player_run.png
- assets/images/player_idle.png
- assets/images/platform.png
- assets/images/background.png
三、核心概念与目录结构图解
在动手编码前,先理解 Flame 的几大核心概念与渲染流程,有助于编写清晰、高内聚的游戏代码。
3.1 Flame 的组件(Component)体系
Component:Flame 游戏中的最小可渲染单元,类似 Flutter 中的
Widget
。PositionComponent
:可定位的基础组件,带有x, y, width, height
四个属性。SpriteComponent
:继承自PositionComponent
,用于显示单张图片或从图集中截取的精灵;AnimationComponent
:继承自PositionComponent
,用于播放一系列连续帧的动画;TextComponent
、ParticleComponent
等,分别用于渲染文字、粒子效果。
- 组件树(Component Tree)
Flame 并不严格要求“树形”层次,但你可以通过add(child)
的方式将多个 Component 组织在一起,共同维护坐标、层级。例如一个“角色”Component 下,可以包含“武器”Component、“阴影”Component 等子节点。
MyGame
├─ BackgroundComponent
├─ PlatformComponent (多个)
├─ PlayerComponent
│ ├─ ShadowComponent
│ └─ HitboxComponent
└─ HUDComponent (UI 层)
3.2 游戏循环(Game Loop)与渲染流程
Flame 内部维护一个游戏循环,与 Flutter 的 Widget 渲染分开跑在同一个线程(单线程):
update(dt)
:每帧调用,dt
为距离上一帧经过的秒数(如 0.016 秒左右)。在此方法中处理逻辑更新(位置、速度、AI 等)。render(Canvas canvas)
:每帧调用,负责绘制当前帧游戏场景。Flame 会自动将挂载在Game
上的所有Component
按顺序render
。
┌─────────────────────────────────────────┐
│ Flutter Framework │
│ ┌───────────────────────────────┐ │
│ │ Flame Game │ │
│ │ │ │
│ │ ┌─────────────┐ render │ │
│ │ │ Frame 1 │◄───────┐ │ │
│ │ └─────────────┘ │ │ │
│ │ ▲ │ │ │
│ │ │ update(dt) │ │ │
│ │ │ │ │ │
│ │ ┌─────────────┐ │ │ │
│ │ │ Frame 2 │─────────┘ │ │
│ │ └─────────────┘ │ │
│ │ ... │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────────┘
3.3 坐标系与世界(World)概念
- 世界坐标(World Coordinates):游戏内部逻辑所使用的坐标,以左上角为
(0,0)
,向右为 X 增加,向下为 Y 增加。 - 屏幕坐标(Viewport / Screen Coordinates):实际渲染到设备屏幕上的坐标,经过相机(camera)变换后得到。
当我们在 Component
的 x, y
修改时,默认就是在“世界坐标”上变动;若需要“相机跟随角色”,再将视口(camera.viewfinder
)定位到角色所在,使得实际呈现在屏幕上的常常是一个“窗口”(window),而非整个世界。
世界坐标示意图(假设场景宽 2000,高 1000):
Y
│
│ o Platform at (300, 500)
│
│ o Player at (500, 400)
│
│ o Enemy at (800, 300)
│
└────────────────────────────────────── X
屏幕视口大小: 800×600
┌────────────────────────────────┐
│ 相机视口 │
│ ┌────────────────────────┐ │
│ │ [400, 300] – [1200,900]│ │ ← 该区域渲染到屏幕
│ │ (角色附近) │ │
│ └────────────────────────┘ │
└────────────────────────────────┘
四、创建最简可运行的 Flame 游戏
4.1 定义 MyGame
类
在 lib/game/my_game.dart
中,创建一个继承自 BaseGame
(或新版 Flame 使用的 FlameGame
)的游戏类:
// lib/game/my_game.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
// 在这里可以加载资源、添加初始组件等
}
@override
void update(double dt) {
super.update(dt);
// 逻辑更新:比如角色位置、AI、碰撞检测等
}
@override
void render(Canvas canvas) {
super.render(canvas);
// 如果想在最底层绘制背景色,可在此绘制
// canvas.drawColor(Colors.lightBlue, BlendMode.src);
}
}
解释:
FlameGame
是 Flame 推荐的新基类(旧版为BaseGame
),其中封装了Component
管理与游戏循环。onLoad()
:异步方法,通常用于加载纹理、精灵图集、音频等资源,并添加到游戏中。update(dt)
:每帧会被自动调用,此方法可省略super.update(dt)
(但建议保留以更新各组件)。render(canvas)
:渲染方法,super.render(canvas)
会绘制所有已添加的Component
,我们可以在其前后自定义绘制。
4.2 在 Flutter 的 Widget
中嵌入 GameWidget
编辑 lib/main.dart
,将 MyGame
嵌入到 Flutter 组件树中:
// lib/main.dart
import 'package:flutter/material.dart';
import 'game/my_game.dart';
void main() {
runApp(const PlatformerApp());
}
class PlatformerApp extends StatelessWidget {
const PlatformerApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final myGame = MyGame();
return MaterialApp(
title: 'Flutter + Flame Platformer',
home: Scaffold(
appBar: AppBar(title: const Text('平台游戏示例')),
body: GameWidget(
game: myGame,
// 可选:loadingBuilder,加载界面占位
loadingBuilder: (context) => const Center(child: CircularProgressIndicator()),
),
),
);
}
}
说明:
GameWidget
是 Flame 提供的专用 Widget,将MyGame
与 Flutter 渲染管线衔接在一起。- 当
MyGame.onLoad()
还未完成时,loadingBuilder
会展示一个加载指示器;加载完成后自动切换到游戏画面。
4.3 运行并验证“空白游戏”
在 IDE / 终端执行:
flutter run
- 模拟器或真机上会出现一个空白画面,带有 AppBar 标题“平台游戏示例”。
- 如果控制台没有报错,说明最简 Flame 游戏框架已成功运行。
下一步:我们将在 onLoad()
中加载 背景、平台、角色 等组件,逐渐丰富游戏内容。
五、构建平台游戏基础:背景与关卡
平台游戏(Platformer)的核心在于“角色站在平台上奔跑、跳跃、避开陷阱、收集道具”。因此第一步就是绘制背景与建立可踩踏的平台。
5.1 加载并渲染静态背景
在 assets/images/
下准备一张宽度足够的大背景图(如 background.png
),或多张拼接。示例中假设 background.png
大小为 2000×1000。
在 lib/game/components/background_component.dart
中实现一个 BackgroundComponent
:
// lib/game/components/background_component.dart
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
class BackgroundComponent extends SpriteComponent with HasGameRef {
BackgroundComponent() : super(size: Vector2.zero());
@override
Future<void> onLoad() async {
// 加载背景精灵
sprite = await gameRef.loadSprite('background.png');
// 将组件大小设为图片原始尺寸,或者屏幕宽度比例缩放
size = Vector2(sprite!.originalSize.x.toDouble(), sprite!.originalSize.y.toDouble());
// 放在世界坐标 (0, 0)
position = Vector2.zero();
}
}
解释:
SpriteComponent
:Flame 内置组件,用于加载并绘制一张图片。gameRef.loadSprite('background.png')
:异步加载assets/images/background.png
。originalSize
包含了图片的原始宽高;如果希望铺满屏幕,可改为size = gameRef.canvasSize
或类似。
修改 MyGame
的 onLoad()
,添加 BackgroundComponent
:
// lib/game/my_game.dart
import 'package:flame/components.dart';
// 其他导入...
import 'components/background_component.dart';
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
// 1. 添加背景
await add(BackgroundComponent());
// 后续再添加平台、角色等
}
// ...
}
此时运行,会看到整个世界里唯一的背景图,角色与平台尚未添加。
5.2 添加平台(Platform)组件
平台通常表现为地块(tiles)或一张长图,用户可在其上行走。为了简单,我们可以使用同一张 platform.png
来拼接多个“平台”,或单独分别放置。示例中假设 platform.png
大小为 256×32。
创建 lib/game/components/platform.dart
:
// lib/game/components/platform.dart
import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
import 'package:flame/game.dart';
class Platform extends SpriteComponent with HasGameRef, Hitbox, Collidable {
Platform(Vector2 position)
: super(position: position, size: Vector2(256, 32)) {
// 在构造时给出初始坐标,大小与图片一致
}
@override
Future<void> onLoad() async {
sprite = await gameRef.loadSprite('platform.png');
// 添加一个 AABB 碰撞盒,与组件大小一致
addHitbox(HitboxRectangle());
// 设置碰撞类型
collisionType = CollisionType.passive;
}
}
解释:
Hitbox
+Collidable
:Flame 中用于碰撞检测的混入(mixin)机制;HitboxRectangle()
自动创建与组件大小对应的轴对齐包围盒(AABB)。collisionType = CollisionType.passive
:表示该物体只能被动碰撞,不会主动检查其他物体碰撞,一般用于地面、平台那种不需要主动响应碰撞的对象。
在 MyGame.onLoad()
中,将多个平台实例添加到合适位置:
// lib/game/my_game.dart
import 'components/platform.dart';
class MyGame extends FlameGame with HasCollisionDetection {
@override
Future<void> onLoad() async {
await add(BackgroundComponent());
// 添加多个平台
await add(Platform(Vector2(100, 400)));
await add(Platform(Vector2(400, 300)));
await add(Platform(Vector2(700, 500)));
// … 可按需求添加更多
}
// ...
}
- 注意:
MyGame
也需混入HasCollisionDetection
才能启用碰撞检测机制。
5.3 ASCII 图解:关卡布局与碰撞区域
世界坐标平面示意(单位:像素)
Y ↑
0 ← 略
|
|
100| [ 空白区 ]
|
200| o Platform at (100, 400) ← size: 256×32
| ┌────────────────┐
| │ │
300| o Platform at (400,300) │
| ┌───────────────────┐│
| │ ││
400| o ┌─────────────────┐ ││
| │ Platform │ ││
| │ 700,500 │ ││
500| └─────────────────┘ ││
| │ ││
| │ [Player Start] ││
600| ▼ ▼│
└────────────────────────────┘•→ X
0 200 400 600 800 ...
- 每个
Platform
的位置由其左上角坐标(x, y)
确定,大小为(256, 32)
。 - 角色(Player)将从某个平台顶部“启动”,并能在这些平台间跳跃和移动。
- 后续章节会在这些平台上实现碰撞检测与响应逻辑。
六、实现角色(Player)移动与重力
6.1 精灵(Sprite)与动画(Animation)加载
先准备两组精灵图(Sprite Sheets):
player_idle.png
:角色静止帧,假设切分为 4 帧,每帧 48×48;player_run.png
:角色奔跑帧,假设切分为 6 帧,每帧 48×48。
将两张图放置于 assets/images/
并在 pubspec.yaml
中声明。
然后,创建 lib/game/components/player.dart
,实现 Player
Component:
// lib/game/components/player.dart
import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
import 'package:flame/input.dart';
import 'package:flame/flame.dart';
import 'package:flame/game.dart';
import 'package:flutter/services.dart';
import '../utils/constants.dart';
class Player extends SpriteAnimationComponent
with HasGameRef, Hitbox, Collidable, KeyboardHandler {
// 状态机标识
bool isRunning = false;
bool isJumping = false;
// 速度向量
Vector2 velocity = Vector2.zero();
// 常量:加速度、最大速度
final double gravity = Constants.gravity; // 例如 800 像素/s²
final double moveSpeed = Constants.moveSpeed; // 200 像素/s
final double jumpSpeed = Constants.jumpSpeed; // -400 像素/s (向上为负)
Player() : super(size: Vector2.all(48)) {
// 初始位置在 (120, 350),略高于第一个平台(100, 400)
position = Vector2(120, 350);
anchor = Anchor.bottomCenter; // 以底部中心为锚点,方便站在平台上
}
@override
Future<void> onLoad() async {
// 1. 加载静止与奔跑动画
final idleSheet = await gameRef.images.load('player_idle.png');
final runSheet = await gameRef.images.load('player_run.png');
final idleAnim = SpriteAnimation.fromFrameData(
idleSheet,
SpriteAnimationData.sequenced(
amount: 4,
stepTime: 0.2,
textureSize: Vector2(48, 48),
),
);
final runAnim = SpriteAnimation.fromFrameData(
runSheet,
SpriteAnimationData.sequenced(
amount: 6,
stepTime: 0.1,
textureSize: Vector2(48, 48),
),
);
// 2. 先将动画设为静止状态
animation = idleAnim;
// 3. 添加碰撞盒:与组件大小一致
addHitbox(HitboxRectangle());
collisionType = CollisionType.active;
}
@override
void update(double dt) {
super.update(dt);
// 应用重力
velocity.y += gravity * dt;
// 水平移动保持不变(例如速度已在输入处理时设置)
// 更新位置
position += velocity * dt;
// 限制最大水平速度
if (velocity.x.abs() > moveSpeed) {
velocity.x = velocity.x.sign * moveSpeed;
}
}
@override
bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
// 键盘输入:左/右/空格
isRunning = false;
if (keysPressed.contains(LogicalKeyboardKey.keyA) ||
keysPressed.contains(LogicalKeyboardKey.arrowLeft)) {
// 向左
velocity.x = -moveSpeed;
isRunning = true;
flipHorizontally = true; // 水平翻转,面朝左
} else if (keysPressed.contains(LogicalKeyboardKey.keyD) ||
keysPressed.contains(LogicalKeyboardKey.arrowRight)) {
// 向右
velocity.x = moveSpeed;
isRunning = true;
flipHorizontally = false; // 保持面朝右
} else {
// 无左右键时,停止水平移动
velocity.x = 0;
}
// 跳跃:空格
if (keysPressed.contains(LogicalKeyboardKey.space) && !isJumping) {
velocity.y = jumpSpeed;
isJumping = true;
}
// 根据 isRunning 和 isJumping 切换动画
if (isJumping) {
// 默认跳跃时保持第一帧静止图
animation = animation!..currentIndex = 0;
} else if (isRunning) {
// 如果是奔跑
animation = gameRef.loadAnimationFromFile('player_run.png', 6, 0.1);
} else {
// 静止
animation = gameRef.loadAnimationFromFile('player_idle.png', 4, 0.2);
}
return true;
}
@override
void onCollision(Set<Vector2> intersectionPoints, Collidable other) {
// 与平台碰撞
if (other is! Player) {
// 简单处理:如果从上方碰撞平台,则停止下落
if (velocity.y > 0 && position.y < other.position.y) {
position.y = other.position.y;
velocity.y = 0;
isJumping = false;
}
}
super.onCollision(intersectionPoints, other);
}
}
说明:
SpriteAnimationComponent
:可自动播放帧动画的 Component;KeyboardHandler
:监听键盘输入(PC / Web 平台有用);如果在移动端需监听触摸或虚拟按钮,可改成onTapDown
、JoystickComponent
等;velocity
用于模拟物理运动,包括重力与跳跃;- 在
onCollision
中,判断是从上方向下方与平台碰撞时,才停止下落并允许再次跳跃,将isJumping = false
。
6.2 定义 Player
Component
由于代码较长,上述 Player
类简要说明关键点即可。实际项目可以将动画加载逻辑单独封装到辅助方法,如 loadAnimationFromFile(...)
,避免复写。以下示例中假定我们已实现此辅助函数。
6.3 模拟重力与竖直运动
- 重力加速度:累加
velocity.y += gravity * dt;
使角色持续下落。 - 位置更新:
position += velocity * dt;
是基本的 Euler Integration。 - 落地检测:当角色与平台碰撞后,将
velocity.y
置零,并且将角色的y
位置固定在平台顶部,避免“穿透”或“抖动”。
6.4 处理左右移动输入
velocity.x = ±moveSpeed
:当用户按下左右按键(A / D / ← / →),设置水平速度。- 若松开左右键,则将水平速度置零,角色停止。
flipHorizontally
:根据方向将角色横向翻转,让角色面朝移动方向。
6.5 ASCII 图解:力学流程与速度向量
^ Y (重力向下)
|
Vy│ // 速度 Vy 随时间增加
│
│ o
│ o / ← 角色图示
│ \ /
│ \/
│ Platform
└─────────────────> X
每帧(dt 秒):
1. Vy ← Vy + g * dt (g > 0 表示向下)
2. Vx ← 由输入决定(-moveSpeed / 0 / +moveSpeed)
3. position.x += Vx * dt
position.y += Vy * dt
4. 检测与平台碰撞
如果落到平台顶部:
position.y = 平台 y 坐标
Vy = 0
isJumping = false
七、碰撞检测与平台交互
7.1 简单 AABB(轴对齐包围盒)碰撞
AABB 碰撞检测:判断两个矩形是否重叠:
bool aabbCollision(Rect a, Rect b) { return a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom; }
- 在 Flame 中,我们使用
HitboxRectangle()
自动创建与组件size
对应的 AABB 碰撞盒,并通过CollisionType
来决定如何参与碰撞。
7.2 Flame 内置的 HasCollisionDetection
与 CollisionComponent
- 在
MyGame
混入HasCollisionDetection
:启用碰撞检测系统。 各
Component
混入Hitbox
+Collidable
:Player
设置为CollisionType.active
,可主动检测并响应碰撞;Platform
设置为CollisionType.passive
,只被动响应。
Flame 在每帧 update()
后会自动遍历场景中所有 Collidable
,并调用 onCollision
回调。
7.3 角色与平台的碰撞回调与响应
以 Player.onCollision
为例(见第 6 节代码),简化逻辑如下:
@override
void onCollision(Set<Vector2> intersectionPoints, Collidable other) {
if (other is Platform) {
// intersectionPoints 中包含重叠区域的点,可以用来判断碰撞方向
final collisionPoint = intersectionPoints.first;
// 如果该碰撞点的 y 坐标小于角色中心 y,则视为从上方向下方碰到平台
if (collisionPoint.y > position.y - size.y / 2) {
// 站在平台上
position.y = other.position.y; // 平台顶部 y
velocity.y = 0;
isJumping = false;
}
}
super.onCollision(intersectionPoints, other);
}
解释:
intersectionPoints
为碰撞检测到的所有顶点,我们可通过这些点判断碰撞方向;- 这里简化为“小于某个阈值”时认定为脚下碰撞;在复杂场景中可进一步细化,如区分左/右/下方碰撞。
7.4 ASCII 图解:碰撞检测流程
世界坐标示意:
角色纵向包围盒: 平台纵向包围盒:
┌──────────┐ ┌──────────┐
│ │ │ │
│ o │ │ │
│ ┌─────┐ │ │ │
│ │Player│ │← 交叠 (overlap) └──────────┘
│ └─────┘ │
└──────────┘ (Platform)
检测流程:
1. 计算角色 AABB 与平台 AABB 是否重叠
2. 如果重叠,调用 Player.onCollision()
3. 在回调中判断“从上方”碰撞:intersectionPoints 与角色中心 y 比较
4. 若是脚下碰撞,将角色 y 固定到平台顶部,并重置垂直速度
八、角色跳跃与跳跃控制
8.1 跳跃力实现与跳跃高度控制
跳跃本质是给角色的 velocity.y
赋一个向上的初速度(负值),然后让重力拉回:
if (keysPressed.contains(LogicalKeyboardKey.space) && !isJumping) {
velocity.y = jumpSpeed; // 例如 -400
isJumping = true;
}
跳跃高度:由
jumpSpeed
、gravity
和初速度方向共同决定,- 最大高度 =
(jumpSpeed^2) / (2 * gravity)
; - 例如:
jumpSpeed = -400 px/s
,gravity = 800 px/s²
→ 最大高度约为 100 像素。
- 最大高度 =
8.2 “二段跳”与“可变跳跃”机制
二段跳:在空中再按一次跳跃键,可允许角色在空中再次获得一次初速度。
- 额外变量:
jumpCount
,初始化为 0,跳跃时jumpCount++
,如果jumpCount < maxJumpCount
(例如 2),则允许再次跳跃。 - 当角色与平台碰撞重置
isJumping = false
时,也需重置jumpCount = 0
。
- 额外变量:
可变跳跃:当用户按住跳跃键时,让角色跳得更高;松开则尽快掉落。
- 在
update(dt)
中,如果当前velocity.y < 0
(向上)且跳跃键已松开,可人为加大重力,比如velocity.y += gravity * extraFactor * dt
,使其更快掉落。
- 在
8.3 代码示例:跳跃逻辑与按键监听
在 Player
中修改:
class Player extends SpriteAnimationComponent
with HasGameRef, Hitbox, Collidable, KeyboardHandler {
// … 上文已定义的属性
int jumpCount = 0;
final int maxJumpCount = 2; // 二段跳
@override
void update(double dt) {
super.update(dt);
// 可变跳跃逻辑
if (velocity.y < 0 && !isJumpKeyPressed) {
// 如果正在上升且跳跃键已松开,额外加速度
velocity.y += gravity * 1.5 * dt;
} else {
velocity.y += gravity * dt;
}
position += velocity * dt;
// … 保持之前水平速度逻辑
}
bool isJumpKeyPressed = false;
@override
bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
// … 水平移动逻辑(同前)
// 跳跃输入
if (keysPressed.contains(LogicalKeyboardKey.space)) {
isJumpKeyPressed = true;
if (jumpCount < maxJumpCount) {
velocity.y = jumpSpeed;
jumpCount++;
isJumping = true;
}
} else {
isJumpKeyPressed = false;
}
// … 切换跑/静止动画,如前
return true;
}
@override
void onCollision(Set<Vector2> intersectionPoints, Collidable other) {
if (other is Platform) {
if (velocity.y > 0 && position.y < other.position.y) {
position.y = other.position.y;
velocity.y = 0;
isJumping = false;
jumpCount = 0; // 碰触地面,重置跳跃计数
}
}
super.onCollision(intersectionPoints, other);
}
}
要点:
jumpCount
控制最多maxJumpCount
次跳跃;isJumpKeyPressed
标志跳跃键状态,以实现“可变跳跃”(松开键后加大重力)。
九、相机跟随(Camera Follow)与世界平移
9.1 Flame 的 camera
概念
Flame 中的 camera
负责将“世界坐标”映射到“屏幕坐标”。默认情况下,相机静止,坐标原点在屏幕左上角。如果想实现场景随着角色移动而滚动,需要:
- 在
MyGame
中混入HasDraggableComponents
或直接使用camera
API。 - 在每帧
update(dt)
中,将相机的camera.followComponent(player)
,或手动设置相机坐标。
9.2 设置相机追踪角色
修改 MyGame
,添加对 Player
的引用,并在 update
中调用:
// lib/game/my_game.dart
import 'components/player.dart';
class MyGame extends FlameGame with HasCollisionDetection {
late Player player;
@override
Future<void> onLoad() async {
await add(BackgroundComponent());
// 添加平台
await add(Platform(Vector2(100, 400)));
await add(Platform(Vector2(400, 300)));
await add(Platform(Vector2(700, 500)));
// 添加角色
player = Player();
await add(player);
// 设置相机跟随角色
camera.followComponent(player,
worldBounds: Rect.fromLTWH(0, 0, 2000, 1000));
}
@override
void update(double dt) {
super.update(dt);
// 在 update 中, 相机会自动根据 player 位置更新视口
}
}
说明:
camera.followComponent(player)
:相机会将视口中心定位到player
的位置。worldBounds
定义了相机可移动的范围,这里设置为场景宽 2000、高 1000,避免相机移出世界边界。
9.3 ASCII 图解:视口(Viewport)与实体坐标映射
世界总宽度:2000,高度:1000
Y ↑
0 ┌────────────────────────────────────────────────┐
│ [ (0,0) 世界原点 ] │
│ │
│ Platform (100,400) │
│ ┌───────┐ │
│ │ │ │
│ │
400│ Player (500,400) │
│ o │
│ │
600│ Platform (400,300) │
│ ┌───────┐ │
│ │
1000└────────────────────────────────────────────────┘
0 500 1000 2000 → X
假设屏幕大小:800×600
当 player 位于 (500,400) 时,camera view:
┌────────────────────────────────────────────────┐
│ 相机视口 (Camera) │
│ │
│ 渲染坐标 (500 - 400, 400 - 300) = (100,100) │
│ │
│ 实际显示的世界区域: │
│ X ∈ [100, 900], Y ∈ [100, 700] │
│ 将映射到屏幕坐标 (0,0) – (800,600) │
└────────────────────────────────────────────────┘
映射关系:
- 当
camera.followComponent(player)
时,相机将玩家设置在视口中心或一个偏移位置; 世界坐标
(worldX, worldY)
与屏幕坐标(screenX, screenY)
的映射遵循:screenX = worldX - camera.position.x screenY = worldY - camera.position.y
- 具体偏移可通过
camera.viewport
属性调整。
- 当
十、丰富游戏体验:动画与粒子效果
为了让平台游戏更具吸引力,可以为角色添加多套动画,为道具或敌人添加粒子消失、跳跃特效等。
10.1 角色动作动画(Run / Idle / Jump)
在第 6 节的 Player
中,我们已实现了静止(Idle)和奔跑(Run)动画切换;若想加入“跳跃”动画,可按如下方式扩展:
- 准备
player_jump.png
,假设切分为 2 帧,大小 48×48。 在
Player.onLoad()
中加载jumpAnim
:final jumpSheet = await gameRef.images.load('player_jump.png'); final jumpAnim = SpriteAnimation.fromFrameData( jumpSheet, SpriteAnimationData.sequenced( amount: 2, stepTime: 0.15, textureSize: Vector2(48, 48), ), );
在
onKeyEvent
或update
中,根据isJumping
切换动画:if (isJumping) { animation = jumpAnim; } else if (isRunning) { animation = runAnim; } else { animation = idleAnim; }
- 注意:应确保每次切换动画时,不会重复创建动画实例,可将
idleAnim
、runAnim
、jumpAnim
存为成员变量,避免浪费内存与性能。
10.2 收集道具特效(ParticleComponent)
Flame 自带一个 ParticleComponent
,可用于显示烟雾、火花、碎片等短暂特效。示例:当角色与“金币”碰撞时,生成一段“星星粒子”特效。
// lib/game/components/coin.dart
import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
import 'package:flame/particles.dart';
import 'package:flame/game.dart';
import 'dart:ui';
class Coin extends SpriteComponent with HasGameRef, Hitbox, Collidable {
Coin(Vector2 position) : super(position: position, size: Vector2(32, 32)) {
anchor = Anchor.center;
}
@override
Future<void> onLoad() async {
sprite = await gameRef.loadSprite('coin.png');
addHitbox(HitboxCircle()); // 圆形碰撞盒
collisionType = CollisionType.passive;
}
@override
void onCollision(Set<Vector2> intersectionPoints, Collidable other) {
if (other is Player) {
// 1. 生成粒子特效
final particle = ParticleSystemComponent(
particle: Particle.generate(
count: 10,
lifespan: 0.5,
generator: (i) => AcceleratedParticle(
acceleration: Vector2(0, 200), // 重力加速
speed: Vector2.random() * 100 - Vector2.all(50),
position: position.clone(),
child: CircleParticle(
radius: 2,
paint: Paint()..color = const Color(0xFFFFD700),
),
),
),
);
gameRef.add(particle);
// 2. 移除自身
removeFromParent();
}
super.onCollision(intersectionPoints, other);
}
}
解释:
Particle.generate
:一次性生成多个Particle
;AcceleratedParticle
:带有重力加速效果;CircleParticle
:简易圆形粒子,radius
与paint.color
可自定义。- 当角色与金币碰撞时,会在金币位置爆炸出 10 颗黄色小圆点,然后销毁金币。
10.3 碰撞反馈:粒子爆炸与震屏效果
震屏效果(Screen Shake) 能增强打击感。可在 MyGame
中实现简单的震屏:
// lib/game/my_game.dart
import 'dart:math';
class MyGame extends FlameGame with HasCollisionDetection {
double shakeTimer = 0.0;
final double shakeDuration = 0.3; // 震屏时长
final double shakeIntensity = 8.0; // 震幅像素值
@override
void update(double dt) {
super.update(dt);
if (shakeTimer > 0) {
shakeTimer -= dt;
// 随机偏移相机位置
final offsetX = (Random().nextDouble() * 2 - 1) * shakeIntensity;
final offsetY = (Random().nextDouble() * 2 - 1) * shakeIntensity;
camera.snapTo(Vector2(player.x + offsetX, player.y + offsetY));
if (shakeTimer <= 0) {
// 恢复正常跟随
camera.followComponent(player, worldBounds: worldBounds);
}
}
}
void triggerScreenShake() {
shakeTimer = shakeDuration;
}
}
- 使用方式:当角色与敌人碰撞或道具收集时,调用
gameRef.triggerScreenShake()
,在update()
中临时打乱相机位置,营造震屏效果。
十一、打包与性能优化
11.1 精灵纹理图集(Sprite Sheets)打包
将多张小图合并成一张大图(纹理图集),可减少 GPU 纹理切换次数、加速渲染。推荐使用工具如 TexturePacker、Shoebox,或 Flutter 社区的 flame\_svg、flame\_spritesheet 插件来打包。示例打包后,使用 SpriteSheet
进行分割加载:
// 假设已将 player_idle.png, player_run.png 等打包到 spritesheet.png
final image = await gameRef.images.load('spritesheet.png');
final sheet = SpriteSheet(
image: image,
srcSize: Vector2(48, 48),
);
// 加载动画
final idleAnim = sheet.createAnimation(row: 0, stepTime: 0.2, to: 4);
final runAnim = sheet.createAnimation(row: 1, stepTime: 0.1, to: 6);
说明:
row: 0
表示第 0 行帧,to: 4
表示 4 帧;- 将所有小图“打包”在同一张大图,可显著降低渲染开销。
11.2 异步资源加载与 FutureBuilder
为了避免卡顿,可在 游戏启动前 使用 preload
,或在 GameWidget
的 loadingBuilder
中分批加载资源,并显示进度。示例:
// 在 MyGame 中添加 preload
Future<void> onLoad() async {
// 1. 预加载所有图片
await images.loadAll([
'spritesheet.png',
'background.png',
'platform.png',
'coin.png',
]);
// 2. 然后创建 SpriteSheet、添加组件等
}
11.3 简单性能调试:FPS 监测与调优建议
Flame 提供了一个 FPS 组件,可用来测试帧率:
// 在 MyGame.onLoad() 中添加
add(FpsTextComponent(
position: Vector2(10, 10),
textRenderer: TextPaint(
style: const TextStyle(color: Colors.white, fontSize: 12),
),
));
调优建议:
- 减少 Draw Call:将多个静态元素合并到同一个
SpriteComponent
或使用SpriteBatchComponent
; - 避免在
update()
中创建新对象:频繁 new List、Vector2 会造成 GC 卡顿,建议复用变量; - 按需加载:只在
onLoad()
或切换关卡时加载资源,避免动态 load 时卡顿; - 粒子数量:尽量限制同时存在的粒子数,如果场景中粒子过多,可以降低粒子生成率或缩短寿命;
- 使用纹理图集:将小精灵集合到一张图,减少纹理切换;
- 合理使用
updatePriority
与renderPriority
:给不同组件设置优先级,让逻辑更新 / 渲染有序执行。
- 减少 Draw Call:将多个静态元素合并到同一个
十二、总结与后续拓展方向
本文从 环境搭建、核心概念、基础组件、角色物理与碰撞、相机控制、动画与粒子 以及 性能优化 等多维度,详细介绍了如何利用 Flutter + Flame 快速构建一款简单的 横版平台游戏:
- 环境准备:明晰项目结构与依赖,为接下来的开发奠定基础。
- 核心概念:理解 Flame 的组件体系、游戏循环与坐标映射,确保代码结构清晰。
- 游戏基础搭建:通过
BackgroundComponent
、Platform
、Player
等组件,完成关卡框架与重力逻辑,实现角色在平台间行走、跳跃。 - 碰撞检测:使用
Hitbox
+Collidable
实现简单的 AABB 碰撞,并在onCollision
回调中处理物理响应。 - 动画与特效:加载多套角色动画、粒子系统与震屏效果,为游戏增添趣味性与视觉冲击。
- 相机跟随:通过
camera.followComponent(player)
,让视口始终聚焦角色,实现场景滚动。 - 性能优化:建议使用纹理图集、异步资源加载、粒子数量控制等手段,在保持流畅帧率的同时,丰富游戏内容。
后续拓展思路:
- 增加敌人 AI:编写
Enemy
Component,添加简单的巡逻、追击逻辑;- 关卡编辑器:设计一套关卡文件(JSON 或 Tiled 格式),在游戏中动态加载;
- 物理引擎集成:引入
flame_forge2d
,实现斜坡、跳板、碰撞弹性等真实效果;- 多种输入适配:支持触摸、虚拟摇杆、桌面键盘与手柄等多种操作方式;
- UI 与存档:结合 Flutter 的 Widget 生态实现主菜单、暂停界面、游戏存档与读档功能;
- 多人联机:利用
socket.io
、Firebase
或WebSocket
,实现简单的 PVP 或联机关卡模式。
希望本文能帮助你理解并掌握 Flutter + Flame 平台游戏开发的方方面面,助你快速上手并扩展更多玩法,打造一款流畅、画面精美、机制丰富的横版平台游戏。
评论已关闭