Flutter象棋:11.云库局面分析

局面分析是一个让你可以「超神」的能力!

一方面,有了局面分析功能,你下棋过程中像是有一个云服务器始终在给你支招。这很利于学棋者提升自己对局面的理解;

另一方面,对于开局的某一个局面,可能让云端分析告诉你目前走哪了步比较好,主要的几种可靠招法的效果得多少分。

本节概要

  • 添加 Flutter 版的 Toast 组件
  • 翻译中文行棋着法
  • 调用云库 API 分析局面
  • 呈现局面分析结果

因为有「象棋云库」的加持,实现局面分析并不是很困难!我们使用前边云库人机对战的那一系列 API 来请求云库对局面进行分析,推荐和评估可行的着法。

在开始局面分析之前,我们先做一点热身运行吧!

添加 Toast 工具类

在分析过程中,我们希望显示一些轻量的提示消息给用户,我们添加一个 Toast 工具类,在文件夹“common”中新建toast.dart文件,如下内容:

    import 'package:flutter/cupertino.dart';
		import 'package:flutter/material.dart';
		
		// Toast 显示位置控制
		enum ToastPostion { top, center, bottom }
		
		class Toast {
		  // toast靠它加到屏幕上
		  static OverlayEntry _overlayEntry;
		  // toast是否正在showing
		  static bool _showing = false;
		  // 开启一个新toast的当前时间,用于对比是否已经展示了足够时间
		  static DateTime _startedTime;
		  // 提示内容
		  static String _msg;
		  // toast显示时间
		  static int _showTime;
		  // 背景颜色
		  static Color _bgColor;
		  // 文本颜色
		  static Color _textColor;
		  // 文字大小
		  static double _textSize;
		  // 显示位置
		  static ToastPostion _toastPosition;
		  // 左右边距
		  static double _pdHorizontal;
		  // 上下边距
		  static double _pdVertical;
		
		  static void toast(
		    BuildContext context, {
		    // 显示的文本
		    String msg,
		    // 显示的时间 单位毫秒
		    int showTime = 1000,
		    // 显示的背景
		    Color bgColor = Colors.black,
		    // 显示的文本颜色
		    Color textColor = Colors.white,
		    // 显示的文字大小
		    double textSize = 16.0,
		    // 显示的位置
		    ToastPostion position = ToastPostion.center,
		    // 文字水平方向的内边距
		    double pdHorizontal = 20.0,
		    // 文字垂直方向的内边距
		    double pdVertical = 10.0,
		  }) async {
		    assert(msg != null);
		
		    _msg = msg;
		    _startedTime = DateTime.now();
		    _showTime = showTime;
		    _bgColor = bgColor;
		    _textColor = textColor;
		    _textSize = textSize;
		    _toastPosition = position;
		    _pdHorizontal = pdHorizontal;
		    _pdVertical = pdVertical;
		
		    // 获取OverlayState
		    OverlayState overlayState = Overlay.of(context);
		
		    _showing = true;
		
		    if (_overlayEntry == null) {
		      //
		      // OverlayEntry负责构建布局
		      // 通过OverlayEntry将构建的布局插入到整个布局的最上层
		      _overlayEntry = OverlayEntry(
		        builder: (BuildContext context) => Positioned(
		          // top值,可以改变这个值来改变toast在屏幕中的位置
		          top: buildToastPosition(context),
		          child: Container(
		              alignment: Alignment.center,
		              width: MediaQuery.of(context).size.width,
		              child: Padding(
		                padding: EdgeInsets.symmetric(horizontal: 40.0),
		                child: AnimatedOpacity(
		                  opacity: _showing ? 1.0 : 0.0, // 目标透明度
		                  duration: _showing ? 
		                      Duration(milliseconds: 100) : Duration(milliseconds: 400),
		                  child: _buildToastWidget(),
		                ),
		              )),
		        ),
		      );
		
		      // 插入到整个布局的最上层
		      overlayState.insert(_overlayEntry);
		      //
		    } else {
		      // 重新绘制UI,类似setState
		      _overlayEntry.markNeedsBuild();
		    }
		
		    // 等待时间
		    await Future.delayed(Duration(milliseconds: _showTime));
		
		    // 2 秒后 到底消失不消失
		    if (DateTime.now().difference(_startedTime).inMilliseconds >= _showTime) {
		      _showing = false;
		      _overlayEntry.markNeedsBuild();
		      await Future.delayed(Duration(milliseconds: 400));
		      _overlayEntry.remove();
		      _overlayEntry = null;
		    }
		  }
		
		  // toast 绘制
		  static _buildToastWidget() {
		    return Center(
		      child: Card(
		        color: _bgColor,
		        child: Padding(
		          padding: EdgeInsets.symmetric(horizontal: _pdHorizontal, vertical: _pdVertical),
		          child: Text(_msg, style: TextStyle(fontSize: _textSize, color: _textColor)),
		        ),
		      ),
		    );
		  }
		
		// 设置toast位置
		  static buildToastPosition(context) {
		    //
		    var backResult;
		
		    if (_toastPosition == ToastPostion.top) {
		      backResult = MediaQuery.of(context).size.height * 1 / 4;
		    } else if (_toastPosition == ToastPostion.center) {
		      backResult = MediaQuery.of(context).size.height * 2 / 5;
		    } else {
		      backResult = MediaQuery.of(context).size.height * 3 / 4;
		    }
		
		    return backResult;
		  }
		}

