从游戏的完整性角度来说,我们还有一些工作要做。例如人机对战的难度,我们可以进行调节;背景音乐要不要自动播放,我们也需要设置;如果有人喜欢我们的游戏,想联系我们,我们应该有个关于页面让别人能找到我们。因此,我们需要给游戏添加一个设置页。
本节概要
- 实现本地配置的持久化工具
- 实现设置页面
- 在关于页面中获取版本信息和链接
实现保存配置的工具类
为了能让玩家的设置被保留起来,我们先在 lib/common 文件夹下新建 "profile.dart",它以键值对的方式,将用户设置信息保存在手机存储中,下次游戏开始时,自动加载并应用这些配置信息:
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
// 基于本地文件和 Json 操作,实现本地持久化
class Profile {
//
static const DefaultFileName = 'default-profile.json';
static Profile _shared;
File _file;
Map _values = {};
static shared() async {
//
if (_shared == null) {
_shared = Profile();
await _shared._load(DefaultFileName);
}
return _shared;
}
// 重定义数组操作
operator [](String key) => _values[key];
operator []=(String key, dynamic value) => _values[key] = value;
// 保存数据的修改
Future commit() async {
//
_file.create(recursive: true);
try {
final contents = jsonEncode(_values);
await _file.writeAsString(contents);
} catch (e) {
print('Error: $e');
return false;
}
return true;
}
// 从文件中加载数据
Future _load(String fileName) async {
//
final docDir = await getApplicationDocumentsDirectory();
_file = File('${docDir.path}/$fileName');
try {
final contents = await _file.readAsString();
_values = jsonDecode(contents);
} catch (e) {
return false;
}
return true;
}
}
这个类运行后,默认会在应用的文档目录下创建一个 default-profile.json 文件,并交用户的配置信息保存在这个文件中。这个类重载了「=」和「=()」运算符,直接可以,可以像使用 Map 一样操作操作配置项。
基于于 Profile,我们在 lib/common 文件夹下新建“config.dart”文件,这个类用于管理用户的配置内容:
import 'profile.dart';
// 基于本地文件持久化工具实现的配置管理类
class Config {
//
static bool bgmEnabled = true;
static bool toneEnabled = true;
static int stepTime = 5000;
// 加载配置项
static Future loadProfile() async {
//
final profile = await Profile.shared();
Config.bgmEnabled = profile['bgm-enabled'] ?? true;
Config.toneEnabled = profile['tone-enabled'] ?? true;
Config.stepTime = profile['step-time'] ?? 5000;
return true;
}
// 保存配置项到本地文件
static Future save() async {
//
final profile = await Profile.shared();
profile['bgm-enabled'] = Config.bgmEnabled;
profile['tone-enabled'] = Config.toneEnabled;
profile['step-time'] = Config.stepTime;
profile.commit();
return true;
}
}
这个工具类集中管理了游戏中需要进行配置的三个设置项。
实现设置页面
现在我们来添加设置页面,我们在 lib/routes 文件夹下新建 setting-page.dart 文件:
import 'package:chessroad/services/player.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info/package_info.dart';
import '../common/toast.dart';
import '../common/color-consts.dart';
import '../common/config.dart';
import '../services/audios.dart';
import 'edit-page.dart';
class SettingsPage extends StatefulWidget {
@override
_SettingsPageState createState() => _SettingsPageState();
}
class _SettingsPageState extends State {
//
String _version = 'Ver 1.00';
@override
void initState() {
super.initState();
loadVersionInfo();
}
// 使用三方插件读取应用的版本信息
loadVersionInfo() async {
//
final packageInfo = await PackageInfo.fromPlatform();
setState(() {
_version = 'Version ${packageInfo.version} (${packageInfo.buildNumber})';
});
}
// 切换引擎的难度等级
changeDifficult() {
//
callback(int stepTime) async {
//
Navigator.of(context).pop();
setState(() {
Config.stepTime = stepTime;
});
Config.save();
}
// 难度等级目前是由给引擎的思考时间决定的
// 其它一些可调整的因素还包括:
// = 是否启用开局库
// = 是否在选择着法时放弃最优着法
showModalBottomSheet(
context: context,
builder: (BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 10),
RadioListTile(
activeColor: ColorConsts.Primary,
title: Text('初级'),
groupValue: Config.stepTime,
value: 5000,
onChanged: callback,
),
Divider(),
RadioListTile(
activeColor: ColorConsts.Primary,
title: Text('中级'),
groupValue: Config.stepTime,
value: 15000,
onChanged: callback,
),
Divider(),
RadioListTile(
activeColor: ColorConsts.Primary,
title: Text('高级'),
groupValue: Config.stepTime,
value: 30000,
onChanged: callback,
),
Divider(),
SizedBox(height: 56),
],
),
);
}
// 开头背景音乐
switchMusic(bool value) async {
//
setState(() {
Config.bgmEnabled = value;
});
if (Config.bgmEnabled) {
Audios.loopBgm('bg_music.mp3');
} else {
Audios.stopBgm();
}
Config.save();
}
// 开关动作音效
switchTone(bool value) async {
//
setState(() {
Config.toneEnabled = value;
});
Config.save();
}
// 修改玩家的游戏名
changeName() async {
//
final newName = await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => EditPage('棋手姓名', initValue: Player.shared.name)),
);
if (newName != null) nameChanged(newName);
}
nameChanged(String newName) async {
//
setState(() {
Player.shared.name = newName;
});
Player.shared.saveAndUpload();
}
// 显示关于对话框
showAbout() {
//
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text('关于「棋路」', style: TextStyle(color: ColorConsts.Primary)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 5),
Text('版本', style: TextStyle(fontFamily: '')),
Text('$_version', style: TextStyle(fontFamily: '')),
SizedBox(height: 15),
Text('QQ群', style: TextStyle(fontFamily: '')),
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: '67220535'));
Toast.toast(context, msg: '群号已复制!');
},
child: Text(
"67220535",
style: TextStyle(fontFamily: '', color: Colors.blue),
),
),
SizedBox(height: 15),
Text('官网', style: TextStyle(fontFamily: '')),
GestureDetector(
onTap: () {
Clipboard.setData(
ClipboardData(text: 'https://www.apppk.cn/apps/chessroad.html'),
);
Toast.toast(context, msg: '网址已复制!');
},
child: Text(
"https://www.apppk.cn/apps/chessroad.html",
style: TextStyle(fontFamily: '', color: Colors.blue),
),
),
],
),
actions: [
FlatButton(child: Text('好的'), onPressed: () => Navigator.of(context).pop()),
],
),
);
}
@override
Widget build(BuildContext context) {
//
final TextStyle headerStyle = TextStyle(color: ColorConsts.Secondary, fontSize: 20.0);
final TextStyle itemStyle = TextStyle(color: ColorConsts.Primary);
return Scaffold(
backgroundColor: ColorConsts.LightBackground,
appBar: AppBar(title: Text('设置')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 10.0),
Text("人机难度", style: headerStyle),
const SizedBox(height: 10.0),
Card(
color: ColorConsts.BoardBackground,
elevation: 0.5,
margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 0),
child: Column(
children: [
ListTile(
title: Text("游戏难度", style: itemStyle),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
Text(Config.stepTime <= 5000 ? '初级' :
Config.stepTime <= 15000 ? '中级' : '高级'),
Icon(Icons.keyboard_arrow_right, color: ColorConsts.Secondary),
]),
onTap: changeDifficult,
),
],
),
),
const SizedBox(height: 16),
Text("声音", style: headerStyle),
Card(
color: ColorConsts.BoardBackground,
margin: const EdgeInsets.symmetric(vertical: 10),
child: Column(
children: [
SwitchListTile(
activeColor: ColorConsts.Primary,
value: Config.bgmEnabled,
title: Text("背景音乐", style: itemStyle),
onChanged: switchMusic,
),
_buildDivider(),
SwitchListTile(
activeColor: ColorConsts.Primary,
value: Config.toneEnabled,
title: Text("提示音效", style: itemStyle),
onChanged: switchTone,
),
],
),
),
const SizedBox(height: 16),
Text("排行榜", style: headerStyle),
Card(
color: ColorConsts.BoardBackground,
margin: const EdgeInsets.symmetric(vertical: 10),
child: Column(
children: [
ListTile(
title: Text("棋手姓名", style: itemStyle),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
Text(Player.shared.name),
Icon(Icons.keyboard_arrow_right, color: ColorConsts.Secondary),
]),
onTap: changeName,
),
],
),
),
const SizedBox(height: 16),
Text("关于", style: headerStyle),
Card(
color: ColorConsts.BoardBackground,
margin: const EdgeInsets.symmetric(vertical: 10),
child: Column(
children: [
ListTile(
title: Text("关于「棋路」", style: itemStyle),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
Text(_version ?? ''),
Icon(Icons.keyboard_arrow_right, color: ColorConsts.Secondary),
]),
onTap: showAbout,
),
],
),
),
const SizedBox(height: 60.0),
],
),
),
);
}
Container _buildDivider() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
width: double.infinity,
height: 1.0,
color: ColorConsts.LightLine,
);
}
}
页面中可以修改排行榜上的玩家名称,因此我们还需要引入一个专门用来编辑玩家名称的页面,我们在 lib/routes 文件夹下新建一个 edit-page.dart 文件:
import 'package:flutter/material.dart';
import '../common/color-consts.dart';
class EditPage extends StatefulWidget {
//
final String title, initValue;
EditPage(this.title, {this.initValue});
@override
_EditPageState createState() => _EditPageState();
}
class _EditPageState extends State {
//
TextEditingController _textController;
FocusNode _commentFocus = FocusNode();
onSubmit(String input) {
Navigator.of(context).pop(input);
}
@override
void initState() {
//
_textController = TextEditingController();
_textController.text = widget.initValue;
Future.delayed(
Duration(milliseconds: 10),
() => FocusScope.of(context).requestFocus(_commentFocus),
);
super.initState();
}
@override
Widget build(BuildContext context) {
//
final inputBorder = OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
borderSide: BorderSide(color: ColorConsts.Secondary),
);
return Scaffold(
appBar: AppBar(
title: Text(widget.title, style: TextStyle(fontFamily: '')),
actions: [
FlatButton(
child: Text('确定',
style: TextStyle(fontFamily: '', color: Colors.white)),
onPressed: () => onSubmit(_textController.text),
)
],
),
backgroundColor: ColorConsts.LightBackground,
body: Container(
margin: EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _textController,
decoration: InputDecoration(
contentPadding:
EdgeInsets.symmetric(vertical: 0, horizontal: 16),
enabledBorder: inputBorder,
focusedBorder: inputBorder,
),
style: TextStyle(
color: ColorConsts.Primary, fontSize: 16, fontFamily: ''),
onSubmitted: (input) => onSubmit(input),
focusNode: _commentFocus,
),
],
),
),
);
}
@override
void deactivate() {
FocusScope.of(context).requestFocus(FocusNode());
super.deactivate();
}
}
这样,一个美观的设置页就完成了!
获取版本信息和链接
这个页还基本功能是没有问题,但还有两个可以优化的点:
- 首先,应用版本应该自动从程序的打包信息中获取,而不应该只是写一个固定的值,避免以后每次发版都要修改设置页里面的版本信息;
- 其次,关于而成放置了我们的 QQ 群和访问网址,用户虽然能看到,但不能点击访问,我们可以试着优化玩家访问 QQ 群和网址的体验。
为了能从包中获取应用版本,我们引入一个插件 package_info。
我们在 pubspec.yaml 中引入我们需要的包,在 vscode 中保存这个文件将自动触发 pub get 指令:
...
dependencies:
flutter:
sdk: flutter
...
package_info: ^0.4.0+16
...
然后我们回到 _SettingPageState 类,我们我覆盖 initState 方法,并在其中使用代码获取应用的版本信息,并将获取到的版本信息存到 _version 变量,然后使用 setState 方法通知 State 重新构造:
class _SettingsPageState extends State {
//
String _version;
@override
void initState() {
super.initState();
loadVersionInfo();
}
loadVersionInfo() async {
//
final packageInfo = await PackageInfo.fromPlatform();
setState(() {
_version = 'Version ${packageInfo.version} (${packageInfo.buildNumber})';
});
}
...
}
我们再来处理网址和群号点击后的如何访问的问题。
如果用户点击了 QQ 群号或网址,我们现在的处理方案是自动把群号或网址复制到剪贴板,并告诉用户已经复制群号或网址。在 _SettingPageState 类的 showAbout 方法中找到以下的代码:
class _SettingsPageState extends State {
//
...
showAbout() {
//
showDialog(
...,
Text('QQ群', style: TextStyle(fontFamily: '')),
Text("http://67220535", style: TextStyle(fontFamily: '')),
SizedBox(height: 15),
Text('官网', style: TextStyle(fontFamily: '')),
Text(
"https://www.apppk.cn/apps/chessroad.html",
style: TextStyle(fontFamily: ''),
),
...
);
}
...
}
我们使用 GestureDetector Widget 来包裹群号和网址,这样能检测到它们的点击事件。此外,为了让玩家觉得群号和链接可以点击,我们需要修改一下链接和群号的文字样式:
class _SettingsPageState extends State {
//
...
showAbout() {
//
showDialog(
...,
Text('QQ群', style: TextStyle(fontFamily: '')),
// 点击 QQ 群时,复制 QQ 群号到剪贴板
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: '67220535'));
Toast.toast(context, msg: '群号已复制!');
},
child: Text(
"http://67220535",
style: TextStyle(fontFamily: '', color: Colors.blue),
),
),
SizedBox(height: 15),
Text('官网', style: TextStyle(fontFamily: '')),
// 点击官网地址时,复制地址到剪贴板
GestureDetector(
onTap: () {
Clipboard.setData(
ClipboardData(text: 'https://www.apppk.cn/apps/chessroad.html'),
);
Toast.toast(context, msg: '网址已复制!');
},
child: Text(
"https://www.apppk.cn/apps/chessroad.html",
style: TextStyle(fontFamily: '', color: Colors.blue),
),
),
...
);
}
...
}
这样,玩家在关于对话框时点击群号或链接时,会弹出 Toast 告知群号或网址已复制到剪贴板上,他们可以方便在 QQ 中添加我们的玩家群,或是在浏览器中访问我们的网址了。
最后一步,我们在各个完整页面上的右上角(菜单页在左上角)的按钮中做关联。
我们全局搜索(在 vscode 中按 Cmd+Shift+F
)
一下 Icons.settings
,这是页面左上角或右上角的设置按钮。应该能搜索到两处,单机对战和挑战云主机用一相同的页面。
我们先在 MainMenu 页的 build 方法中找到以下代码:
child: IconButton(
icon: Icon(Icons.settings, color: ColorConsts.Primary),
onPressed: () {},
),
将它改成下边的样子:
child: IconButton(
icon: Icon(Icons.settings, color: ColorConsts.Primary),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (context) => SettingsPage()),
),
),
之后,我们在 _BattlePageState 的 createPageHeader 方法中,找到以下的代码:
IconButton(
icon: Icon(Icons.settings, color: ColorConsts.DarkTextPrimary),
onPressed: () {},
),
将它改成下边的样子:
IconButton(
icon: Icon(Icons.settings, color: ColorConsts.DarkTextPrimary),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (context) => SettingsPage()),
),
),
现在,如果你在一个安静的环境中想玩两盘象棋,去设置面关掉背景音乐和音效即可。
记得将代码提示到 git 仓库,游戏的开发工作靠一段落!
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git add .
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git commit -m '游戏设置-带bug'
[master 404a7a7] 游戏设置-带bug
8 files changed, 684 insertions(+), 2 deletions(-)
create mode 100644 lib/common/config.dart
create mode 100644 lib/common/profile.dart
create mode 100644 lib/routes/edit-page.dart
create mode 100644 lib/routes/setting-page.dart
create mode 100644 lib/services/player.dart
create mode 100644 lib/services/ranks.dart
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ sudo git push
[sudo] elapse 的密码:
Username for 'https://rocketgit.com': elapse
Password for 'https://[email protected]':
枚举对象: 21, 完成.
对象计数中: 100% (21/21), 完成.
使用 4 个线程进行压缩
压缩对象中: 100% (14/14), 完成.
写入对象中: 100% (14/14), 6.44 KiB | 6.44 MiB/s, 完成.
总共 14 (差异 4),复用 0 (差异 0)
remote: RocketGit: Info: == Welcome to RocketGit! ==
remote: RocketGit: Info: you are connecting from IP 2408:8256:686:a5d9:c8f6:779b:a786:ce9e by http(s).
remote: RocketGit: Info: date/time: 2020-09-07 11:50:22 (UTC), debug id 0024a5.
To https://rocketgit.com/user/elapse/chinese_chess
da1a94e..404a7a7 master -> master
elapse@elapse-PC:~/Language/Flutter/chinese_chess$
小节回顾
这一节没有复杂的内容,我们先是通过文件操作,实现了文件持久化的工具,用来保存用户的配备到本地文件,并且实时加载和启用这些配置信息。
然后,我们实现了一个美观的设置页面,在其中玩家可以调整对战的难度以及控制音乐和音效是否被启用。
最后我们实现了一个关于对话框,用于呈现我们的联系、交流信息,并使用三方插件来获取应用的版本信息、显示一个可点击的链接……