经过之前的持续努力,我们有了一个可以正常对战、且规则完备的象棋游戏了!
但要做为放在市场上的的一个有一定竞争力的游戏,体验还不能达标 — 太安静了、太朴素了!
本节我们来提升游戏的综合体验!
本节概要
- 导入音乐/图片设计资源
- 美化主菜单页面
- 象棋游戏中动效实现
- 实现音效和音乐播放
Flutter 图像资源的组织
我们先来配置一下游戏中需要使用的图像资源。
为了方便大家跟随课程做试验性开发,你可以从Git(https://rocketgit.com/user/elapse/chinese_chess/source/tree/branch/master/tree/) 这里下载我们提供的样本。
一个游戏必不可少 Logo,它是你游戏的灵魂载体,你最好自己设计它!
将我们提供的的图版资源复制到项目根目录下的 images 文件夹中,然后打开项目根目下的 pubspec.yaml
文件,我们将在其中注册我们的图片:
...
flutter:
...
assets:
- images/logo.png
- images/logo-mini.png
- images/mei.png
- images/zhu.png
...
在 Flutter 中加载图像资源可以很好的覆盖不同密度屏幕。直接在 images 下分别建立不同的密度倍数的子文件夹:1.5x、2.0x、3.0x……,在进行 Assets 资源引用的时候,不需要在资源路径中带上密度指示文件夹,Flutter 会自动地选择适应当前屏幕的图像资源:
现在,我们开始美化主菜单页面。
美化主菜单页
打到 MainMenu 类的 build 方法中的 menuItems 变量的定义,我们为主界面添加 Logo 和点缀用的一丛梅花、几株竹子的的图片,将它修改成下边的样子:
@override
Widget build(BuildContext context) {
//
final nameStyle = TextStyle(
fontSize: 64,
color: Colors.black,
);
final menuItemStyle = TextStyle(
fontSize: 28,
color: ColorConsts.Primary,
);
final menuItems = Center(
child: Column(
children: [
Expanded(child: SizedBox(), flex: 3),
Image.asset('images/logo.png'), // logo
Expanded(child: SizedBox()),
Text('中国象棋', style: nameStyle, textAlign: TextAlign.center),
Expanded(child: SizedBox()),
FlatButton(child: Text('单机对战', style: menuItemStyle), onPressed: () {}),
Expanded(child: SizedBox()),
FlatButton(
child: Text('挑战云主机', style: menuItemStyle),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => BattlePage()),
);
},
),
Expanded(child: SizedBox()),
FlatButton(child: Text('排行榜', style: menuItemStyle), onPressed: () {}),
Expanded(child: SizedBox(), flex: 3),
Text('用心娱乐,为爱传承', style: TextStyle(color: Colors.black54, fontSize: 16)),
Expanded(child: SizedBox()),
],
),
);
return Scaffold(
backgroundColor: ColorConsts.LightBackground,
body: Stack(
children: [
// 右上角显示梅花
Positioned(right: 0, top: 0, child: Image(image: AssetImage('images/mei.png'))),
// 左下角显示竹子
Positioned(left: 0, bottom: 0, child: Image(image: AssetImage('images/zhu.png'))),
menuItems,
Positioned(
top: ChessRoadApp.StatusBarHeight,
left: 10,
child: IconButton(
icon: Icon(Icons.settings, color: ColorConsts.Primary),
onPressed: () {},
),
),
],
),
);
}
一图顶万言!在 vscode 中按 F5
运行代码看看,因为
Logo 和两张图片的加入,浓浓的中国风味道出来了!
Logo 上的 Hero 动画
Logo 是品宣的重要元素,怎么突出都不算过份。
接着,我们在对战页面顶部添加小 Logo,打开 _BattlePageState 类的 createPageHeader 方法,找到 Text('单机对战', style: titleStyle)
代码行,在之上插入两行代码:
Image.asset('images/logo-mini.png'),
SizedBox(width: 10),
这会在对战页面最醒目的位置 — 标题前边添加我们的“将帅象棋”小 logo。
我们将使用 Hero 牵引动效,以加深 Logo 在用户心中的份量,它会在 MainMenu 和 BattlePage 两个页面转场的过程中,产生动画牵引效果。
实现方法比较简单,在 MainMenu 和 _BattlePageState 页面中,用 Hero Widget 包裹 Logo 和小 logo,并给它们指定相同的 hero tag。
在 MainMenu 中找到 Image.asset('images/logo.png')
,用
Hero 包裹它:
Hero(child: Image.asset('images/logo.png'), tag: 'logo'),
在 BattlePage 中找到 Image.asset('images/logo-mini.png')
,用
Hero 包裹它:
Hero(child: Image.asset('images/logo-mini.png'), tag: 'logo'),
两个页面中被 Hero 包裹且有相同 Tag 的 Widget,在发生页面转场时,Hero 包裹的 Widget 将自动地从前一个页面变换到另一个页面具有相同 TAG 的 Hero 的 Widget 的新位置。
现在运行产品看看,体会一下 Hero 动画虽简单但神奇的效果:
动效和动态阴隐
现在再看首页 UI,文字过于平面化!我们给它添加一些阴隐,应该有助于消除这种感觉!
在 MainMenu 中找到 build 方法开始始处的两个文字样式定义,在它们之前添加两上 shadow 定义,并把它们用在应用标题和菜单的文字样式上:
final nameShadow = Shadow(
color: Color.fromARGB(0x99, 66, 0, 0),
offset: Offset(0, 2),
blurRadius: 4,
);
final menuItemShadow = Shadow(
color: Color.fromARGB(0x7F, 0, 0, 0),
offset: Offset(0, 2),
blurRadius: 4,
);
final nameStyle = TextStyle(
fontSize: 64,
color: Colors.black,
shadows: [nameShadow],
);
final menuItemStyle = TextStyle(
fontSize: 28,
color: ColorConsts.Primary,
shadows: [menuItemShadow],
);
用了不同的颜色设置了游戏名称和菜单的阴影效果了,运行程序看看。
首页有那么点样子了!如果说还差点什么的话,应该是差点动效了,那我们动手吧!
将 MainMenu 从 StatelessWidget 改为 StatefulWidget,前文说过修改方法:
在 vscode 里面,将光标停留在
class MainMenu
字样上,按Cmd+.
,在弹出菜单中选择Convert to StatefuleWidget
。
在 _MainMenuState 类中添加两个动画以及两个动画的控制器变量的定义,并为 _MainMenuState 添加 TickerProviderStateMixin 注入:
class _MainMenuState extends State with TickerProviderStateMixin {
//
AnimationController inController, shadowController;
Animation inAnimation, shadowAnimation;
...
}
接着,覆盖 _MianMenuState 的 initState 方法,并在其中初始化动画实例:
@override
void initState() {
//
super.initState();
// 标题缩放动画
inController = AnimationController(
duration: Duration(milliseconds: 600),
vsync: this,
);
inAnimation = CurvedAnimation(parent: inController, curve: Curves.bounceIn);
inAnimation = new Tween(begin: 1.6, end: 1.0).animate(inController);
// 阴影厚度变化
shadowController = AnimationController(
duration: Duration(milliseconds: 1500),
vsync: this, // 自身已经 Mixin 进了 TickerProviderStateMixin
);
shadowAnimation = new Tween(begin: 0.0, end: 12.0).animate(shadowController);
// 缩放动画完成后,开始阴影厚度变换动画
inController.addStatusListener((status) {
if (status == AnimationStatus.completed) shadowController.forward();
});
// 阴影厚度变换动画完成后,自动复位(为下一次呈现做准备)
shadowController.addStatusListener((status) {
if (status == AnimationStatus.completed) shadowController.reverse();
});
/// use 'try...catch' to avoid exception -
/// 'setState() or markNeedsBuild() called during build.'
inAnimation.addListener(() {
try {
setState(() {});
} catch (e) {}
});
shadowAnimation.addListener(() {
try {
setState(() {});
} catch (e) {}
});
inController.forward();
}
上边的代码定义了两个动画,并定义了两动画的状态转换关系:
- 第一个动画 inAnimation 在入场时动态的缩放「中国象棋」几个字
- 第二个动画动态地为「中国象棋」几个字生成厚度不同的阴影效果
- 第一个动画完成后,自动开始第二个动画。
- 第二个动画完成后,再反向复位消除阴影
接着,我们让动画结合到具体的 Widget 上去。在 _MainMenuState 类的 build 方法中找到以下的代码:
Text('中国象棋', style: nameStyle, textAlign: TextAlign.center)
将它用 Transform 包裹,改成下边的样子:
Transform.scale(
scale: inAnimation.value,
child: Text('中国象棋', style: nameStyle, textAlign: TextAlign.center),
),
之后,找到 _MainMenuState 类的 build 方法开始的处的两个 shadown 的定义,将它改成下边的样子:
final nameShadow = Shadow(
color: Color.fromARGB(0x99, 66, 0, 0),
offset: Offset(0, shadowAnimation.value / 2),
blurRadius: shadowAnimation.value,
);
final menuItemShadow = Shadow(
color: Color.fromARGB(0x7F, 0, 0, 0),
offset: Offset(0, shadowAnimation.value / 6),
blurRadius: shadowAnimation.value / 3,
);
这样一来,在动画的变换过程中,标题「中国象棋」几个字将从大到小入场,完了之后它的阴隐从无到有,然后从薄到厚地呈现,再从厚到薄,再消失……
使用动画时,别忘了 dispose 动画控制器,覆盖 _MainMenuState 的 dispose 方法,释放动画控制器:
@override
void dispose() {
//
inController.dispose();
shadowController.dispose();
super.dispose();
}
动画运行完美,就是这简单称手!但仅限入场的时候呈现。如果从其它页面返回首页菜单也能呈现动画就好了。
我们做点调整来实现从其它页面重返时的动效吧。首先在 _MainMenuState 类中添加下的方法:
navigateTo(Widget page) async {
//
await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => page),
);
// 从其它页面返回后,再呈现动效效果
inController.reset();
shadowController.reset();
inController.forward();
}
然后在 _MainMenuState 的 build 方法中,找到以下代码:
FlatButton(
child: Text('挑战云主机', style: menuItemStyle),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => BattlePage()),
);
},
),
将它修改成下边的样子:
FlatButton(
child: Text('挑战云主机', style: menuItemStyle),
onPressed: () => navigateTo(BattlePage()),
),
界面动画讲究「刚刚好」,就这些吧。在 vscode 中按 F5
运行产品看看:
先把动画这一部份代码push到Git:
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git add .
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git commit -m '添加动画效果'
[master 8075ce0] 添加动画效果
24 files changed, 895 insertions(+), 14 deletions(-)
create mode 100644 images/1.5x/logo-mini.png
create mode 100644 images/1.5x/logo.png
create mode 100644 images/1.5x/mei.png
create mode 100644 images/1.5x/zhu.png
create mode 100644 images/2.0x/logo-mini.png
create mode 100644 images/2.0x/logo.png
create mode 100644 images/2.0x/mei.png
create mode 100644 images/2.0x/zhu.png
create mode 100644 images/3.0x/logo-mini.png
create mode 100644 images/3.0x/logo.png
create mode 100644 images/3.0x/mei.png
create mode 100644 images/3.0x/zhu.png
create mode 100644 images/4.0x/logo-mini.png
create mode 100644 images/4.0x/logo.png
create mode 100644 images/4.0x/mei.png
create mode 100644 images/4.0x/zhu.png
create mode 100644 images/logo-mini.png
create mode 100644 images/logo.png
create mode 100644 images/mei.png
create mode 100644 images/zhu.png
create mode 100644 lib/luck.html
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ sudo git push
[sudo] elapse 的密码:
Username for 'https://rocketgit.com': elapse
Password for 'https://[email protected]':
枚举对象: 39, 完成.
对象计数中: 100% (39/39), 完成.
使用 4 个线程进行压缩
压缩对象中: 100% (33/33), 完成.
写入对象中: 100% (33/33), 2.09 MiB | 54.00 KiB/s, 完成.
总共 33 (差异 4),复用 0 (差异 0)
remote: RocketGit: Info: == Welcome to RocketGit! ==
remote: RocketGit: Info: you are connecting from IP 2408:8256:686:857c:65a5:d57e:83b8:2920 by http(s).
remote: RocketGit: Info: date/time: 2020-08-30 05:20:16 (UTC), debug id b4df61.
To https://rocketgit.com/user/elapse/chinese_chess
8f50019..8075ce0 master -> master
elapse@elapse-PC:~/Language/Flutter/chinese_chess$
导入游戏音效
游戏怎么能只有画面,没有音效呢?因此,我们需要添加一些声音资源到游戏中。
与前面的图片一样,我们有些素材提供,大家可以从Git下载这些声音素材。下载声音素材后,请将它复制到项目根上下,如下图所示:
为了能在项目中使用这素材,我们首先要将素材在 pubspec.yaml
文件中进行注册:
...
flutter:
...
assets:
...
- audios/bg_music.mp3
- audios/capture.mp3
- audios/check.mp3
- audios/click.mp3
- audios/regret.mp3
- audios/draw.mp3
- audios/tips.mp3
- audios/invalid.mp3
- audios/lose.mp3
- audios/move.mp3
- audios/win.mp3
...
音效资源注册好了,现在准备播放声音!
使用三方音效插件
这儿有一个坏消息,Flutter 只是一套 UI 库,声音的处理不在 Flutter 内部支持功能的范围!
不过呢,使用第三方为处理与系统相关的事务是 Flutter 推荐的方式,我们只需要找一个评价好的三方插件就解决问题了。
查找和安装第三方插件的常规的方法是这样的:
使用浏览器打开 https://pub.dev,查找你关心的关键词,这里我们用 audio
作关键词搜索,可以看到大量插件出现在搜索结果,我们需要选出适用的那一个。
对插件进行大致选择有个重要依据,一个是其它开发者对其给出的评价:
但这显然不够,还要考虑开发语言是不是一致这些因素,这个随然也有兼容处理的方案,但总是多一事不如少一事。
试过几个音效播放插件,有些不能从 assets 资源中播放,有些不支持提前缓存,各有各的问题,最后我选择了 audioplayers 插件:
图上的插件名称为 audioplayers
,当前版本 0.14.0
,把对它的依赖和版本加到你项目根目的 pubspec.yaml
文件中去:
...
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^0.1.2
audioplayers: ^0.14.0
...
版本号前的「^」,相关于从这个版本开始找之后的新版本,然后使用它
在 vscode 中直接保持 pubspec.yaml
文件的改动时,会自动开始执行 flutter pub get
指令,等指令运行完成后就可以使用插件了。
创建 lib/services 文件夹,在其下创建 audios.dart 文件,其中的 Audios 类用以包装音效播放相关的操作:
import 'package:audioplayers/audio_cache.dart';
import 'package:audioplayers/audioplayers.dart';
class Audios {
//
static AudioPlayer _fixedBgmPlayer, _fixedTonePlayer;
static AudioCache _bgmPlayer, _tonePlayer;
// 专门负责音乐轮播
static loopBgm(String fileName) async {
//
try {
if (_bgmPlayer == null) {
//
_fixedBgmPlayer = AudioPlayer();
_bgmPlayer = AudioCache(prefix: 'audios/', fixedPlayer: _fixedBgmPlayer);
await _bgmPlayer.loadAll(['bg_music.mp3']);
}
_fixedBgmPlayer.stop();
_bgmPlayer.loop(fileName);
} catch (e) {}
}
// 专门负责音效播放
static playTone(String fileName) async {
//
try {
if (_tonePlayer == null) {
//
_fixedTonePlayer = AudioPlayer();
_tonePlayer = AudioCache(prefix: 'audios/', fixedPlayer: _fixedTonePlayer);
await _tonePlayer.loadAll([
'capture.mp3',
'check.mp3',
'click.mp3',
'regret.mp3',
'draw.mp3',
'tips.mp3',
'invalid.mp3',
'lose.mp3',
'move.mp3',
'win.mp3',
]);
}
_fixedTonePlayer.stop();
_tonePlayer.play(fileName);
} catch (e) {}
}
static stopBgm() {
try {
if (_fixedBgmPlayer != null) _fixedBgmPlayer.stop();
} catch (e) {}
}
// 停止音乐和音效
static Future release() async {
try {
if (_fixedBgmPlayer != null) {
await _fixedBgmPlayer.release();
}
if (_fixedTonePlayer != null) {
await _fixedTonePlayer.release();
}
} catch (e) {}
}
}
在游戏中,长时间循环播放的背景音乐,和只是叮一声、咚一声响一下的短暂音效处理方式是不同的。因此我们在 Audios 类中实现了 Bgm 播放和 tone 播放的独立控制。
这个插件有个好处,就是可以预先把音效都先加载到缓存,这样播放的时候就几乎没有延迟了。
音效工具准备好了,我们只需要在合理的地方放置音效播放代码就完事了。
为了得到更细致的生命周期方法控制,我们将 main.dart 中的 ChessApp 由 StatelessWidget 改为 StatefulWidget。
前文件两次说过这个技巧,请大家记住:
在 vscode 中,将方法放在
class ChessApp
上按Cmd+.
,然后在弹出的菜单中选择Convert to StatefulWidget
。
在 _ChessAppState 类中覆盖 initState 方法,启动背景音乐循环播放:
@override
void initState() {
super.initState();
Audios.loopBgm('bg_music.mp3');
}
注意这里只使用音效的名称,因为之前已经将音乐文件预告加入缓存了。
在 _ChessAppState 类的 build 方法中,我们使用 WillPopScope 包裹 MainMenu 的实例,这将会在页面被弹出时得到回调机会,具体的 build 方法代码如下:
@override
Widget build(BuildContext context) {
//
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.brown, fontFamily: 'QiTi'),
debugShowCheckedModeBanner: false,
home: WillPopScope(
onWillPop: () async {
// 菜单页被弹出时,停止音乐
Audios.release();
return true;
},
child: MainMenu(),
),
);
}
MainMenu 被弹出(android 的 back 键退出)方法被回调时,我们停止所有音乐、音效播放。
在 _BattlePageState 中找到 gotWin 方法的实现,在最开始的地方添加一行代码:
Audios.playTone('win.mp3');
类似的,在 gotLose 方法最开始的地方,也添加如下的一行代码:
Audios.playTone('lose.mp3');
在 Battle 类中找到 select 方法的实现代码,在最后添加一行代码:
Audios.playTone('click.mp3');
为了能判断刚才着法有没有吃子(或只是移动棋子),我们将 Phase 类的 move 方法修改成如下的样子:
// 修改后的 move 方法不再返回 bool 值,而是返回字符串
// null 表示着法不合法,字符表示被吃掉的棋子
String move(int from, int to) {
//
if (!validateMove(from, to)) return null;
final captured = _pieces[to];
if (captured != Piece.Empty) {
halfMove = 0;
} else {
halfMove++;
}
if (fullMove == 0) {
fullMove++;
} else if (side == Side.Black) {
fullMove++;
}
// 修改棋盘
_pieces[to] = _pieces[from];
_pieces[from] = Piece.Empty;
// 交换走棋方
_side = Side.oppo(_side);
return captured;
}
修改 Battle 类的 move 方法,修改后像下边这样:
bool move(int from, int to) {
//
final captured = _phase.move(from, to);
// 着法无效
if (captured == null) {
Audios.playTone('invalid.mp3');
return false;
}
_blurIndex = from;
_focusIndex = to;
// 将军
if (ChessRules.checked(_phase)) {
Audios.playTone('check.mp3');
} else {
// 吃子或仅仅是移动棋子
Audios.playTone(captured != Piece.Empty ? 'capture.mp3' : 'move.mp3');
}
return true;
}
美妙的音乐响起,驱散一切沉闷!试试运行我们的产品,听到背景音乐了吗?是不是更有意愿多走两了?
将代码提交到 git 仓库,多多感受一下我们的象棋游戏吧!
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git add .
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git commit -m '添加声音'
[master 46f767f] 添加声音
19 files changed, 246 insertions(+), 774 deletions(-)
create mode 100644 audios/bg_music.mp3
create mode 100644 audios/capture.mp3
create mode 100644 audios/check.mp3
create mode 100644 audios/click.mp3
create mode 100644 audios/draw.mp3
create mode 100644 audios/invalid.mp3
create mode 100644 audios/lose.mp3
create mode 100644 audios/move.mp3
create mode 100644 audios/regret.mp3
create mode 100644 audios/tips.mp3
create mode 100644 audios/win.mp3
delete mode 100644 lib/luck.html
create mode 100644 lib/services/audios.dart
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ sudo git push
[sudo] elapse 的密码:
Username for 'https://rocketgit.com': elapse
Password for 'https://[email protected]':
枚举对象: 37, 完成.
对象计数中: 100% (37/37), 完成.
使用 4 个线程进行压缩
压缩对象中: 100% (24/24), 完成.
写入对象中: 100% (26/26), 3.23 MiB | 96.00 KiB/s, 完成.
总共 26 (差异 8),复用 0 (差异 0)
remote: RocketGit: Info: == Welcome to RocketGit! ==
remote: RocketGit: Info: you are connecting from IP 112.94.53.44 by http(s).
remote: RocketGit: Info: date/time: 2020-08-30 08:57:13 (UTC), debug id c753d0.
To https://rocketgit.com/user/elapse/chinese_chess
8075ce0..46f767f master -> master
elapse@elapse-PC:~/Language/Flutter/chinese_chess$
小节回顾
本节开头,我们介绍了向 App 引入图片、音乐等设计资源的方式。
接着,我们使用和图片资源来美化了主菜单页面的视觉效果。
之后,我们在主菜单的菜单条上,实现了菜单项基础变换动效的阴隐厚度变换动效;
这之后,我们在主菜单和对战页面之间实现了 Hero 牵引动效;
最后,我们从 pub.dev 寻找了一个播放音效的三方插件,使用它来轮播了游戏的背景音乐,并为行棋等环节添加了动效。
在 Flutter 开发中,我们时常需要寻找三方插件来完善 Flutter 的能力。使用大多数三方插件的引入流程与我们引入音效播放插件的流程是一致,大家可以标记这一部分内容,将来以此为参考。