调用云库接口分析局面

我们在 lib/engine 文件夹下新建 analysis.dart 文件,创建两个解析 chess-db 着法响应的工具类:

class AnalysisItem {
		  // 着法,着法中文描述
		  String move, stepName;
		  // 此着法应用后的局面分值
		  int score;
		  // 使用此着法后的棋局胜率估算
		  double winrate;
		
		  AnalysisItem({this.move, this.score, this.winrate});
		
		  @override
		  String toString() {
		    return '{move: ${stepName ?? move}, score: $score, winrate: $winrate}';
		  }
		}
		
		// 解析云库的分析结果
		class AnalysisFetcher {
		  // 默认解析前5种着法
		  static List fetch(String response, {limit = 5}) {
		    //
		    final segments = response.split('|');
		
		    List result = [];
		
		    final regx = RegExp(r'move:(.{4}).+score:(\-?\d+).+winrate:(\d+.?\d*)');
		
		    for (var segment in segments) {
		      //
		      final match = regx.firstMatch(segment);
		
		      if (match == null) break;
		
		      final move = match.group(1);
		      final score = int.parse(match.group(2));
		      final winrate = double.parse(match.group(3));
		
		      result.add(AnalysisItem(move: move, score: score, winrate: winrate));
		      if (result.length == limit) break;
		    }
		
		    return result;
		  }
		}
		

在 CloudEngine 类中,我们添加一个 analysis 方法来封装 chess-db 着法查询 API:

// 给云库引擎添加分析方法,之后会调用前述的分析结果解析工具
		static Future analysis(Phase phase) async {
		  //
		  final fen = phase.toFen();
		  var response = await ChessDB.query(fen);
		
		  if (response == null) return EngineResponse('network-error');
		
		  if (response.startsWith('move:')) {
		    final items = AnalysisFetcher.fetch(response);
		    if (items.isEmpty) return EngineResponse('no-result');
		    return EngineResponse('analysis', value: items);
		  }
		
		  print('ChessDB.query: $response\n');
		  return EngineResponse('unknown-error');
		}
		

云库的 API 包装就这么简单地搞定了!

呈现分析结果

我们进入 _BattlePageState 类,先声明一个 bool 型的成员变量,它是为了预防上一个分析结果还没有返回,用户又请求下一次分析的:

class _BattlePageState extends State {
		  //
		  ...
		  bool _analysising = false;
		
		  ...
		}
		

接着,我们在 _BattlePageState 类中添加两个方法,用于调用我们刚才实现于 CloudEngine 中的分析方法,并呈现给用户:

