

Flutter 游戏开发(flame) 04 分数, 存档和音效(4/5)
source link: https://www.bugcatt.com/archives/564
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

分数和高分记录是任何游戏中不可或缺的一部分. 有些游戏根据收集的金币数量计算得分, 有些基于杀敌数, 有些则基于存活时间.
同样不能忽视音效和BGM(背景音乐). 加上它们, 游戏将会蜕变.
如果一个游戏无声, 它就是不完整的.
欢迎来到Flutter+Flame系列的第四章. 如果你还没有读过之前的章节, 建议你先阅读一下哦!
注意: 本教程的背景音乐有些过时. 你可以先学本章教程, 之后再替换为这篇教程
下面是我们本系列教程的文章目录:
需具备的条件
- 本系列教程之前的全部要求
- 更多的资源包 – 本教程提供了资源包, 但你也可以使用自己的. 推荐资源网站Open Game Art.
- 音效和音乐资源 – 这些也可以在游戏资源网站上找到, 比如Open Game Art. 还有专门的音频资源比如BenSound.com. 同样, 你必须查看许可并在游戏中表示感谢.
我们将使用与前一部分相同的编码规范
新的资源包
这一部分中, 我们需要另一个资源包, 包含额外的图形、背景音乐和一些音效.
CC-BY-NC-ND
许可证的许可.这意味着您可以共享、复制或者重新分发资源.
- 你必须在感谢中提到, 提供许可证的链接, 并标明你是否进行了更改.
- 你不得将资源用于商业目的.
- 如果混合, 转换或者构建资源, 则不能分发修改后的资源.
- 你不得应用法律条款或技术措施, 在法律上限制他人做许可证允许的任何事情.
在这部分中, 我们将专注于分数和音效.
我们将使用另一个Flutter库来记录, 保存玩家的最高分.
至于音效, 我们将使用Flame中的音频库.
第一步: 评分
目前为止, 游戏的目标只是不停的击杀小飞蝇, 直到玩家打偏. 除此之外, 没有目标.
我们来添加一个目标. 在玩家击中小飞蝇时得一分, 并累计到当前分数中. 与其他的游戏一样, 分数将从零开始, 并在新游戏时重置.
新建用于记录分数的实例变量
首先来到./lib/langaw-game.dart
, 添加实例变量:
int score;
小提示: 只需要添加到其他实例变量的下方.
在initialize()
中, 初始化score
的值:
score = 0;
我们需要玩家每次点击开始游戏按钮时, 重置此值.
跳转至./lib/components/start-button.dart
, 在onTapDown
处理器中添加:
game.score = 0;
到目前为止还是蛮顺利的! 接下来我们把分数显示出来.
我们可以选择在game类中进行渲染. 但是由于展示的东西比较少, 我们将该逻辑封装在自己的component.
创建一个组件./lib/components/score-display.dart
:
import 'dart:ui';
import 'package:flutter/painting.dart';
import 'package:langaw/langaw-game.dart';
class ScoreDisplay {
final LangawGame game;
ScoreDisplay(this.game) {}
void render(Canvas c) {}
void update(double t) {}
}
代码解析: 从
import
开始,dart:ui
使我们可以访问Canvas
和Offset
;package:flutter/painting
使我们可以访问TextPainter
;package:langaw/langaw-game.dart
使我们可以访问game类.
提示: 我们已经有了一个名为
game
的实例变量, 在创建此类的实例时必须传入此变量. 与我们在签名的component中定义的其他component、controller和view一致.
让我们再添加三个实例变量: TextPainter
类型的painter
, 用于渲染分数值; textStyle
控制分数样式; position
决定分数的Offset
(偏移量):
TextPainter painter;
TextStyle textStyle;
Offset position;
然后我们来处理构造函数, 在其中初始化实例变量值:
ScoreDisplay(this.game) {
painter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
textStyle = TextStyle(
color: Color(0xffffffff),
fontSize: 90,
shadows: [
Shadow(
blurRadius: 7,
color: Color(0xff000000),
offset: Offset(3, 3),
),
],
);
position = Offset.zero;
}
代码解析: 首先, 使用创建一个新的
TextPainter
用于初始化painter
. 将其textAlign
属性设置为居中, 因为我们将在屏幕上水平居中显示得分. 由于游戏是英文的(书写方向为从左至右), 我们将textDirection
属性设置为LTR(Left-to-Right).接下来, 使用
TextStyle
初始化textStyle
属性. 设置三个属性:color
设置为Color(0xffffffff)
(纯白色); 字体大小为90 逻辑像素
; 并且将shadows
属性设置为包含一项的类型为Shadow
的List
. 此项定义一个阴影, 该阴影在右侧和底部偏移3个逻辑像素. 如果分数在另一个白色对象的顶部渲染(比如白云), 通过阴影可以增加对比度, 更容易看清.想了解有关逻辑像素的更多信息, 请查看Flutter官方文档(英文)
update
函数实际上发生在渲染之前(无论是初始化还是在game loop中), 所以让我们首先来写它:
void update(double t) {
if ((painter.text?.text ?? '') != game.score.toString()) {
painter.text = TextSpan(
text: game.score.toString(),
style: textStyle,
);
painter.layout();
position = Offset(
(game.screenSize.width / 2) - (painter.width / 2),
(game.screenSize.height * .25) - (painter.height / 2),
);
}
}
代码解析: 为了避免不必要的文本布局重新计算, 若变量
painter
的(文本属性的)text
属性与当前分数的字符串相等, 就不会发生任何事情.
通过以下判断完成:
(painter.text?.text ?? '') != game.score.toString()
这个表达式左侧看起来比较复杂, 所以在此解释一下. 此表达式使用Dart的null运算符.?.
用于检查其左边的变量是否为null
, 若为null
则立即停止表达式并返回null
. 我们已经知道Painter
已经初始化且不为null
, 因此我们不用检查它. 我们不确定的是painter
的text
属性是否为null
, 因此我们使用该运算符.
使用的另一个运算符是??.
. 若左侧不为null, 返回左侧表达式; 若为null, 返回右侧表达式.
对于整个表达式, 如果未设置painter
的text
属性, 则整个painter.text?.text
返回null
. 由于其后跟随了一个??
, 因此if的返回值是一个空字符串. 最终的值是与game.score.toString()
进行比较的结果. 另一方面, 如果没有设置painter
的text
属性, 则会返回当前的实际分数.
你可以在这篇《Dart 中的 Null-aware (null感知运算符)》获取Null-aware运算符(Null感知运算符)的更多信息!
现在如果painter
的text与当前分数不一致, 我们用一个新的TextSpan
的实例来更新它的text属性, 该实例将获取游戏中score
变量和可重复使用的textStyle
变量的当前值. 然后调用layout
函数, 以便TextPainter
可以计算刚刚分配的新文本的尺寸.
然后, 我们用一个新的Offset
实例并将其分配给position
变量. 我们希望分数水平居中. 至于垂直方位, 我们将score的垂直中心放在距屏幕顶部约1/4的高度上.
若有其他问题, 欢迎加入我的Flame交流群(QQ).
最终在render
函数中, 添加下面的代码块:
void render(Canvas c) {
painter.paint(c, position);
}
代码解析: 通过调用
painter
的paint()
并提供所需的参数来渲染分数: 一个可以用于绘制的画布并且用Offset
告诉painter在哪里绘制分数.
整个的./lib/components/score-display.dart
文件应该像这样:
import 'dart:ui';
import 'package:flutter/painting.dart';
import 'package:langaw/langaw-game.dart';
class ScoreDisplay {
final LangawGame game;
TextPainter painter;
TextStyle textStyle;
Offset position;
ScoreDisplay(this.game) {
painter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
textStyle = TextStyle(
color: Color(0xffffffff),
fontSize: 90,
shadows: [
Shadow(
blurRadius: 7,
color: Color(0xff000000),
offset: Offset(3, 3),
),
],
);
position = Offset.zero;
}
void render(Canvas c) {
painter.paint(c, position);
}
void update(double t) {
if ((painter.text?.text ?? '') != game.score.toString()) {
painter.text = TextSpan(
text: game.score.toString(),
style: textStyle,
);
painter.layout();
position = Offset(
(game.screenSize.width / 2) - (painter.width / 2),
(game.screenSize.height * .25) - (painter.height / 2),
);
}
}
}
绘制分数component
若要真正的把分数component渲染在屏幕上, 我们必须将其添加至game类, 并将其包含在game loop中.
老规矩, 打开./lib/langaw-game.dart
, 导入刚刚创建的类:
import 'package:langaw/components/score-display.dart';
创建ScoreDisplay
类型的实例变量:
ScoreDisplay scoreDisplay;
在initialize()
中(调用resize
之后)创建一个新的ScoreDisplay
实例, 并将其赋值给scoreDisplay
变量(注意大小写哦), 最好在初始化按钮的下方:
scoreDisplay = ScoreDisplay(this);
在update()
内部, 判断当前的界面是否为View.playing
, 若是, 则调用scoreDisplay
的update
函数. 你可以将此行放在任何地方.
在函数末尾添加:
if (activeView == View.playing) scoreDisplay.update(t);
然后在game类的render()
中, 我们做类似的事情, 但是是调用render()
. 记住在此函数内写入行的顺序是图形在屏幕上绘制的实际顺序.
我们希望score正好高于背景, 低于其他的所有东西. 小飞蝇可以飞越它. 因此, 在渲染背景后添加:
if (activeView == View.playing) scoreDisplay.render(canvas);

处理小飞蝇加分
玩家想要加分, 必须击落小飞蝇.
要拥有这套逻辑, 打开./lib/components/fly.dart
. 为了等下使用, 导入View
枚举类.
import 'package:langaw/view.dart';
在onTapDown
处理器内部, 替换已存在的那一行代码为:
if (!isDead) {
isDead = true;
if (game.activeView == View.playing) {
game.score += 1;
}
}
代码解析: 当一只小飞蝇被点击, 我们首先要检查它是否活着(
!isDead
). 如果小飞蝇已经挂掉, 那么什么也不会发生. 如果还活着, 我们设置其isDead
参数值为true
让component知道小飞蝇已经死掉.之后, 我们将判断当前界面是否为playing. 如果玩家没有开始游戏, 那么就不需要新增分数. 若玩家在游戏中, 则增加分数. 这将更新
ScoreDisplay
实例.
运行游戏, 你应该可以看到分数了:

进阶版小飞蝇
小飞蝇不仅仅是飞来飞去, 它们同时也会进食. 另外, 若玩家只是伺机而动精准击落小飞蝇, 多少有些无聊.
让我们再来添加一个游戏失败的条件.
一旦小飞蝇出现在屏幕上, 它会显示一个倒数的计数器. 这个计数器相当于小飞蝇还有多久”吃完”. 当计数器归零时, 意味着小飞蝇”吃完”, 游戏失败.
这样游戏就增加了挑战性, 没有之前那么无聊了.
我们必须确保玩家可以注意到计数器, 看看那些小飞蝇即将归零. 我们将使用资源包中的标注图形来显示此计数器.
让我们添加资源包中的标注图形./assets/images/ui/callout.png
到资源目录中, 然后要把它注册进./pubspec.yaml
中:
- assets/images/ui/callout.png
然后打开./lib/main.dart
并添加ui/callout.png
到预加载的图像List中. 在Flame.images.loadAll
中:
Flame.images.loadAll([
// 其他的图片资源
'ui/callout.png',
]);
然后, 创建为此标注创建一个component, ./lib/components/callout.dart
:
import 'dart:ui';
import 'package:flame/sprite.dart';
import 'package:flutter/material.dart';
import 'package:langaw/components/fly.dart';
class Callout {
final Fly fly;
Rect rect;
Sprite sprite;
double value;
TextPainter tp;
TextStyle textStyle;
Offset textOffset;
Callout(this.fly) {}
void render(Canvas c) {}
void update(double t) {}
}
代码解析: 这里我们又创建了一个相当标准的component. 只不过引入的不是
game
而是fly. 就像fly component的子类一样.
此类具有其他实例变量, 这些实例变量将用于绘制标注中的值.
继续初始化构造函数的值:
Callout(this.fly) {
sprite = Sprite('ui/callout.png');
value = 1;
tp = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
textStyle = TextStyle(
color: Color(0xff000000),
fontSize: 15,
);
}
代码解析: 别头大, body实际上只有4行. 为了便于阅读才把它垂直伸展开. 在构造函数内部, 我们只为实例变量分配初始化值. 在
update
函数中, 我们将变量减少一定的值, 若降至0, 则将游戏中的当前界面改为you lose
. 当然, 上述操作仅在界面为playing
时才进行.
import 'package:langaw/view.dart';
再将以下代码块放入update
函数中:
if (fly.game.activeView == View.playing) {
value = value - .5 * t;
if (value <= 0) {
fly.game.activeView = View.lost;
}
}
代码解析: 首先, 检查界面是否为
View.playing
, 若是则从值中减去0.5 * t
.t
变量包含上次调用update
时间的1/2. 此计算可确保小飞蝇的寿命为2秒.然后我们判断该值是否为0. 若是则告诉玩家输掉游戏.
之后我们确保此标注的rect
变量已更新, 以便render()
相对于父级fly
正确放置. 该代码块刚好在减少value
块下方:
rect = Rect.fromLTWH(
fly.flyRect.left - (fly.game.tileSize * .25),
fly.flyRect.top - (fly.game.tileSize * .5),
fly.game.tileSize * .75,
fly.game.tileSize * .75,
);
代码解析: 到目前为止, 与我们完成的所有其他
Rect
的初始化一致, 实际上只有一行代码. 最后两个参数是rect
的宽度和高度, 它们都设置为区块大小的3/4. Left的值与小飞蝇的rect
减去区块大小的1/4相同. Top的值使用相同的逻辑, 不同的是减去区块大小的1/2.
仍然在update
函数中, 最后的代码块将更新文本painter, 该painter在标注图形中绘制当前的值.
tp.text = TextSpan(
text: (value * 10).toInt().toString(),
style: textStyle,
);
tp.layout();
textOffset = Offset(
rect.center.dx - (tp.width / 2),
rect.top + (rect.height * .4) - (tp.height / 2),
);
代码解析: 由于我们已经使用
TextPainter
类的实例初始化了tp
变量, 因此我们只是将其text
属性设置为TextSpan
类的实例, 将当前值乘以10转换为整数, 然后转换为字符串.
该值乘以10, 就好像从9数到0一行.
然后我们调用layout
函数, 以便tp
知道将给文本字符串和提供给它的样式以及文本大小.
接下来, 我们使用新的Offset
来更新textOffset
的值, 该Offset
会传递一个计算, 将文本居中在标注的白色区域内.
最后编写render()
:
void render(Canvas c) {
if (rect != null){
sprite.renderRect(c, rect);
tp.paint(c, textOffset);
}
}
代码解析: 首先我们渲染标注(现在应该比较熟悉了). 然后我们使用
TextPainter
的paint
函数来绘制文本, 并传递我们刚刚在update
函数中更新的textOffset
变量.
整个的Callout类:
import 'dart:ui';
import 'package:flame/sprite.dart';
import 'package:flutter/material.dart';
import 'package:langaw/components/fly.dart';
import 'package:langaw/view.dart';
class Callout {
final Fly fly;
Rect rect;
Sprite sprite;
double value;
TextPainter tp;
TextStyle textStyle;
Offset textOffset;
Callout(this.fly) {
sprite = Sprite('ui/callout.png');
value = 1;
tp = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
textStyle = TextStyle(
color: Color(0xff000000),
fontSize: 15,
);
}
void render(Canvas c) {
if (rect != null){
sprite.renderRect(c, rect);
tp.paint(c, textOffset);
}
}
void update(double t) {
if (fly.game.activeView == View.playing) {
value = value - .5 * t;
if (value <= 0) {
fly.game.activeView = View.lost;
}
}
rect = Rect.fromLTWH(
fly.flyRect.left - (fly.game.tileSize * .25),
fly.flyRect.top - (fly.game.tileSize * .5),
fly.game.tileSize * .75,
fly.game.tileSize * .75,
);
tp.text = TextSpan(
text: (value * 10).toInt().toString(),
style: textStyle,
);
tp.layout();
textOffset = Offset(
rect.center.dx - (tp.width / 2),
rect.top + (rect.height * .4) - (tp.height / 2),
);
}
}
现在我们只需将标注component添加至
Fly
类中. 此过程应该也比较熟悉了, 因为我们在前面做过类似的操作. 打开./lib/components/fly.dart
, 导入Callout
:
import 'package:langaw/components/callout.dart';
添加实例变量用于保存callout:
Callout callout;
在Fly
构造函数中, 初始化callout
变量并且使用this
关键字用于传入当前的Fly:
callout = Callout(this);
如果小飞蝇没有死掉, 那么标注就需要进行更新. 因此在Fly
的update
函数内, 在else
(小飞蝇尚未死掉)代码块底部添加:
callout.update(t);
最终, 若小飞蝇还没死(else
块内)并且当前的界面为playing
, 则渲染标注. 添加到render
中:
if (game.activeView == View.playing) {
callout.render(c);
}
运行游戏, 看看效果:

