Flutter象棋:12.游戏设置

从游戏的完整性角度来说,我们还有一些工作要做。例如人机对战的难度,我们可以进行调节;背景音乐要不要自动播放,我们也需要设置;如果有人喜欢我们的游戏,想联系我们,我们应该有个关于页面让别人能找到我们。因此,我们需要给游戏添加一个设置页。

本节概要

  • 实现本地配置的持久化工具
  • 实现设置页面
  • 在关于页面中获取版本信息和链接

实现保存配置的工具类

为了能让玩家的设置被保留起来,我们先在 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$ 
		
		

小节回顾

这一节没有复杂的内容,我们先是通过文件操作,实现了文件持久化的工具,用来保存用户的配备到本地文件,并且实时加载和启用这些配置信息。

然后,我们实现了一个美观的设置页面,在其中玩家可以调整对战的难度以及控制音乐和音效是否被启用。

最后我们实现了一个关于对话框,用于呈现我们的联系、交流信息,并使用三方插件来获取应用的版本信息、显示一个可点击的链接……