12

Flutter 游戏开发(flame) 04 分数, 存档和音效(4/5)

 3 years ago
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.
neoserver,ios ssh client

Flame中文网已建成,欢迎进入Flame中文论坛发帖讨论!

分数高分记录是任何游戏中不可或缺的一部分. 有些游戏根据收集的金币数量计算得分, 有些基于杀敌数, 有些则基于存活时间.

同样不能忽视音效BGM(背景音乐). 加上它们, 游戏将会蜕变🦋.

如果一个游戏无声, 它就是不完整的.

欢迎来到Flutter+Flame系列的第四章. 如果你还没有读过之前的章节, 建议你先阅读一下哦!🤭

🔴 注意: 本教程的背景音乐有些过时. 你可以先学本章教程, 之后再替换为这篇教程

下面是我们本系列教程的文章目录:

需具备的条件

  1. 本系列教程之前的全部要求👈
  2. 更多的资源包 – 本教程提供了资源包, 但你也可以使用自己的. 推荐资源网站Open Game Art.
  3. 音效和音乐资源 – 这些也可以在游戏资源网站上找到, 比如Open Game Art. 还有专门的音频资源比如BenSound.com. 同样, 你必须查看许可并在游戏中表示感谢.

我们将使用与前一部分相同的编码规范👩‍🏫

👉Github码云上查看本章全部代码

新的资源包

这一部分中, 我们需要另一个资源包, 包含额外的图形、背景音乐和一些音效.

点击图片或此处进行下载


注意: 如果你遵循本教程, 则可以使用上述资源包. 本资源包是Github上Langaw(原作者)项目的一部分, 该项目获得CC-BY-NC-ND许可证的许可.

这意味着您可以共享、复制或者重新分发资源.

  • 你必须在感谢中提到, 提供许可证的链接, 并标明你是否进行了更改.
  • 你不得将资源用于商业目的.
  • 如果混合, 转换或者构建资源, 则不能分发修改后的资源.
  • 你不得应用法律条款或技术措施, 在法律上限制他人做许可证允许的任何事情.

点击此处了解有关 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使我们可以访问CanvasOffset; 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属性设置为包含一项的类型为ShadowList. 此项定义一个阴影, 该阴影在右侧和底部偏移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, 因此我们不用检查它. 我们不确定的是paintertext属性是否为null, 因此我们使用该运算符.

使用的另一个运算符是??.. 若左侧不为null, 返回左侧表达式; 若为null, 返回右侧表达式.

对于整个表达式, 如果未设置paintertext属性, 则整个painter.text?.text返回null. 由于其后跟随了一个??, 因此if的返回值是一个空字符串. 最终的值是与game.score.toString()进行比较的结果. 另一方面, 如果没有设置paintertext属性, 则会返回当前的实际分数.

你可以在这篇《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);
}

💡 代码解析: 通过调用painterpaint()并提供所需的参数来渲染分数: 一个可以用于绘制的画布并且用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, 若是, 则调用scoreDisplayupdate函数. 你可以将此行放在任何地方.

在函数末尾添加:

if (activeView == View.playing) scoreDisplay.update(t);

然后在game类的render()中, 我们做类似的事情, 但是是调用render(). 记住在此函数内写入行的顺序是图形在屏幕上绘制的实际顺序.

我们希望score正好高于背景, 低于其他的所有东西. 小飞蝇可以飞越它. 因此, 在渲染背景后添加:

if (activeView == View.playing) scoreDisplay.render(canvas);
02-3.png

处理小飞蝇加分

玩家想要加分, 必须击落小飞蝇.

要拥有这套逻辑, 打开./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实例.

运行游戏, 你应该可以看到分数了:

03-2.png

进阶版小飞蝇

小飞蝇不仅仅是飞来飞去, 它们同时也会进食. 另外, 若玩家只是伺机而动精准击落小飞蝇, 多少有些无聊🤐🤐.

让我们再来添加一个游戏失败的条件😲.

一旦小飞蝇出现在屏幕上, 它会显示一个倒数的计数器. 这个计数器相当于小飞蝇还有多久”吃完”. 当计数器归零时, 意味着小飞蝇”吃完”, 游戏失败.