第二步: 存储最高分
如果游戏没有记录最高分, 那么获得这个宝贵的分数将是一种浪费.
让我们来记录最高分, 这样玩家就可以不断突破自己, 取得更高的分数.
记录最高分, 仅需存储一条数据. 只是一个整数.
这个简单的任务可以通过shared_preferences
第三方库来解决. 这个库包含SharedPreferences
, 它可以处理简单的数据(数字、字符串以及布尔值). 它还在内部处理根据不同操作系统(IOS和Android)来保存数据.
若想了解
SharedPreference
更多内容, 欢迎阅读阿航的这篇《Flutter 数据存储 SharedPreferences》. 里面包含超详细讲解.
准备数据存储
就像Flame一样, shared_preferences
也是Flutter的一个插件. 若要安装此插件, 打开./pubspec.yaml
并且添加以下行在dependencies
中, flame
下方:
shared_preferences: ^0.5.1+2
提示: 注意缩进! 若格式有误将会出现错误哦!
运行packages get
.
为了更轻松的读写数据, 我们需要在game
的context中创建SharedPreference
实例变量.
打开./lib/langaw-game.dart
, 导入shared_preferences
:
import 'package:shared_preferences/shared_preferences.dart';
然后在LangawGame
类中添加final
实例变量. 这样甚至会在创建LangawGame
实例前就准备好SharedPreference
实例:
final SharedPreferences storage;
标记为final
的任何实例变量必须在声明时具有初始值, 否则必须通过构造函数将其传递给它. 因此我们来修改构造函数:
LangawGame(this.storage) {
initialize();
}
在构造函数中, 使用以this.
为前缀的参数时, 意味着传递给它的任何值都是其名称所代表的变量值.
在其之后, 跳转至./lib/main.dart
文件, 以便我们可以正确初始化game类.
首先导入shared_preferences
:
import 'package:shared_preferences/shared_preferences.dart';
接下来在main
函数中创建SharedPreferences
的实例:
SharedPreferences storage = await SharedPreferences.getInstance();
提示:
.getInstance
工厂返回一个Future
, 因此我们必须使用await
关键字来等待Futrue
返回内容(需要SharedPreferences
的一个实例).main
函数已经为async
, 因此我们可以等待main
中的Future
.
在声明LangawGame
实例的代码部分, 传递我们刚刚声明的第一个(也是唯一一个)参数的storage
变量:
LangawGame game = LangawGame(storage);
现在只要我们可以访问game, 就可以访问storage
变量(LangawGame类的实例).
显示最高分
我们的游戏应该始终显示最高分. 我们可以使用类似ScoreDisplay
的另一个component来执行此操作.
创建./lib/components/highscore-display.dart
文件, 并编写内容:
import 'dart:ui';
import 'package:flutter/painting.dart';
import 'package:langaw/langaw-game.dart';
class HighscoreDisplay {
final LangawGame game;
TextPainter painter;
TextStyle textStyle;
Offset position;
HighscoreDisplay(this.game) {}
void render(Canvas c) {}
}
代码解析: 如你所见, 这是一个相当标准的不含
update
的类文件. 因为分数将会被手动更新.
实例变量必须在创建此类实例时被初始化, 因此修改构造函数为:
HighscoreDisplay(this.game) {
painter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
Shadow shadow = Shadow(
blurRadius: 3,
color: Color(0xff000000),
offset: Offset.zero,
);
textStyle = TextStyle(
color: Color(0xffffffff),
fontSize: 30,
shadows: [shadow, shadow, shadow, shadow],
);
position = Offset.zero;
updateHighscore();
}
构造函数解析: 第一部分使用
TextPainter
的类实例初始化painter
变量, 该类包含文本对齐和方向所需的值.接下来我们将创建一个内部的
Shadow
变量, 将其添加到下面初始化的textStyle
时, 将有助于创建阴影效果. 我们放置了shadow
变量的四个实例, 因此当它们重叠时, 它们包含阴影.
position
变量设置初始化为零(0, 0)
.最后, 我们调用一个名为
updateHighscore()
的函数. 这将自动更新高分值以及对painter对象绘制的文本进行处理.
接下来添加函数, 用于构建手动更新:
void updateHighscore() {
int highscore = game.storage.getInt('highscore') ?? 0;
painter.text = TextSpan(
text: 'High-score: ' + highscore.toString(),
style: textStyle,
);
painter.layout();
position = Offset(
game.screenSize.width - (game.tileSize * .25) - painter.width,
game.tileSize * .25,
);
}
代码解析: 在这个函数中, 我们从保存在game类的
storage
变量中的SharedPreference
实例中获得最高分的值. 由于我们的分数(和最高分)只是整数, 所以我们将其存储为integer
.然后我们更新
painter
的text
属性为一个新的TextSpan
, 并传入刚刚的最高分. 这和ScoreDisplay
中的更新过程相似.调用
layout
(决定绘制文本大小)后, 我们将position
变量设置为新的Offset
, 其值将使绘制文本的右侧位于屏幕右侧边缘约1/4处, 而其顶部位于屏幕的右边缘. 与屏幕顶部边缘的距离相同.
我们通过编写render
函数的内容来完成该类:
void render(Canvas c) {
painter.paint(c, position);
}
没有复杂的点, 只需在updateHighScore
函数中预先计算的位置将最高分绘制到屏幕上即可.
整个HighscoreDisplay
类文件:
import 'dart:ui';
import 'package:flutter/painting.dart';
import 'package:langaw/langaw-game.dart';
class HighscoreDisplay {
final LangawGame game;
TextPainter painter;
TextStyle textStyle;
Offset position;
HighscoreDisplay(this.game) {
painter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
Shadow shadow = Shadow(
blurRadius: 3,
color: Color(0xff000000),
offset: Offset.zero,
);
textStyle = TextStyle(
color: Color(0xffffffff),
fontSize: 30,
shadows: [shadow, shadow, shadow, shadow],
);
position = Offset.zero;
updateHighscore();
}
void updateHighscore() {
int highscore = game.storage.getInt('highscore') ?? 0;
painter.text = TextSpan(
text: 'High-score: ' + highscore.toString(),
style: textStyle,
);
painter.layout();
position = Offset(
game.screenSize.width - (game.tileSize * .25) - painter.width,
game.tileSize * .25,
);
}
void render(Canvas c) {
painter.paint(c, position);
}
}
现在就剩把它添加至game类中了, 打开./lib/langaw-game.dart
, 导入HighscoreDisplay
:
import 'package:langaw/components/highscore-display.dart';
添加实例变量, 类型为HighscoreDisplay
:
HighscoreDisplay highscoreDisplay;
在initialize
函数内, 初始化highscoreDisplay
(最好在scoreDisplay
下面):
highscoreDisplay = HighscoreDisplay(this);
最后在render
内, 渲染背景后(渲染scoreDisplay
前), 使用以下代码渲染最高分:
highscoreDisplay.render(canvas);
尝试运行游戏, 应该可以在屏幕右上角看到分数显示(目前为0):