analysisPhase() async {
		    //
		    Toast.toast(context, msg: '正在分析局面...', position: ToastPostion.bottom);
		
		    setState(() => _analysising = true);
		
		    try {
		      final result = await CloudEngine.analysis(Battle.shared.phase);
		
		      // 云库反回了正确的分析结果
		      if (result.type == 'analysis') {
		        //
		        List items = result.value;
		        items.forEach(
		          (item) => item.stepName = StepName.translate(
		            Battle.shared.phase,
		            Move.fromEngineStep(item.move),
		          ),
		        );
		        showAnalysisItems(
		          context,
		          title: '推荐招法',
		          items: result.value,
		          callback: (index) => Navigator.of(context).pop(),
		        );
		      } else if (result.type == 'no-result') {
		        // 云库表示无分析结论
		        // 我们提交服务器后台进行计算,玩家可以过一会再点击分析来查看分析结论
		        Toast.toast(
		          context,
		          msg: '已请求服务器计算,请稍后查看!',
		          position: ToastPostion.bottom,
		        );
		      } else {
		        Toast.toast(
		          context,
		          msg: '错误: ${result.type}',
		          position: ToastPostion.bottom,
		        );
		      }
		    } catch (e) {
		      Toast.toast(context, msg: '错误: $e', position: ToastPostion.bottom);
		    } finally {
		      setState(() => _analysising = false);
		    }
		  }
		
		    // 显示分析结论
		  showAnalysisItems(
		    BuildContext context, {
		    String title,
		    List items,
		    Function(AnalysisItem item) callback,
		  }) {
		    //
		    final List children = [];
		
		    for (var item in items) {
		      children.add(
		        ListTile(
		          title: Text(item.stepName, style: TextStyle(fontSize: 18)),
		          subtitle: Text('胜率:${item.winrate}%'),
		          trailing: Text('分数:${item.score}'),
		          onTap: () => callback(item),
		        ),
		      );
		      children.add(Divider());
		    }
		
		    children.insert(0, SizedBox(height: 10));
		    children.add(SizedBox(height: 56));
		
		    showModalBottomSheet(
		      context: context,
		      builder: (BuildContext context) => SingleChildScrollView(
		        child: Column(mainAxisSize: MainAxisSize.min, children: children),
		      ),
		    );
		  }
		

最后,在 createOperatorBar 方法中找到以下代码:

FlatButton(child: Text('分析局面', style: buttonStyle), onPressed: () {}),
		

将它修改成下边的样子:

FlatButton(
		  child: Text('分析局面', style: buttonStyle),
		  onPressed: _analysising ? null : analysisPhase,
		),
		

现在运行产品试试:轮到你走棋的时候,你可以点击「分析局面」,然后会从屏幕底部弹出一个弹框,提示有哪些推荐的着法,信息还包括应用指定着法后的局面分值和胜率!

有了云库的局面分析做后盾,杀个特大也不特别难样~~

记得将代码提交到 git 仓库,多玩玩我们的产品,找到你自己的体验提升点!


		elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git add .
		elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git commit -m '云库局面分析-带bug'
		[master da1a94e] 云库局面分析-带bug
		 6 files changed, 303 insertions(+), 3 deletions(-)
		 create mode 100644 lib/common/toast.dart
		 create mode 100644 lib/engine/analysis.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% (12/12), 完成.
		写入对象中: 100% (12/12), 4.39 KiB | 2.20 MiB/s, 完成.
		总共 12 (差异 6),复用 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 10:34:17 (UTC), debug id acaa59.
		To https://rocketgit.com/user/elapse/chinese_chess
		   c574648..da1a94e  master -> master
		elapse@elapse-PC:~/Language/Flutter/chinese_chess$ 
		
		

小节回顾

本小节中,我们实现了局面分析的功能,这个功能使用云库的 API 来给当前局面查找最优的后续着法,并对每个可能着法进行评分。

为了向玩家展示着法,我们首先实现了对着法的中文化翻译,这部分功能也在记谱中被使用。

接着,我们使用与云库引擎类似的方式来封装了云库的 API 接口,通过将局面 FEN 传给云库来获取云库对局面的着法推荐和着法评估。

最后,我们把云库返回的着法以中文化的着法列表方式显示给玩家,供玩家理解和学习局面。