本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
一、碰撞检测
碰撞检测在游戏开发中是非常非常重要的一环,无论是子弹命中角色,还是主角碰到陷阱,都需要检测碰撞情况来触发核心的业务逻辑。
1. 碰撞检测区域
在正式处理 Trex 游戏的碰撞需求之前,这里仍是准备了一些开胃小菜,辅助大家更容易理解 Flame 中的碰撞检测。第一道菜如下所示:
准备可拖拽的直线(针),移动过程中检测它和小恐龙的碰撞情况。
两者碰撞时,将针和小恐龙的外框颜色置为蓝色示意。
Line
构件可以通过 render
回调绘制线;想要一个构件响应碰撞事件,可以:
- [1] 混入
CollisionCallbacks
。 - [2] 为构件添加
RectangleHitbox
支持矩形碰撞区。 - [3] 覆写 onCollisionStart 响应碰撞开始事件;onCollisionEnd 响应碰撞结束事件。
dart
---->[lib/world/11/heroes/line.dart]----
class Line extends PositionComponent with CollisionCallbacks {
Line() : super(position: Vector2(300, 100), size: Vector2(120, 2));
final Paint _paint = Paint() ..color = Colors.black
..style = PaintingStyle.stroke..strokeWidth = 1;
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawLine(Offset.zero, Offset(width, 0), _paint);
}
@override
Future<void> onLoad() async {
add(RectangleHitbox());
}
@override
void onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
// 碰撞开始时将线的颜色置为蓝色
_paint.color = Colors.blue;
}
@override
void onCollisionEnd(PositionComponent other) {
super.onCollisionEnd(other);
// 碰撞开始时将线的颜色置为黑色
_paint.color = Colors.black;
}
}
小恐龙构件也是类似,RectangleHitbox
本质上也是一种 Component 构件,可以设置 debugMode
展示区域调试信息;debugColor
时调试信息的颜色。
dart
---->[lib/world/11/heroes/player.dart]----
class PlayerComponent extends SpriteComponent with HasGameRef<GameWorld>, CollisionCallbacks {
PlayerComponent();
@override
Future<void> onLoad() async {
super.onLoad();
sprite = Sprite(
game.spriteImage,
srcPosition: Vector2(1514.0, 4.0),
srcSize: Vector2(88.0, 90.0),
);
position = Vector2(100, 50);
// 添加矩形碰撞区
RectangleHitbox rHitBox = RectangleHitbox();
rHitBox..debugMode = true..debugColor = Colors.orange;
add(rHitBox);
}
@override
void onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
children.first.debugColor = Colors.blue;
}
@override
void onCollisionEnd(PositionComponent other) {
super.onCollisionEnd(other);
children.first.debugColor = Colors.orange;
}
}
最后也是最主要的一点, 游戏主类 GameWorld
需要混入 HasCollisionDetection 启用碰撞检测。拖动针
移动的功能,可以通过混入 PanDetector
覆写 onPanUpdate 回调,处理针位置坐标的偏移量:
dart
---->[lib/world/11/game_world.dart]----
class GameWorld extends FlameGame with PanDetector, HasCollisionDetection {
late Line line;
late final Image spriteImage;
PlayerComponent player = PlayerComponent();
@override
Future<void> onLoad() async {
spriteImage = await Flame.images.load('trex/trex.png');
line = Line();
add(player);
add(line);
}
@override
Color backgroundColor() => const Color(0xffffffff);
@override
void onPanUpdate(DragUpdateInfo info) {
line.position += info.delta.global;
}
}
2. 细致的碰撞区域
如果仅是橙色矩形框(下左图) 作为碰撞区域,在游戏中如下红色的 空白矩形区域
碰到障碍物时,也会被视为碰撞。其实我们并不想这样,所以可以添加多个小矩形检测区域(下右图)
单一矩形检测区 | 多矩形检测区 |
---|---|
下面是将检测区域变为三个小矩形的效果,通过探针碰撞可以很清晰地看出:左上角、右下角、左下角的空白区域将忽略碰撞, 这样可以更精准地校验碰撞的有效性。
在代码中只需要添加多个 RectangleHitbox
即可;如下所示,可以通过 RectangleHitbox.relative
构造,方便以父区域为基准,创建一个矩形区域。其中:
parentSize : 父区域尺寸。
定义入参: 相对于父区域的宽高百分比。
position: 生成区域的偏移量。
anchor : 与父区域的对其锚点
比如头部的小矩形,以精灵图的整个矩形框为基准,宽是整体区域的 45%; 高时整体区域的 35% ; 在右上角对其、并有略微向左的偏移量:
dart
---->[lib/world/12/heroes/player.dart]----
List<RectangleHitbox> createHitBoxes() {
return [
RectangleHitbox.relative(
Vector2(0.45, 0.35),
position: Vector2(4, 0),
anchor: const Anchor(-1,0),
parentSize: size,
),
RectangleHitbox.relative(
Vector2(0.66, 0.45),
position: Vector2(4, 32),
parentSize: size,
),
RectangleHitbox.relative(
Vector2(0.3, 0.15),
position: Vector2(24, height-16),
parentSize: size,
)
];
}
然后我们依次对需要碰撞的精灵设计碰撞区域,达到如下的效果。这里角色的区域设定就不一一介绍了,
具体代码详见: lib/world/12
二、实现 Trex 核心功能
现在万事俱备只欠东风,只要在游戏过程中校验小恐龙和障碍物的碰撞,在碰撞时结束游戏即可。下面是录屏效果,其中展示出游戏过程中小恐龙和障碍物的碰撞边界:
1. 恐龙状态与碰撞区域
这里代码中有一个小难点:小恐龙具有趴下的状态,此时碰撞区域和 running 不同。所以 Player
组件需要根据小恐龙状态 动态改变碰撞区域,如下所示将两个区域定义为成员变量:
dart
---->[lib/trex/05/heroes/player.dart]----
/// 蹲下碰撞区域
late final List<RectangleHitbox> _downHitBoxes = [
RectangleHitbox.relative(
Vector2(0.96, 0.42),
position: Vector2(4, 36),
parentSize: size,
),
RectangleHitbox.relative(
Vector2(0.3, 0.15),
position: Vector2(24, height - 16),
parentSize: size,
)
];
/// 其他碰撞区域
late final List<RectangleHitbox> _customHitBoxes = [
RectangleHitbox.relative(
Vector2(0.45, 0.35),
position: Vector2(4, 0),
anchor: const Anchor(-1, 0),
parentSize: size,
),
RectangleHitbox.relative(
Vector2(0.66, 0.3),
position: Vector2(4, 32),
parentSize: size,
),
RectangleHitbox.relative(
Vector2(0.35, 0.28),
position: Vector2(22, height - 30),
parentSize: size,
)
];
下面定义 switchHitBoxByState
方法根据小恐龙的新旧状态处理碰撞区域:当新状态是蹲下时,旧状态不是蹲下,此时移除之前的碰撞区域,将 _downHitBoxes
添加其中即可。站起奔跑时同理:
dart
---->[lib/trex/05/heroes/player.dart]----
void switchHitBoxByState(PlayerState? newState, PlayerState? oldState) {
if (newState == PlayerState.down && oldState != PlayerState.down) {
// 新状态是蹲下,修改碰撞区域
removeWhere((component) => component is RectangleHitbox);
addAll(_downHitBoxes);
}
if (oldState == PlayerState.down && newState != PlayerState.down || oldState == null) {
// 旧状态是蹲下,修改碰撞区域
removeWhere((component) => component is RectangleHitbox);
addAll(_customHitBoxes);
}
}
2. 小恐龙变化的逻辑
在 Player 中定义设置 state
的方法,切换蹲下和起立的状态。是否可以蹲下或起立,需要进行校验,比如只有正在奔跑 (running) 时,才能切换到蹲下状态:
dart
---->[lib/trex/05/heroes/player.dart]----
set state(PlayerState newState) {
PlayerState? old = current;
// 死亡或跳跃中不允许修改状态
if(old == PlayerState.crashed ||old == PlayerState.jumping) return;
// 蹲下
if (old == PlayerState.running && newState == PlayerState.down) {
current = PlayerState.down;
switchHitBoxByState(current, old);
}
// 起立
if (old != PlayerState.running && newState == PlayerState.running) {
current = PlayerState.running;
if(old==PlayerState.down){
switchHitBoxByState(current, old);
}
}
// 起跳
if (old != PlayerState.jumping && newState == PlayerState.jumping) {
current = PlayerState.jumping;
vY = -770;
sY = 0;
}
}
按下 ↓
按键时 (arrowDown),小恐龙蹲下;键盘事件为 RawKeyUpEvent
时,表示抬起事件,当抬起 ↓
按键时,让小恐龙站起:
dart
---->[lib/trex/05/trex_game.dart]----
@override
KeyEventResult onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
if (event is RawKeyUpEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
player.state = PlayerState.running;
}
}
if (keysPressed.contains(LogicalKeyboardKey.arrowDown)) {
player.state = PlayerState.down;
}
if (keysPressed.contains(LogicalKeyboardKey.space)) {
player.state = PlayerState.jumping;
}
return KeyEventResult.handled;
}
3.移动端的交互优化
到这里,最核心的 跳跃躲避障碍
的功能就实现了。但对于移动端来说,一般不会连接键盘,所以跳跃和蹲下的交互需要设计一些。如果交互比较复杂,可以弄些按钮的控件来触发,这里只有两个动作,可以将屏幕分成两半:
onTapDown
的回调参数 TapDownEvent
对象中包含 localPosition
的点位信息,可以用其进行校验:横坐标值小于屏幕宽度一半处理跳跃,反之蹲下。 onTapUp
回调用于监听手指抬起事件:
dart
---->[lib/trex/05/trex_game.dart]----
@override
void onTapDown(TapDownEvent event) {
if (player.current == PlayerState.waiting) {
moveSpeed = 320;
player.state = PlayerState.running;
}
if (player.current == PlayerState.running) {
if (event.localPosition.x < size.x / 2) {
player.state = PlayerState.jumping;
} else {
player.state = PlayerState.down;
}
}
}
@override
void onTapUp(TapUpEvent event) {
super.onTapUp(event);
if (event.localPosition.x < size.x / 2) {
} else {
player.state = PlayerState.running;
}
}
三、游戏场景优化
下面来规整一下项目,完善游戏整体流程。比如:
游戏状态维护:等待、运行中、游戏结束。
游戏重新开始功能。
1. 游戏场景的规划
其实之前写的交互只是游戏的场景之一。如下所示,根据游戏的不同状态,还需要给出两个场景 等待场景 和 结束场景。
等待场景 | 结束场景 |
---|---|
之前代码中把所有构件都塞到 TrexGame
中,在完整流程中需要根据游戏状态展示不同场景。如果仍然全部塞入TrexGame
中,会让代码显得非常杂乱。这里引入 场景 Scene 的概念来维护不同场景的构件:
代码中只需要根据游戏状态,显示不同的场景组合即可。比如游戏开始时是 waiting
状态,只展示 WaitingScene 构件;点击启动后移除 WaitingScene
,创建并添加 RunningScene
构件即可;同理游戏结束时添加 GameOverScene。
dart
---->[lib/trex/06/main.dart]----
enum GameState { waiting, running, gameOver }
2. 游戏运动场景 RunningScene
这里将之前游戏主类中的构件提取到 RunningScene 中,视为游戏运行的主场景,其中提供 state 的获取和设置方法,以供外界操作 Player 的状态:
dart
---->[lib/trex/06/scene/running_scene.dart]----
class RunningScene extends PositionComponent with HasGameReference<TrexGame> {
final Player player = Player();
final ScoreComponent score = ScoreComponent();
final GroundComponent ground = GroundComponent();
final CloudManager cloudManager = CloudManager();
final ObstacleManager obstacleManager = ObstacleManager();
@override
FutureOr<void> onLoad() {
add(cloudManager);
add(ground);
add(obstacleManager);
add(player);
add(score);
return super.onLoad();
}
set state(PlayerState newState) {
player.state = newState;
}
PlayerState get state => player.current ?? PlayerState.waiting;
}
3. 等待和结束场景
WaitingScene 构件比较简单:通过 SpriteComponent
呈现小恐龙等待状态图片; 以及 TextComponent
展示提示文字:
dart
class WaitingScene extends PositionComponent with HasGameReference<TrexGame> {
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
y = (size.y - text.height + sprite.height) / 2;
x = (size.x - text.width) / 2;
width = size.x;
height = size.y;
}
late TextComponent text = TextComponent(
text: '按任意键或点击屏幕开始',
textRenderer: TextPaint(style: const TextStyle(fontSize: 24, color: Colors.black)),
);
late SpriteComponent sprite = SpriteComponent(
sprite: Sprite(
game.spriteImage,
srcPosition: Vector2(76.0, 6.0),
srcSize: Vector2(88.0, 90.0),
));
@override
FutureOr<void> onLoad() {
add(text);
add(sprite);
sprite.y -= sprite.height + 10;
return super.onLoad();
}
}
GameOverScene 中展示展示两个精灵图片,覆盖在 RunningScene 上方:
dart
---->[lib/trex/06/scene/game_over_scene.dart]----
class GameOverScene extends PositionComponent with HasGameReference<TrexGame> {
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
y = (size.y - spriteText.height - spriteButton.height) / 2;
x = (size.x - spriteText.width) / 2;
}
late SpriteComponent spriteText = SpriteComponent(
sprite: Sprite(
game.spriteImage,
srcPosition: Vector2(954.0, 30.0),
srcSize: Vector2(384.0, 32.0),
));
late SpriteComponent spriteButton = SpriteComponent(
sprite: Sprite(
game.spriteImage,
srcPosition: Vector2(4.0, 4.0),
srcSize: Vector2(72.0, 62.0),
));
@override
FutureOr<void> onLoad() {
add(spriteButton);
add(spriteText);
spriteButton.y += spriteText.height + 20;
spriteButton.x += (spriteText.width-spriteButton.width)/2;
return super.onLoad();
}
}
4. 游戏主类对场景的维护
现在 TrexGame
在 onLoad 回调中只需要加入等待场景 waitingScene
:
dart
---->[lib/trex/06/trex_game.dart]----
class TrexGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection {
double moveSpeed = 0;
double kInitSpeed = 320;
late final Image spriteImage;
GameState state = GameState.waiting;
late RunningScene runningScene;
final WaitingScene waitingScene = WaitingScene();
final GameOverScene gameOverScene = GameOverScene();
@override
Future<void> onLoad() async {
spriteImage = await Flame.images.load('trex/trex.png');
add(waitingScene);
}
如果游戏状态是 GameState.waiting
,点击屏幕或任意按键时进入游戏。具体逻辑是:移除 WaitingScene 并通过 startRunningGame
方法动态添加 RunningScene
。
dart
---->[点击或按键回调时]----
if (state == GameState.waiting) {
removeWhere((component) => component is WaitingScene);
startRunningGame();
return;
}
void startRunningGame(){
runningScene = RunningScene();
add(runningScene);
moveSpeed = kInitSpeed;
runningScene.state = PlayerState.running;
state = GameState.running;
}
游戏结束时,触发 gameOver
方法,添加 gameOverScene
场景;游戏结束之后,再次点击需要重新开始。这里的逻辑处理是:将 GameOverScene 和 RunningScene 从游戏中移除,然后通过 startRunningGame
重新创建并添加。
dart
void gameOver() {
moveSpeed = 0;
runningScene.state = PlayerState.crashed;
state = GameState.gameOver;
add(gameOverScene);
}
---->[点击回调时]----
if (state == GameState.gameOver) {
removeWhere((component) => component is GameOverScene || component is RunningScene);
startRunningGame();
return;
}
四、游戏功能优化
上面 等待 -> 运行 -> 结束 -> 重新开始 功能实现完毕,整个游戏的基本交互逻辑就融会贯通了。在此基础上,我们可以继续完善或者拓展新的功能:
游戏分数功能,持久化记录最高记录。
根据分数的增加,加快地面运动速度。
展示游戏帧刷新速率 FPS 。
1.游戏得分的设计
游戏的得分在 ScoreComponent
构件中进行展示,计分方式大家也可以自己设计。这里取运动的总距离 _distance~/5
作为得分;每 1000 分,地面的移动速度增加 20 px/s ,最高增加 10 次:
dart
---->[lib/trex/06/heroes/score_component.dart]----
int _score = 0;
int _highScore = 0;
double _distance = 0;
final double acceleration = 20;
@override
void update(double dt) {
super.update(dt);
if (game.state == GameState.running) {
_distance += dt * game.moveSpeed;
score = _distance ~/ 5;
// 通过分数确定等级,提高速度
int level = _score ~/ 1000;
if (level <= 10) {
game.moveSpeed = game.kInitSpeed + level * acceleration;
}
}
}
2. 通过 shared_preferences 保存最高记录
shared_preferences 是一个全平台的基于 xml 配置文件的数据持久化手段;实现需要在 pubspec.yaml
中配置
yaml
dependencies:
...
shared_preferences: ^2.2.2
可以在游戏主类中提供全局的访问点 sp
, 在 onLoad
回调中异步初始化 SharedPreferences 对象;在 gameOver 方法中获取到 scoreComponent 触发 saveHistory 保存历史记录:
dart
---->[lib/trex/06/trex_game.dart]----
class TrexGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection {
late SharedPreferences sp;
@override
Future<void> onLoad() async {
sp = await SharedPreferences.getInstance();
// 略同...
}
void gameOver() {
// 略同...
runningScene.scoreComponent.saveHistory();
}
然后在 ScoreComponent 中定义 highestScore 表示最高记录,在 onLoad
回调中读取本地存储的最高记录;并提供 saveHistory
方法,当分数大于历史记录时,保存新的历史记录。这样即使退出游戏,下次进入时最高记录依旧生效:
dart
const String kHighestScoreKey = 'highestScore';
class ScoreComponent extends PositionComponent with HasGameReference<TrexGame> {
int highestScore = 0;
@override
Future<void> onLoad() async {
highestScore = game.sp.getInt(kHighestScoreKey)??0;
// 略同...
}
void saveHistory() async{
if (score > highestScore) {
highestScore = score;
await game.sp.setInt(kHighestScoreKey, highestScore);
}
}
3. 展示每秒刷新频率 FPS
游戏中每秒刷新频率 FPS 是性能体验很重要的标准,刷新率越高说明游戏性能体验越好。一般电影是 24fps ,仅对于人类视觉而言就可以很流畅;但游戏是交互性的产品,需要及时反馈,所以流畅的游戏帧率要求高一些,一般游戏在是 40fps 左右就可以称之为流畅,达到 60 fps 就是非常好的体验了。
小游戏的逻辑处理对于桌面端来说是轻飘飘的,我这里可以达到 150 FPS ,不同的电脑会有所差异。在 Android 手机上也能稳定在 60 FPS。可以说目前该游戏没有性能上的问题。
想要在界面上展示 FPS 也非常简单,只要统计一秒钟渲染多少帧即可。如下所示,每帧渲染时都会触发 update
回调,这里每 500 ms 统计一次期间的帧数,除以真正流逝的时间 span 即可:
dart
---->[lib/trex/06/heroes/fps_text.dart]----
class FpsText extends PositionComponent {
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
x = 10;
y = 8;
}
late TextComponent text = TextComponent(
textRenderer: TextPaint(style: const TextStyle(fontSize: 14, color: Colors.grey)),
);
@override
Future<void> onLoad() async => add(text);
int _timeRecord = 0;
int _frameCount = 0;
@override
void update(double dt) {
super.update(dt);
int now = DateTime.now().millisecondsSinceEpoch;
_frameCount++;
int span = now - _timeRecord;
if (span > 500) {
_timeRecord = now;
text.text = 'FPS: ${(_frameCount / span * 1000).toInt()}';
_frameCount = 0;
}
}
}
本集的重点在于对角色碰撞的处理,以及通过划分场景,让游戏的交互逻辑融会贯通。玩家可以在游戏结束后重新开始,从而生生不息。
经历了四集,到这里恐龙跳跃 Trex 1.0.0
版基本功能就实现完毕了。你可以将它打包成全平台的应用程序,分享给大小朋友玩耍了。通过这个过程,想必大家对 Flame 已经有了一定的认知,接下来我们将继续通过其他小游戏学习,敬请期待~