更新最高分
目前的最高分是暂时无效的. 只要满足以下条件, 就需要更新它:
- 玩家当前界面是"playing"
- 当前分数高于最高分
为此我们打开./lib/components/fly.dart
. 在onTapDown
处理器中, 我已经有了一个if
块, 用于判断当前界面是否为"playing".
在该块内, 我们添加1
分下面, 插入以下代码:
if (game.score > (game.storage.getInt('highscore') ?? 0)) {
game.storage.setInt('highscore', game.score);
game.highscoreDisplay.updateHighscore();
}
代码解析: 此块仅检查分数是否高于当前保存为最高分的分数. 因为经过
if
的判断, 我们已经知道玩家正在"playing".如果满足条件, 则首先调用
setInt
函数, 传递字符串highscore
和新值. 此函数和getInt
类似, 但是它传递的是引用, 而不是它的值.之后, 我们手动更新
HighscoreDisplay
实例, 以向玩家显示他的分数是当前最高分.
代码截图:

现在运行游戏, 你应该会看到: 每次超过当前最高分, 都会更新最高分. 开启下一局游戏时, 该最高分会被保留. 这样会不断使玩家挑战自己, 打破记录.

第三步: 音效
提示: Mac和IOS设备在播放
.ogg
文件时可能出现问题. 如果您遇到类似的播放异常, 请用你喜欢的音频转换工具将.ogg
文件转换为.mp3
文件. 另外也请注意要把对应的.ogg
后缀改为.mp3
.
一个游戏没有音乐将会相当单调. 幸运的是, Flame可以很轻松的为游戏添加声音!
首先创建一个目录, 用于存放音效文件. 创建目录./assets/audio/sfx
, 并把资源包中./audio/sfx
目录下全部文件放在创建的目录中:
./assets
./assets/audio
./assets/audio/sfx
./assets/audio/sfx/haha1.ogg
./assets/audio/sfx/haha2.ogg
./assets/audio/sfx/haha3.ogg
./assets/audio/sfx/haha4.ogg
./assets/audio/sfx/haha5.ogg
./assets/audio/sfx/ouch1.ogg
./assets/audio/sfx/ouch2.ogg
./assets/audio/sfx/ouch3.ogg
./assets/audio/sfx/ouch4.ogg
./assets/audio/sfx/ouch5.ogg
./assets/audio/sfx/ouch6.ogg
./assets/audio/sfx/ouch7.ogg
./assets/audio/sfx/ouch8.ogg
./assets/audio/sfx/ouch9.ogg
./assets/audio/sfx/ouch10.ogg
./assets/audio/sfx/ouch11.ogg
下一步将它们注册进入Flutter中, 编译时将它们打包进去, 在./pubspec.yaml
的assets
中添加:
assets:
- assets/audio/sfx/haha1.ogg
- assets/audio/sfx/haha2.ogg
- assets/audio/sfx/haha3.ogg
- assets/audio/sfx/haha4.ogg
- assets/audio/sfx/haha5.ogg
- assets/audio/sfx/ouch1.ogg
- assets/audio/sfx/ouch2.ogg
- assets/audio/sfx/ouch3.ogg
- assets/audio/sfx/ouch4.ogg
- assets/audio/sfx/ouch5.ogg
- assets/audio/sfx/ouch6.ogg
- assets/audio/sfx/ouch7.ogg
- assets/audio/sfx/ouch8.ogg
- assets/audio/sfx/ouch9.ogg
- assets/audio/sfx/ouch10.ogg
- assets/audio/sfx/ouch11.ogg
同样的, 一定要注意缩进.
然后进入./lib/main.dart
, 在main
函数中添加以下代码行:
Flame.audio.disableLog();
Flame.audio.loadAll([
'sfx/haha1.ogg',
'sfx/haha2.ogg',
'sfx/haha3.ogg',
'sfx/haha4.ogg',
'sfx/haha5.ogg',
'sfx/ouch1.ogg',
'sfx/ouch2.ogg',
'sfx/ouch3.ogg',
'sfx/ouch4.ogg',
'sfx/ouch5.ogg',
'sfx/ouch6.ogg',
'sfx/ouch7.ogg',
'sfx/ouch8.ogg',
'sfx/ouch9.ogg',
'sfx/ouch10.ogg',
'sfx/ouch11.ogg',
]);
代码解析: 第一行禁用了debug日志, 以免在控制台中刷屏.
接下来的几行实际上只有一行, 一个函数调用的参数为数组. 为了便于阅读, 数组被垂直展开. 这将预加载所有音效文件, 以便将它们缓存并随时可被游戏播放.
你可能注意到了, 和预加载图片的格式是一致的.
下一步是在适当的位置播放声音.
我们基本上有两种声音效果. 一种是"ouch"(类似于惨叫), 另一种是"haha"(哈哈笑). 当玩家击落小飞蝇时, 会播放"ouch", 玩家打偏的时候会播放"haha".
你可能想知道为什么有整整11个"haha"和5个"ouch".
在这里分享一个游戏开发秘诀.
任何重复都是无聊的. 比如一个笑话讲了十次就不再是笑话了. 生活中大多数事情都是重复的(呼吸、 日子以及game loop), 但是我们可以通过在播放时使用多个版本的声音效果, 是游戏的声音添加一些趣味. 如果我们每次击杀小飞蝇时都播放相同的声音, 它可能会很快被玩家玩腻.
为此, 每次我们需要播放声音时, 就会从game
的rnd
中获取一个随机数, 并播放相应的"相同"的随机一种音效.
打开./lib/components/fly.dart
, 导入Flame:
import 'package:flame/flame.dart';
然后在onTapDown
处理器后, 在判断被点击的小飞蝇没有死的后面添加:
Flame.audio.play('sfx/ouch' + (game.rnd.nextInt(11) + 1).toString() + '.ogg');
代码解析: 若要调用Flame音频库的
play
函数, 只需要传入音频文件名.详细介绍一下随机生成器:
(game.rnd.nextInt(11) + 1)
. 函数nextInt()
接收一个整数作为参数, 并返回一个从0到所传参数(不包含)的随机值的整数. 因此若传递11, 则可以得到0
到10
之间的任意数字. 然后将其加1, 使返回的数字变为从1
到11
, 这样刚好匹配我们的文件名(ouch1.ogg
,ouch2.ogg
, …,ouch11.ogg
).
运行游戏, 你会发现击落小飞蝇后, 小飞蝇会发出声音.
打开./lib/langaw-game.dart
, 修改if
块的代码, 该代码判断玩家是否"正在玩游戏"并且点击但未击中小飞蝇.
找到:
if (activeView == View.playing && !didHitAFly) {
activeView = View.lost;
}
if (activeView == View.playing && !didHitAFly) {
Flame.audio.play('sfx/haha' + (rnd.nextInt(5) + 1).toString() + '.ogg');
activeView = View.lost;
}
代码解析: 我们同样调用了
play
函数, 但是播放的是5个haha
的变体.
我们还有另一种失败条件, 所以打开./lib/components/callout.dart
, 导入:
import 'package:flame/flame.dart';
在update()
内部, 修改if
块, 该块判断value
是否小于等于0:
if (value <= 0) {
fly.game.activeView = View.lost;
}
if (value <= 0) {
Flame.audio.play('sfx/haha' + (fly.game.rnd.nextInt(5) + 1).toString() + '.ogg');
fly.game.activeView = View.lost;
}
代码解析: 与上面的代码基本相同, 只是访问game的
rnd
对象的方式不同. 在此类中, 我们没有直接引用game实例. 我们先通过fly
引用, 然后再通过fly
的game
进行引用, 然后再转到rnd
对象.
尝试运行游戏, 看看效果!
第四步: 背景音乐
是时候添加BGM(背景音乐)了!
背景音乐决定了游戏的气氛或者玩家当前的界面. 对于此游戏, 我们将会有两种不同的背景音乐, 一种用于play界面, 另一种用于其他.
首先, 将资源包的./audio/bgm
内的文件复制到游戏目录中的./lib/assets/audio/bgm
中, 结构如下:
./assets
./assets/audio
./assets/audio/bgm
./assets/audio/bgm/home.mp3
./assets/audio/bgm/playing.mp3
这些文件也同样要绑定至包中, 所以把它们加在./pubspec.yaml
资源中:
assets:
- assets/audio/bgm/home.mp3
- assets/audio/bgm/playing.mp3
- assets/audio/sfx/haha1.ogg
BGM将会被循环播放, 因此我们使它们预加载. 打开./lib/main.dart
, 并将BGM文件包含在传入Flame.audio.loadAll
的数组中:
Flame.audio.loadAll([
'bgm/home.mp3',
'bgm/playing.mp3',
'sfx/haha1.ogg',
使用SFX(音效), 我们可以只播放他们就放手不管, 因为他们短且只有一次. 对于BGM, 我们需要可以对它们进行控制, 比如暂停、继续播放和搜索.
我们将存储这些引用, 保留在game类的变量, 因此我们打开./lib/langaw-game.dart
.
首先, 我们需要访问AudioPlayer
, 因此导入:
import 'package:audioplayers/audioplayers.dart';
下一步, 我们需要实例变量, 该变量将保存每个BGM对音频播放器的引用:
AudioPlayer homeBGM;
AudioPlayer playingBGM;
在initialize()
中的末尾, 我们添加以下代码块初始化这些变量, 暂停播放并播放主页BGM:
homeBGM = await Flame.audio.loop('bgm/home.mp3', volume: .25);
homeBGM.pause();
playingBGM = await Flame.audio.loop('bgm/playing.mp3', volume: .25);
playingBGM.pause();
playHomeBGM();
代码解析: 第一行获取一个
AudioPlayer
的实例, 该实例加载了传递的文件名. 我们使用loop
函数, 因此它将立即开始播放BGM. 我们通过下面的homeBGM.pause()
来取消该效果.你可能会注意到我们将音量设置为0.25, 这是其原始音量的1/4. 若背景音乐声音过大, 将会盖住其他的更重要的东西. 你可以随时使用该值, 有效值为从
0
(静音)到1
(最大音量).接下来两行做同样的事, 针对于正在播放的BGM.
最后, 我们调用playHomeBGM()
(还没开始写)来播放home的BGM
提示: 如果你使用的是新版本的Flame(比如
0.11.0
), 你会需要使用loopLongAudio
来代替loop
, 并且替换掉其他需要用到loop
的函数.
也欢迎来看一下新的管理及播放背景音乐教程
现在来写playHomeBGM()
:
void playHomeBGM() {
playingBGM.pause();
playingBGM.seek(Duration.zero);
homeBGM.resume();
}
void playPlayingBGM() {
homeBGM.pause();
homeBGM.seek(Duration.zero);
playingBGM.resume();
}
代码解析: 这两个函数做了类似的事情, 但一种是用于home的BGM, 另一种用于playing的BGM. 它们基本上是对立的.
使用
playHomeBGM
, 我们暂停playBGM
, 并将其播放位置设为从头开始(Duration.zero
). 另一方面,playPlayingBGM
做了类似的事情, 只是把homeBGM
和playBGM
进行交换.
每当玩家输掉比赛时, 我们都应该返回到homeBGM
, 因此在onTapDown
处理器和未命中情况下(播放"haha"的SFX下面), 添加以下行用于暂停重置playBGM
并播放homeBGM
:
playHomeBGM();
然后立刻回到./lib/components/callout.dart
, 在游戏失败条件(检查值是否小于0的if
块)中, "haha"SFX的下方添加:
fly.game.playHomeBGM();
最后一步, 我们要在开始游戏时播放"playing"的BGM, 因此打开./lib/components/start-button.dart
, 并在inTapDown
函数的末尾添加:
game.playPlayingBGM();
运行游戏, 听一下我们刚刚加入的背景音乐吧!
提示: 如果出现音乐播放异常, 先检查代码是否有误.
若保证代码无误, 可以尝试一下清理缓存、重新安装游戏至虚拟机. 有关清理Flutter缓存, 欢迎查看这篇《Flutter 清理编译缓存》
第五步: 控制BGM 和 SFX
游戏中包含音乐和音效通常体验不错, 但我们也需要考虑特殊情况. 有时玩家只想安静的玩游戏.
为此, 我们将为玩家提供两个开关按钮, 一个用于开启/屏蔽音乐, 另一个来开启/屏蔽音效.
我们将要做的两个按钮分别有两种状态: 启用/禁用. 我们已经在资源包中提供了两种(4个)图片.
让我们将其余的图标从资源包的./images/ui/
复制到./assets/images/ui/
的assets目录, 文件结构如下:
./assets
./assets/images
./assets/images/ui
./assets/images/ui/icon-music-disabled.png
./assets/images/ui/icon-music-enabled.png
./assets/images/ui/icon-sound-disabled.png
./assets/images/ui/icon-sound-enabled.png
就像其他所有资源文件一样, 我们需要将这些文件添加至./pubspec.yaml
的assets
中, 使Flutter知道我们需要将这些文件打在包中:
- assets/images/ui/icon-music-disabled.png
- assets/images/ui/icon-music-enabled.png
- assets/images/ui/icon-sound-disabled.png
- assets/images/ui/icon-sound-enabled.png
注意: 可能是原作者的忙中出错, 检查资源包中的
icon-sound enabled.png
文件, 若有误则需要重命名为icon-sound-enabled.png
!
打开./lib/main.dart
来预加载这些图标. 在Flame.images.loadAll
调用的数组中添加:
'ui/icon-music-disabled.png',
'ui/icon-music-enabled.png',
'ui/icon-sound-disabled.png',
'ui/icon-sound-enabled.png',
现在, 我们将创建按钮并将其添加到game类中. 这些按钮与"帮助"和"感谢"按钮相似.
创建./lib/components/music-button.dart
:
import 'dart:ui';
import 'package:flame/sprite.dart';
import 'package:langaw/langaw-game.dart';
class MusicButton {
final LangawGame game;
Rect rect;
Sprite enabledSprite;
Sprite disabledSprite;
bool isEnabled = true;
MusicButton(this.game) {
rect = Rect.fromLTWH(
game.tileSize * .25,
game.tileSize * .25,
game.tileSize,
game.tileSize,
);
enabledSprite = Sprite('ui/icon-music-enabled.png');
disabledSprite = Sprite('ui/icon-music-disabled.png');
}
void render(Canvas c) {
if (isEnabled) {
enabledSprite.renderRect(c, rect);
} else {
disabledSprite.renderRect(c, rect);
}
}
void onTapDown() {
if (isEnabled) {
isEnabled = false;
game.homeBGM.setVolume(0);
game.playingBGM.setVolume(0);
} else {
isEnabled = true;
game.homeBGM.setVolume(.25);
game.playingBGM.setVolume(.25);
}
}
}
代码解析: 由于它和"帮助"以及"感谢"按钮非常相似, 所以重点讲一下它们的不同之处.
我们不是1个
sprite
变量来存储按钮的sprite. 而是两个, 一个保存启用状态的sprite, 另一个则是禁用状态的sprite. 该按钮在屏幕的左上角, 其左边缘和顶部边缘分别在屏幕的左边和顶部边缘的1/4的位置.我们还有另一个名为
isEnabled
的变量, 它是一个布尔值, 表示它可以包含true
和false
. 通过操纵此变量来切换按钮的状态, 并渲染适当的sprite, 你可以在render()
内部看到.不过, 最重要的区别是
onTapDown
处理器. 有一个用于检查isEnabled
是否为true
的if
块. 若为true
, 则将值改为false
, 并且homeBGM
和playingBGM
的音量(在game
实例中)设为0. 若isEnabled
为false
, 则将值改为true
, 并将两个BGM的音量设置为0.25
(初始值).
让我们来写另一个控制按钮, 创建文件./lib/components/sound-button.dart
, :
import 'dart:ui';
import 'package:flame/sprite.dart';
import 'package:langaw/langaw-game.dart';
class SoundButton {
final LangawGame game;
Rect rect;
Sprite enabledSprite;
Sprite disabledSprite;
bool isEnabled = true;
SoundButton(this.game) {
rect = Rect.fromLTWH(
game.tileSize * 1.5,
game.tileSize * .25,
game.tileSize,
game.tileSize,
);
enabledSprite = Sprite('ui/icon-sound-enabled.png');
disabledSprite = Sprite('ui/icon-sound-disabled.png');
}
void render(Canvas c) {
if (isEnabled) {
enabledSprite.renderRect(c, rect);
} else {
disabledSprite.renderRect(c, rect);
}
}
void onTapDown() {
isEnabled = !isEnabled;
}
}
代码解析: 你应该能注意到, 它几乎和
MusicButton
完全一致, 其位置在屏幕的左上角, 但位置稍微偏右. 另外,onTapDown
处理器只是布尔值的简单切换. 这是因为音效比较短, 无需使它静音.
为了将这些按钮显示在游戏中, 我们需要将它们添加至game类中. 打开./lib/langaw-game.dart
, 导入刚刚创建的按钮:
import 'package:langaw/components/music-button.dart';
import 'package:langaw/components/sound-button.dart';
然后创建按钮的实例变量:
MusicButton musicButton;
SoundButton soundButton;
我们需要在initialize()
中初始化这些按钮, 最好在"帮助"和"感谢"按钮下方:
musicButton = MusicButton(this);
soundButton = SoundButton(this);
接下来我们需要从game类的render()
中调用按钮的render()
函数. 在最上面显示这些按钮(但在对话框下面), 以便在游戏过程中任何时候都可用(除了"帮助"和"感谢"弹框在屏幕上时):
musicButton.render(canvas);
soundButton.render(canvas);
最后, 我们需要将点击事件转发到按钮的onTapDown
处理器. 请记住, 最上面的对象应该首先接收点击事件. 由于我们的按钮将位于弹框的后面, 因此这些按钮的点击判断应在弹框的点击处理器的下方:
// 音乐按钮
if (!isHandled && musicButton.rect.contains(d.globalPosition)) {
musicButton.onTapDown();
isHandled = true;
}
// 音效按钮
if (!isHandled && soundButton.rect.contains(d.globalPosition)) {
soundButton.onTapDown();
isHandled = true;
}
MusicButton
已经在处理BGM的音量了. 现在唯一缺少的就是让SoundButton
的状态影响实际的SFX.
我们要做的是在尝试播放音效之前检查声音按钮的状态. 将"播放"的调用逻辑放在if
块中, 就可以实现这个需求, 该块判断soundButton
的isEnabled
属性是否为true
.
我们需要更改三个位置来实现这个功能, 首先打开./lib/langaw-game.dart
, onTapDown
内部.
替换:
Flame.audio.play('sfx/haha' + (rnd.nextInt(5) + 1).toString() + '.ogg');
if (soundButton.isEnabled) {
Flame.audio.play('sfx/haha' + (rnd.nextInt(5) + 1).toString() + '.ogg');
}
第二步, 打开./lib/components/callout.dart
, 在update()
内部.
替换:
Flame.audio.play('sfx/haha' + (fly.game.rnd.nextInt(5) + 1).toString() + '.ogg');
if (fly.game.soundButton.isEnabled) {
Flame.audio.play('sfx/haha' + (fly.game.rnd.nextInt(5) + 1).toString() + '.ogg');
}
最后, 打开./lib/components/fly.dart
, 在onTapDown()
内部.
替换:
Flame.audio.play('sfx/ouch' + (game.rnd.nextInt(11) + 1).toString() + '.ogg');
if (game.soundButton.isEnabled) {
Flame.audio.play('sfx/ouch' + (game.rnd.nextInt(11) + 1).toString() + '.ogg');
}
测试游戏!
如果你一直跟着教程走, 那么现在应该有了一个这样的游戏:
我们的游戏已经相当完善了: 有了积分系统、高分记录, 音效, BGM和声音控制按钮.
下一章之前, 留给大家一个挑战. 这里有一个小功能大家可以自行研究: 每次玩家启动游戏时, 音乐和音效按钮都会被重置为已启动, 看看你能否把设置状态存起来!
如果你出现了不懂的地方, 不要犹豫, 欢迎在评论区留言! 也欢迎加入我的Flame交流群(QQ), 进行实时沟通.
下一章会干什么
强烈建议看下一章前, 先来看下新的背景音乐教程
下一章就是终章了! 我们将会修复一些bug, 并将我们的游戏打包, 预备好发布到各大APP平台上!
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK