本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
Trex 小游戏介绍
Chrome 在断网时,会有一个小恐龙跳跃躲避障碍物的小游戏,也可以在 chrome://dino/
地址访问。这个游戏 麻雀虽小五脏俱全 ,是体验游戏开发很好的切入点。
它包含以下几个要点:
- 角色呈现
- 跳跃移动
- 碰撞检测
- 分数记录
这个小游戏将作为 Flutter&Flame 第二季的先锋。通过对恐龙跳跃小游戏的逐步实现,来初步体验 Flame 开发一个小游戏的基本工作流程。下面开始进入游戏开发的世界吧~
一、地面、云朵和障碍物的呈现
本小结你将收获的技能点: 本节源码见 [lib/trex/01]
[1]. 资源加载 : 运行 Flame 的项目代码,加载图片资源。
[2]. 角色的呈现: 如何通过精灵图将角色展示到场景中。
[3]. 角色的定位:如何控制角色在场景中的位置。
在 Flame 中,场景中的一切都是 Component 对象的组合,为了区分Flutter 中的 Widget (组件),文中一律称之为 构件 (游戏的构成零件) 。比如下图中的小恐龙、云朵、地面、分数、障碍物,都是一个个被加入到游戏主类中的构件:
本小节我们将读取图片资源,展示地面、云朵和障碍物三个静态的角色,了解一下 Component 的基本使用。
1.游戏主类和资源图片加载
Flame 中通过 GameWidget 组件呈现,其中传入一个 FlameGame 的派生类作为游戏的入口。这里游戏中的所有资源通过 精灵图
的方式集合在一起,如下所示:
TrexGame 可以在 onLoad 回调 中异步加载资源;游戏的图片资源可以通过 Flame.images.load
方法加载。复写 backgroundColor 方法,可以修改游戏的背景色(默认是黑色)
---->[lib/trex/01/main.dart]----
main() => runApp(GameWidget(game: TrexGame()));
---->[lib/trex/01/trex_game.dart]----
class TrexGame extends FlameGame {
late final Image spriteImage;
@override
Future<void> onLoad() async{
spriteImage = await Flame.images.load( 'trex/trex.png' );
}
@override
Color backgroundColor() {
return const Color(0xffffffff);
}
}
2. 静态角色的呈现: 云朵
拿云朵来说,它在游戏中的也是以 Component 的身份呈现在场景中的。SpriteComponent
可以展示一个精灵资源,对于 精灵图
来说,我们可以通过顶点坐标 srcPosition 和尺寸 srcSize 来确定某一个精灵,如下示意:
下面定义 CloudComponent
继承自 SpriteComponent,在 onLoad 回调中根据图片资源对象创建 Sprite ,并为 sprite 赋值即可:
tips:
with HasGameReference<TrexGame>
后,类中可以通过 game 得到 TrexGame 对象.
---->[lib/trex/01/heroes/cloud_component.dart]----
class CloudComponent extends SpriteComponent with HasGameReference<TrexGame>{
@override
Future<void> onLoad() async {
sprite = Sprite(
game.spriteImage,
srcPosition: Vector2(166.0, 2.0),
srcSize: Vector2(92.0, 28.0),
);
}
}
有人可能会问,我怎么能知道坐标和尺寸的确切数值?
- 精灵图制作时,工具会给出坐标相关的配置信息(
如下图
),可以解析 json 文件得到精灵尺寸和位置。 - 如果你是拿别人的精灵图,且没有配置信息,可以自己用 PhotoShop 量一下。
云朵的构件已经准备完毕,接下来把它 "挂在"
屏幕上。TrexGame#onLoad
方法中通过 add 方法添加 Component 进行展示。构件默认会定位在场景的 左上角
:
---->[lib/trex/01/trex_game.dart]----
@override
Future<void> onLoad() async {
spriteImage = await Flame.images.load('trex/trex.png');
add(CloudComponent());
}
3. 构件的定位: 地面和障碍物
如下所示,我们先把地面放在场景中。同样定义一个 GroundComponent
的构件,在 onLoad
时设置地面对于的精灵图。默认会在左上角,SpriteComponent 派生类中,可以通过 x,y
决定构件的位置:
onGameResize 回调会 在窗口尺寸变化时
或者构件加载完后
触发,其中的 size 是窗口尺寸。这里想让地面在中间偏下一点,只要将 y
赋值即可:
class GroundComponent extends SpriteComponent with HasGameReference<TrexGame>{
final double groundHeight = 24;
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
y = size.y / 2 + groundHeight/2;
}
@override
Future<void> onLoad() async {
sprite = Sprite(
game.spriteImage,
srcPosition: Vector2(2, 104.0),
srcSize: Vector2(2400, groundHeight),
);
}
}
接下来把第一个障碍物放到场景的中间,同理创建一个 ObstacleComponent 构件表示障碍物。在 onLoad 回调
中创建 Sprite ; 在 onGameResize 回调
中设置偏移量:
class ObstacleComponent extends SpriteComponent
with HasGameReference<TrexGame> {
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
y = size.y / 2 - 55.0 + 21;
x = size.x / 2 - width / 2;
}
@override
Future<void> onLoad() async {
sprite = Sprite(
game.spriteImage,
srcPosition: Vector2(446.0, 2.0),
srcSize: Vector2(34.0, 70.0),
);
}
}
通过云朵、地面、障碍物三个图片精灵的展示,大家应该对如何呈现一个图片资源有了清晰地认知。
小思考: 如何在场景中添加多个障碍物和云朵? (稍后介绍)
二、小恐龙的呈现与状态变化
本小结你将收获的技能点: 本节源码见 [lib/trex/02]
[1]. 多状态精灵 : 一个构建中如何拥有多种状态,并支持切换。
[2]. 键盘和手势 : 通过点击事件和键盘回调事件,切换小恐龙的展示状态。
1. 多状态精灵图片的处理
场地已经在界面上了,那么接下来让小恐龙登场吧! 在游戏中,小恐龙有 不同状态
, 使用需要展示不同的图片资源,这里将它的状态通过 PlayerState
表示:
---->[lib/trex/02/heroes/player.dart]----
enum PlayerState {
waiting, // 等待
running, // 奔跑
jumping, // 跳跃
down, // 趴下
crashed, // 死亡
}
像这种不同状态有不同图片,而且某些状态需要有 序列帧动画 的角色。可以通过 SpriteAnimationGroupComponent
构件进行展示,它支持一个 泛型 T
表示状态。创建 Player 类型如下:
---->[lib/trex/02/heroes/player.dart]----
class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<TrexGame>{
// TODO
}
上面的 SpriteComponent 通过 sprite
对象展示静态的精灵图片,这里 SpriteAnimationGroupComponent 有一个映射 animations
对象:
以状态 T 为键,以 SpriteAnimation 为值。我们需要完成对 animations 映射赋值的工作。
其中 SpriteAnimation
就是序列帧图片,用来展示角色,每一帧图片在对应精灵图的一个矩形区域。下面简单封装 loadAnimation
方法来加载:
- 传入角色的尺寸 size 和位置列表 frames,来确定矩形区域。
- stepTime 表示帧动画的间隔时间秒数。
---->[lib/trex/02/heroes/player.dart]----
SpriteAnimation loadAnimation({
required Vector2 size,
required List<Vector2> frames,
double stepTime = double.infinity,
}) {
return SpriteAnimation.spriteList(
frames.map((vector) => Sprite(
game.spriteImage,
srcSize: size,
srcPosition: vector,
)).toList(),
stepTime: stepTime,
);
}
2. 映射关系的初始化和呈现
在 Player 构件的 onLoad 回到中通过 _initAnimations
方法来初始化映射关系:
---->[lib/trex/02/heroes/player.dart]----
@override
Future<void> onLoad() async {
_initAnimations();
}
将不同的 PlayerState
状态,对应为不同的 SpriteAnimation
资源。其中 frames
表示图片的序列帧起点坐标,有多个就表示当前状态具有动画效果:
---->[lib/trex/01/heroes/player.dart]----
void _initAnimations(){
animations = {
PlayerState.running: loadAnimation(
size: Vector2(88.0, 90.0),
frames: [Vector2(1514.0, 4.0), Vector2(1602.0, 4.0)],
stepTime: 0.2,
),
PlayerState.waiting: loadAnimation(
size: Vector2(88.0, 90.0),
frames: [Vector2(76.0, 6.0)],
),
PlayerState.jumping: loadAnimation(
size: Vector2(88.0, 90.0),
frames: [Vector2(1338.0, 4.0)],
),
PlayerState.crashed: loadAnimation(
size: Vector2(88.0, 90.0),
frames: [Vector2(1778.0, 4.0)],
),
PlayerState.down: loadAnimation(
size: Vector2(114.0, 90.0),
frames: [Vector2(1866, 6.0), Vector2(1984, 6.0)],
stepTime: 0.2,
),
};
current = PlayerState.waiting;
}
然后在 TrexGame 中创建 Player 对象,在 onLoad 方法中通过 add 添加构建,此时角色精灵就可以展示出来了。
SpriteAnimationGroupComponent 中同样可以通过 x 和 y
数值设置构件的位置。在 onGameResize
回调中根据窗口尺寸进行设置:
---->[lib/trex/02/heroes/player.dart]----
class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<TrexGame> {
double get centerY => (game.size.y / 2) - height / 2;
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
y = centerY;
x = 60;
}
/// 略同...
3.键盘所示与 Player 状态切换
SpriteAnimationGroupComponent
中的 current
表示当前的状态,更新该值就可以展示对应状态的图片资源。如下所示,在 toggleState
方法中轮换状态值:
---->[lib/trex/02/heroes/player.dart]----
void toggleState() {
int nextIndex = (current?.index ?? 0) + 1;
nextIndex = nextIndex % PlayerState.values.length;
current = PlayerState.values[nextIndex];
}
然后只要在合适的时机触发 Player#toggleState
方法即可切换小恐龙的状态。通过混入:
- KeyboardEvents 监听键盘事件。
- TapCallbacks 监听点击手势事件。
下面代码中,监听到按键 a
以及 onTapDown
事件时,触发 player.toggleState()
:
---->[lib/trex/02/heroes/player.dart]----
import 'package:flutter/widgets.dart' hide Image;
class TrexGame extends FlameGame with KeyboardEvents, TapCallbacks{
/// 略同...
@override
KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed ) {
if (keysPressed.contains(LogicalKeyboardKey.keyA)) {
player.toggleState();
}
return KeyEventResult.handled;
}
@override
void onTapDown(TapDownEvent event) {
player.toggleState();
}
}
SpriteAnimationGroupComponent
可以通过 debugMode=true
展示调试信息,包括矩形的边界和位置信息。如下所示,切换是否展示信息也就是切换 debugMode 的真假:
这里在 Player 中添加一个 toggleDebugMode 方法,切换 debugMode
值,并且在键盘 D :
---->[lib/trex/02/heroes/player.dart]----
void toggleDebugMode() {
debugMode = !debugMode;
}
三、文字的展示
界面呈现中处理图片之外,最重要的就是文字。Flame 中通过 TextComponent 展示文字,本节就来介绍一下文字的展现方式。
[1]. 使用文字 :通过文本展示小恐龙的状态信息以及提示信息。
[2]. 精灵字体 :通过 SpriteFont 展示分数的图片像素文字。
1.文字信息的展示
虽然现在呈现了小恐龙的状态变化,但是看起来并不是很清晰,如果界面上可以展示一些提示文字,就可以清晰地自动当前案例的作用。比如当前操作的按键作用以及小恐龙的状态信息:
flame 中一切的表现都是 Component
, 为了方便展示维护提示信息,可以像 Player 那样将其视为一个角色加入游戏场景中。 如下所示,定义 HelpText 继承自 PositionComponent
,让其拥有定位能力;在 onLoad 回调中加入两个 TextComponent 分别展示状态和提示文字。并提供 changeState 方法更新状态文字的内容:
class HelpText extends PositionComponent with HasGameReference<TrexGame> {
TextStyle stateStyle = const TextStyle(fontSize: 12, color: Colors.blue);
TextStyle infoStyle = const TextStyle(fontSize: 12, color: Colors.grey);
final String _info = '提示信息:\n'
'键盘a/点击: 切换恐龙状态\n'
'键盘 d: 切换展示边框信息' ;
String initState = '' ;
HelpText(this.initState);
double get centerY {
return (game.size.y / 2) - height / 2;
}
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
y = centerY;
x = 60;
}
late TextComponent _stateText;
void changeState(String state) {
_stateText.text = state;
}
@override
Future<void> onLoad() async {
_stateText = TextComponent(
text: initState,
position: Vector2(0,68),
textRenderer: TextPaint(style: stateStyle),
);
add( _stateText);
add(TextComponent(
position: position.translated(0, 68+20),
text: _info,
textRenderer: TextPaint(style: infoStyle),
));
}
}
然后在 TrexGame 中将 HelpText 像 Player 那样加入到场景中。当点击和按键事件时,通过 changeState 方法修改状态文字即可:
class TrexGame extends FlameGame with KeyboardEvents, TapCallbacks {
/// 略同...
late final HelpText helpText;
@override
Future<void> onLoad() async {
spriteImage = await Flame.images.load( 'trex/trex.png' );
add(player);
String initState = player.current.toString();
helpText = HelpText(initState);
add(helpText);
}
2. 精灵字体 SpriteFont
Flame 中提供了 SpriteFont 方便展示精灵图中的字体。在项目精灵图中,有数字和字母相关的图片作为分数。通过精灵字体,就可以将对应的字符串
映射为 精灵图片列表
展示:
比如下面的 1024 HI 2048
字符串,就可以访问到对应的精灵图片,展示文字:
同样,这里也定义一个 ScoreComponent
负责维护分数角色的展示, 在 onLoad 回调中创建并添加 TextComponent
。通过 SpriteFont
来建立字符集合图片区域的映射 Glyph
对象。这样对应的字符在渲染时就可以找到对应区域的图片精灵,完成展示:
class ScoreComponent extends PositionComponent with HasGameReference<TrexGame> {
late TextComponent _score;
@override
Future<void> onLoad() async {
const chars = '0123456789HI ';
final renderer = SpriteFontRenderer.fromFont(
SpriteFont(
source: game.spriteImage,
size: 23,
ascent: 23,
glyphs: [
for (var i = 0; i < chars.length; i++)
Glyph(chars[i], left: 954.0 + 20 * i, top: 0, width: 20),
],
),
letterSpacing: 2,
);
_score = TextComponent( textRenderer: renderer);
_score.text = '1024 HI 2048';
add(_score);
}
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
x = size.x - _score.width -20;
y = 20;
}
}
到这里已经人物登场啦,第一集的内容就介绍完毕了。下面整理了一下本集的知识。大家可以自己根据每一项思考一下具体内容:
下一章将继续推进,学习如何让画面动起来。