本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Flutter&Flame 游戏开发系列前言:
该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端
跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。
第一季:30 篇短文章,快速了解 Flame 基础。
[已完结]
第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。
两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。
一、游戏整体优化介绍
我们前面通过两篇内容,介绍了打砖块游戏界面的核心逻辑。一个完整的游戏,光有游戏界面是不够的。在核心玩法的基础上,需要其他场景、音效、特效、道具、数据持久化等方面丰富游戏内容。下面我们将逐步优化项目,本文将完成以下功能:
- 游戏多个菜单界面的跳转。
- 游戏暂停/继续。
- 游戏音效/背景音乐。
- 构建主界面和设置界面。
- 初步完善游戏整体逻辑。
1. 游戏主页面与游戏界面
目前游戏主界面如下所示,目前没有界面没有太多设计,目前以实现功能为主,界面的美化可以留在后期:
- 主页顶部展示两个游戏货币 绿水晶 和 金币,为后续功能做准备。
- 主页中间有四个按钮,跳转到不同的功能界面。
- 后期游戏打算引入关卡,每关有三次机会。
- 游戏界面顶部是当前关卡的信息,以及三个操作按钮:返回主页、暂停和设置
游戏主页 | 游戏界面 |
---|---|
2.操作按钮
一个游戏最基本的是这三个按钮,点击时弹出对应的对话框,对话框弹出的过程中,需要暂停游戏,对话框消失后恢复游戏。
- 系统设置: 需要支持背景音乐和点击音效开启/关闭。
- 返回主页:顶部左侧按钮返回主页,弹出对话框进行确认操作,避免误触。
- 暂停按钮:暂停游戏,可选择重新开始或继续游戏。
游戏设置 | 返回主页 |
---|---|
3.游戏的结束
一个关卡中游戏结束表现为成功和失败,通过对话框展示信息,及后续交互:
- 三次机会用完,游戏失败。
- 击碎所有砖块,关卡通关,得到一颗绿水晶。
游戏失败 | 游戏胜利 |
---|---|
二、主界面中浮层菜单的使用
现在相当游戏中有多个界面,我们都知道 Flutter 中的界面跳转本质上就是 Overlayer
浮层的插入和移除。Flame 中对浮层的操作进行了封装,可以自定义一个浮层界面的映射关系,通过 game 对象操作浮层的插入和移除。
1. 定义菜单浮层映射关系
GameWidget 本质上是一个 StatefulWidget,所以它可以视为一个普通的组件,放入到一个 Flutter 项目中。之前使用 GameWidget
时需要传入 FlameGame 的派生类 BricksGame 。
如果不想让组件持有游戏主类对象,可以通过 GameWidget<BricksGame>.controlled
构造,将 BricksGame 的创建交由 gameFactory 函数。这样组件类支持有一个函数,并将游戏主类的创建时机延迟到使用时。
dart
---->[lib/bricks/04/app.dart]----
class BricksGameApp extends StatelessWidget {
const BricksGameApp({super.key});
@override
Widget build(BuildContext context) {
return GameWidget<BricksGame>.controlled(
gameFactory: BricksGame.new,
overlayBuilderMap: {
'HomePage': (_, game) => HomePage(game: game),
'Settings': (_, game) => SettingsPage(game: game),
'ShopPage': (_, game) => ShopPage(game: game),
'LevelPage': (_, game) => LevelPage(game: game),
'PauseMenu': (_, game) => PauseMenu(game: game),
'ExitMenu': (_, game) => ExitMenu(game: game),
'GameOverMenu': (_, game) => GameOverMenu(game: game),
'GameSuccessMenu': (_, game) => GameSuccessMenu(game: game),
},
initialActiveOverlays: const ['HomePage'],
);
}
}
GameWidget 在构造时有两个浮层相关的参数:
overlayBuilderMap
映射对象,将每个界面浮层与字符串 key 对应。构建界面的回调中,有游戏主类的参数。initialActiveOverlays
表示初始时展示的浮层键列表。
这样在代码中就可以通过 game.overlays 根据 key 插入和移除浮层。这里开始展示 HomePage,点击开始按钮时,进入游戏界面,对于浮层来说,就是将 HomePage 对应的浮层移除:
dart
---->[点击回调时,移除 HomePage 浮层]----
game.overlays.remove('HomePage');
2. 主界面 HomePage 的构建
前面我们知道 Flame 游戏世界会不停地渲染,但目前主页这种相对静态的界面,和游戏过程无关。我们可以使用 Flutter 的 Widget 界面进行布局,通过暂停游戏世界,来避免不必要的游戏世界更新。 Flame 也为 Flutter 提供了一些 Widget 方便展示精灵图、按钮,也是接下来需要介绍的。
对于 HomePage 组件,我们希望感知其生命周期的变化,来控制游戏世界的运行情况。比如它展示时,可以暂停游戏;它销毁时可以取消暂停。这个需求下,HomePage 可以继承自 StatefulWidget, 通过 状态类
的回调来感知生命周期的变化。在 initState 回调中,将 game.paused 设为 true,暂停游戏世界。在 dispose 回调中置为 false , 游戏世界将继续渲染。
dart
---->[lib/bricks/04/overlays/home_page/home_page.dart]----
class HomePage extends StatefulWidget {
final BricksGame game;
const HomePage({super.key, required this.game});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
@override
void initState() {
super.initState();
widget.game.paused = true;
}
@override
void dispose() {
widget.game.paused = false;
super.dispose();
}
主页视图包括两个部分,标题和按钮组,如下所示。HomePage 的布局很简单,通过 Column 将 HomeTitle 和 HomeButtons 竖向排列即可:
标题 HomeTitle | 按钮 HomeButtons |
---|---|
dart
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xff263466),
body: Column(
children: [
HomeTitle(game: widget.game),
Expanded(flex: 4, child: HomeButtons(game: widget.game)),
const Spacer(flex: 1)
],
),
);
}
}
3. Flutter 布局中使用精灵图
HomeButtons 由四个按钮构成,Flame 中提供了 SpriteButton 展示精灵图按钮,如下所示,一个按钮需要准备两张精灵图,分别在按压和非按压时展示:
下面是 开始游戏 按钮的构建逻辑,SpriteButton 构造入参中:
- label 展示文字内容。
- onPressed 处理点击回调事件。
- sprite 和 pressedSprite 是按压和非按压时的图片精灵,通过 loader 加载。
- height 和 width 是按钮的宽高,必须传入。
dart
---->[lib/bricks/04/overlays/home_page/home_buttons.dart]----
const TextStyle style = TextStyle(color: Color(0xFFFFFFFF), fontWeight: FontWeight.bold);
double height = 36;
double width = 36 * 241 / 55;
SpriteButton(
onPressed: () {
game.overlays.remove('HomePage');
},
label: const Text('开始游戏', style: style),
sprite: game.loader['Btn_V15.png'],
pressedSprite: game.loader['Btn_V16.png'],
height: height,
width: width,
),
这里点击事件中移除 HomePage
浮层即可。另外其他的几个按钮处理类似,就不一一介绍了。
顶部的绿水晶和金币有动画效果,对于这种序列帧,Flame 提供了 SpriteAnimationWidget 组件进行展示。使用方式如下:
dart
SizedBox(
width: 20,
height: 20,
child: SpriteAnimationWidget.asset(
playing: true,
path: 'break_bricks/MonedaD.png',
data: SpriteAnimationData.sequenced(
amount: 5,
stepTime: 0.15,
textureSize: Vector2(16, 16),
),
),
),
SpriteAnimationWidget.asset
构造方法可以加载图片资源中的序列帧; SpriteAnimationData.sequenced
是动画精灵的数据,传入贴图的数量和每个贴图的尺寸大小,已经每帧间的事件间隔秒数 stepTime
:
三、菜单界面与播放声音
目前系统设置菜单只有两个设置项,主要介绍 Flame 中声音的播放。游戏中的声音包括两个方面: 背景音乐 和 游戏音效 。背景音乐会一直持续播放;游戏音效是一些短的声音,比如小球的撞击声、按钮的点击声、获得道具时的音效等。
1、使用 flame_audio 播放声音
flame_audio 是 Flame 官方为游戏声音播放封装的插件,低层依赖 audioplayers 实现,基于目前已经支持全平台的音频播放。音效也可以在一些开放游戏社区找到免费的可商用资源包。
由于各个平台的音频格式支持程度不同,建议使用 mp3 的格式的音频文件。将其放在 assets/audio
文件夹之下,这里 break_bricks
文件夹放置打砖块的音效; ui
文件夹放置交互时的音效。
声音的播放非常简单,对于背景音乐来说使用 FlameAudio.bgm.play 播放指定路径的音频文件;通过 FlameAudio.play 播放短的音效。这里通过有 AudioManager 类负责维护音频播放相关的功能,并定义 enableSoundEffect
和 enableBgMusic
来决定是否启用背景音乐:
dart
---->[lib/bricks/04/config/audio_manager/audio_manager.dart]----
class AudioManager {
bool enableSoundEffect = true;
bool enableBgMusic = true;
final String _bgMusicPath = 'break_bricks/background.mp3';
void startBgm() async{
FlameAudio.bgm.initialize();
FlameAudio.bgm.play(_bgMusicPath, volume: 0.8);
}
void play(SoundEffect type) {
if (!enableSoundEffect) {
return;
}
FlameAudio.play(type.path);
}
void toggleBgMusic() {
if (enableBgMusic) {
FlameAudio.bgm.stop();
enableBgMusic = false;
} else {
FlameAudio.bgm.play(_bgMusicPath, volume: 0.8);
enableBgMusic = true;
}
}
void toggleSoundEffect() {
enableSoundEffect = !enableSoundEffect;
}
}
这里将 AudioManager 作为 BricksGame 的成员,方便通过 game 对象访问:
ini
---->[lib/bricks/04/bricks_game.dart#BricksGame]----
AudioManager am = AudioManager();
2. 短音效的维护
游戏中会有很多短音效,如何维护它们是一个问题。短音效最重要的是其路径地址,在某个版本中,短音效的个数是一定的,可枚举的。再加上 Dart 中枚举已经支持添加属性,所以这里通过枚举维护音效是比较优雅的。如下所示:
dart
---->[lib/bricks/04/config/audio_manager/sound_effect.dart]----
enum SoundEffect {
uiClick('ui/click.mp3'),
uiOpen('ui/open.mp3'),
uiClose('ui/close.mp3'),
uiSelect('ui/select.mp3'),
ballBrick('break_bricks/tone.mp3'),
bitWall('break_bricks/hit2.mp3'),
;
final String path;
const SoundEffect(this.path);
}
播放短音效时,通过 game.am.play 方法,传入对应的 SoundEffect 枚举元素即可。如下所示,点击系统设置时,播放 SoundEffect.uiOpen 对应的音效:
dart
---->[lib/bricks/04/overlays/home_page/home_page.dart]----
game.am.play(SoundEffect.uiOpen);
3. 菜单弹框与九宫缩放
一般图片在拉伸时会发生形变,将其作为面板的背景,不能适应不同的尺寸:
Flame 中封装了 NineTileBoxWidget 组件,支持九宫模式的缩放,可以让面板图片,可以仅缩放设定的区域.其原理是,将图片等分为九块,在渲染时仅对中间区域(下面阴影部分) 进行缩放,四角区域的内容保持不变。
NineTileBoxWidget 组件需要指定 tileSize
表示每块网格的大小。这种方式使用起来有一定的局限性,如果能自定义上下左右的边距来控制伸展区域的大小,会更加灵活。
dart
SizedBox(
width: width,
height: height,
child: NineTileBoxWidget.asset(
path: 'break_bricks/panel.png',
tileSize: 33,
destTileSize: 100,
),
),
NineTileBoxWidget 组件本质上是通过 Flutter 的 Canvas#drawImageNine 方法绘制图片,所以完全可以自己写一个组件来更灵活地封装九宫模式的图片绘制。下面是我封装的 NineImageWidget 组件,可以传入 Rect 决定区域左上右下的边距 (边距值可以自己进行量取):
通过自定义的绘制,也可以实现更多的特性,比如可以提供透明度的参数,这样菜单面板下方就可以隐约看到下层的内容,视觉上更佳:
菜单界面的构建逻辑详见: lib/bricks/04/overlays/settings,这里就不赘述了。
不透明 | 增加透明度 |
---|---|
dart
---->[packages/flame_ext/lib/widget/nine_image_widget.dart]----
class NineImageWidget extends StatelessWidget {
final Rect expandZone;
final Widget? child;
final ui.Image image;
final double opacity;
final EdgeInsetsGeometry? padding;
const NineImageWidget({
super.key,
required this.expandZone,
required this.image,
this.child,
this.opacity = 1,
this.padding,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _NightImagePainter(image, expandZone, opacity),
child: padding == null ? child : Padding(padding: padding!, child: child),
);
}
}
final _emptyPaint = Paint();
class _NightImagePainter extends CustomPainter {
final ui.Image image;
final Rect expandZone;
final double opacity;
_NightImagePainter(this.image, this.expandZone, this.opacity);
@override
void paint(Canvas canvas, Size size) {
Rect rect = Rect.fromLTWH(expandZone.left, expandZone.top,
image.width - expandZone.right, image.height - expandZone.bottom);
_emptyPaint.color = Colors.white.withOpacity(opacity);
canvas.drawImageNine(image, rect, Offset.zero & size, _emptyPaint);
}
@override
bool shouldRepaint(covariant _NightImagePainter oldDelegate) {
return oldDelegate.expandZone != expandZone
|| oldDelegate.image != image||oldDelegate.opacity!=opacity;
}
}
四、游戏界面的优化
游戏界面增加了顶部栏 GameTopBar,展示游戏信息以及操作按钮。效果如下:
1. 游戏顶部栏 GameTopBar
GameTopBar 内部包括以下的构件,在 onLoad
时添加将他们到这些部件,并初始化它的位置信息:
- BrickWall : 顶部的固定墙体。
- XXXButton : 操作按钮。
- Life: 生命信息。
- Icon: 金币信息。
- LevelText: 关卡信息。
dart
---->[lib/bricks/04/heroes/game_top_bar/game_top_bar.dart]----
class GameTopBar extends PositionComponent with HasGameRef<BricksGame> {
final Coin coin = Coin();
final Life life = Life(3);
final HomeButton home = HomeButton();
final SettingButton setting = SettingButton();
final PauseButton pause = PauseButton();
final LevelText levelText = LevelText();
void updateLifeCount(int count){
removeWhere((component) => component is Life);
add(Life(count));
}
@override
FutureOr<void> onLoad() async {
size = Vector2(kViewPort.width, 320);
add(BrickWall());
add(levelText);
add(coin);
add(life);
add(home);
add(setting);
add(pause);
initPosition();
return super.onLoad();
}
void initPosition(){
final double iconSize = setting.width;
final double half = (64-iconSize)/2;
setting..x = width - iconSize - half..y = half;
pause..x = setting.x-64..y=setting.y;
home..x = half..y = half;
coin.x=64;
levelText..x = width / 2..y = height / 2;
}
}
2.顶部墙壁: BrickWall
碰撞的上边界将在顶部栏的底部,这里通过 BrickWall 构件,展示 64*64
的贴图,并承担碰撞检测的职能。这里贴图砖块的摆放和之前是类似的,通过行列来数遍历铺满。添加过程中也可以通过逻辑控制收集的坐标,比如这里让中间空缺:
dart
---->[lib/bricks/04/heroes/game_top_bar/brick_wall.dart]----
class BrickWall extends PositionComponent with HasGameRef<BricksGame> {
final int column;
final int row;
BrickWall({this.column = 9, this.row = 5});
@override
FutureOr<void> onLoad() {
addAll(_createBricks());
width = 64.0 * column;
height = 64.0 * row;
add(RectangleHitbox());
return super.onLoad();
}
List<SpriteComponent> _createBricks() {
Sprite sprite = game.loader['texture_metal1.png'];
List<SpriteComponent> bricks = [];
for (int i = 0; i < row; i++) {
for (int j = 0; j < column; j++) {
if ((i == 0 || i == row - 1) || (j == 0 || j == column - 1)) {
SpriteComponent brick = SpriteComponent(sprite: sprite);
brick.x = 64.0 * j;
brick.y = 64.0 * i;
bricks.add(brick);
}
}
}
return bricks;
}
}
3. 生命组件:Life 与游戏终止
打砖块游戏中,每关卡有三条生命。小球落到底部时,生命值减 1
, 生命为 0 时游戏结束。砖块全部打完时,关卡通关。Life 组件生命值通过两个图片,根据当前生命值进行展示,小于当前生命值时填满的爱心,否则是空的爱心:
dart
---->[lib/bricks/04/heroes/game_top_bar/life.dart]----
class Life extends PositionComponent with HasGameRef<BricksGame> {
final int lifeCount;
Life(this.lifeCount);
late Sprite life = game.loader['tile_0044.png'];
late Sprite lifeOutline = game.loader['tile_0046.png'];
@override
FutureOr<void> onLoad() async {
addAll(createLife());
position = Vector2(64, 64) + Vector2(8, 8);
return super.onLoad();
}
List<SpriteComponent> createLife() {
List<SpriteComponent> result = [];
for (int i = 0; i < 3; i++) {
SpriteComponent s1 = SpriteComponent(sprite: life)
..size = Vector2(36, 36);
SpriteComponent s2 = SpriteComponent(sprite: lifeOutline)
..size = Vector2(36, 36);
s1.x = 36.0 * i;
s2.x = 36.0 * i;
if (i < lifeCount) {
result.add(s1);
} else {
result.add(s2);
}
}
return result;
}
}
- 在 PlayWorld 中定义 died 方法,用于小球死亡触发时的逻辑。
- 在
GameTopBar
中定义 updateLifeCount 方法,移除旧的生命值,添加新的生命值。 - 修正 Ball 碰撞到底部时的逻辑处理,触发世界的 died 方法。
dart
---->[lib/bricks/04/bricks_game.dart#PlayWorld]----
int _life = 3;
void died() {
_life -= 1;
titleBar.updateLifeCount(_life);
game.status = GameStatus.ready;
if (_life == 0) {
gameOver();
}
}
---->[lib/bricks/04/heroes/game_top_bar/game_top_bar.dart]----
void updateLifeCount(int count){
removeWhere((component) => component is Life);
add(Life(count));
}
---->[lib/bricks/04/heroes/ball.dart]----
void _handleHitPlayground(Vector2 position, Vector2 areaSize) {
if(position.y >= areaSize.y-height){
game.world.died();
v = Vector2(0, 0);
return;
}
4. 暂停恢复与重新开始
游戏暂停和返回主页的面板和设置面板类似,都是通过 NineImageWidget 展示局部缩放的图片面板:
游戏暂停 | 返回主页 |
---|---|
游戏场景中,构件本身也可以混入 TapCallbacks ,通过复写 onTapDown 方法监听点击事件。下面是暂停按钮构件,继承自 SpriteComponent 展示图片资源。点击时弹出 PauseMenu
并将 game.paused
置为 true ,即可暂行游戏:
dart
class PauseButton extends SpriteComponent
with HasGameRef<BricksGame>, TapCallbacks {
@override
FutureOr<void> onLoad() {
sprite = game.loader['flatDark15.png'];
return super.onLoad();
}
@override
void onTapDown(TapDownEvent event) {
game.paused = true;
game.am.play(SoundEffect.uiClose);
game.overlays.add('PauseMenu');
}
}
游戏重新开始,可以将当前所有的已变动的构件数据重置,但这样操作起来比较麻烦。有种简单的方式,就是将游戏中的 world 重新设置,这样新的 PlayWorld 一切都会重置。
dart
---->[lib/bricks/04/bricks_game.dart#BricksGame]----
void restart() {
world = PlayWorld();
status = GameStatus.ready;
}
到这里,打砖块的基本游戏流程就已经完善了,玩家可以通过操作来暂停、重新开始。游戏也有胜利和失败的结果。接下来将继续优化打砖块游戏,设计多个关卡,支持选关操作,丰富玩法。