这样游戏就增加了挑战性, 没有之前那么无聊了.

我们必须确保玩家可以注意到计数器, 看看那些小飞蝇即将归零. 我们将使用资源包中的标注图形来显示此计数器.

让我们添加资源包中的标注图形./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);
    }
}

💡 代码解析: 首先我们渲染标注(现在应该比较熟悉了). 然后我们使用TextPainterpaint函数来绘制文本, 并传递我们刚刚在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);

如果小飞蝇没有死掉, 那么标注就需要进行更新. 因此在Flyupdate函数内, 在else(小飞蝇尚未死掉)代码块底部添加:

callout.update(t);

最终, 若小飞蝇还没死(else块内)并且当前的界面为playing, 则渲染标注. 添加到render中:

if (game.activeView == View.playing) {
  callout.render(c);
}

🟢 运行游戏, 看看效果:

04-1.gif

👉Github码云上查看这部分的代码.

第二步: 存储最高分

如果游戏没有记录最高分, 那么获得这个宝贵的分数将是一种浪费.

让我们来记录最高分, 这样玩家就可以不断突破自己, 取得更高的分数.

记录最高分, 仅需存储一条数据. 只是一个整数.

这个简单的任务可以通过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.

然后我们更新paintertext属性为一个新的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):

05-2.png

更新最高分

目前的最高分是暂时无效的. 只要满足以下条件, 就需要更新它:

  1. 玩家当前界面是"playing"
  2. 当前分数高于最高分

为此我们打开./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实例, 以向玩家显示他的分数是当前最高分.

代码截图:

06-2.png

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

07-2.png

👉Github码云上查看这部分的代码.

第三步: 音效

🟡 提示: 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.yamlassets中添加:

 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), 但是我们可以通过在播放时使用多个版本的声音效果, 是游戏的声音添加一些趣味. 如果我们每次击杀小飞蝇时都播放相同的声音, 它可能会很快被玩家玩腻.

为此, 每次我们需要播放声音时, 就会从gamernd中获取一个随机数, 并播放相应的"相同"的随机一种音效.

打开./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, 则可以得到010之间的任意数字. 然后将其加1, 使返回的数字变为从111, 这样刚好匹配我们的文件名(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引用, 然后再通过flygame进行引用, 然后再转到rnd对象.

🟢 尝试运行游戏, 看看效果!

👉Github码云上查看这部分的代码.

第四步: 背景音乐

是时候添加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做了类似的事情, 只是把homeBGMplayBGM进行交换.

每当玩家输掉比赛时, 我们都应该返回到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 清理编译缓存》

👉Github码云上查看这部分的代码.

第五步: 控制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.yamlassets中, 使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的变量, 它是一个布尔值, 表示它可以包含truefalse. 通过操纵此变量来切换按钮的状态, 并渲染适当的sprite, 你可以在render()内部看到.

不过, 最重要的区别是onTapDown处理器. 有一个用于检查isEnabled是否为trueif块. 若为true, 则将值改为false, 并且homeBGMplayingBGM的音量(在game实例中)设为0. 若isEnabledfalse, 则将值改为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块中, 就可以实现这个需求, 该块判断soundButtonisEnabled属性是否为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');
}

👉Github码云上查看这部分的代码.

测试游戏!

如果你一直跟着教程走, 那么现在应该有了一个这样的游戏:

我们的游戏已经相当完善了: 有了积分系统、高分记录, 音效, BGM和声音控制按钮.

下一章之前, 留给大家一个挑战. 这里有一个小功能大家可以自行研究: 每次玩家启动游戏时, 音乐和音效按钮都会被重置为已启动, 看看你能否把设置状态存起来!

如果你出现了不懂的地方, 不要犹豫, 欢迎在评论区留言! 也欢迎加入我的Flame交流群(QQ), 进行实时沟通.

Flame中文网已建成,欢迎进入Flame中文论坛发帖讨论!

下一章会干什么

强烈建议看下一章前, 先来看下新的背景音乐教程

下一章就是终章了! 我们将会修复一些bug, 并将我们的游戏打包, 预备好发布到各大APP平台上!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK