Flutter象棋:6.使用象棋云库

象棋游戏总是需要人和电脑之间进行对战的,毕竟象棋就是一种双向对抗游戏!一般来说,象棋人机对战需要实现一套人工智能的算法来为电脑侧的 NPC 提供着法选择。

中国象棋当然需要人机对战,人和手机 AI 对战的部分,我们将在后续章节中详细介绍!

但在这之前,我们来点狠货 — 让人来挑战我们的云端服器 — 象棋云库。相对于普通人类玩家,象棋云库是强大的,它几乎是一个不可战胜的对手!

会有些复杂的工作,也没什么好担心的,直面挑战吧!

本节概要

  • 象棋云库知识
  • 象棋局面的 FEN 表示
  • 引擎着法表示方式
  • 封装云库引擎
  • 实现与云库的人机对战

象棋云库

在人与云端主机的对战中,我们会用到象棋云库,它是是一个什么样的存在呢?

中国象棋云库(简称"云库")是一个基于分布式计算和存储的中国象棋数据库,包含开局库和残局库。

不同于传统的棋谱统计方式,云库使用软件对局面进行分析、拆解,克服了软件打分波动及由于搜索剪裁产生的象棋引擎盲区问题。

云库意在探索象棋开局知识的全新体系,通过学习对弈双方的着法并不断完善,目前已涵盖所有主流开局着法,并在实战中不断优化。

云库提供了一套 API 接口,用于查询某一局面下的优先着法,在开局阶段特别强大。

它提供了一套比较简单的 API,而我们将会用到只有其中用于局面查询的那么两三个接口!

用云库与人类玩家对战有利于锻炼玩家开局功底,快速提升棋力!

与云库对战的过程有几点需要大家先了解的信息:

  • 我们按照棋软常用的局面表示方式(FEN)将局面传递给云库服务器,服务器分析局面返回推荐的着法。
  • 云库收集的庞大开局库,如果我们当前的局面是云库熟悉的(不脱谱),那么服务器会瞬间返回推荐着法;
  • 如果我们的局面比较特殊(已脱谱),云库会返回无最佳着法推荐;
  • 对于云库没着法返回的情况,我们可以请求服务器对局面进行计算,云库服务器将使用知名象棋 AI 引擎 — 「旋风」引擎进行局面计算;
  • 云库在后端计算局面的应用着法需要一些时间,我们可以等有结果后去查询此局面的推荐着法;

要实现人与云库对战,这是一个复杂的问题,我们可以把这个复杂的问题进行抽象:

  • 对战需要两方,一方是云库引擎,一方是人类玩家
  • 两方对战需要一个战场
  • 对战需要一套规则

好的,暂时就这些了!我们先来实现对战的一方 — 云库引擎!

封装象棋云库引擎

我们建立 lib/engine 文件夹,然后在其下新建 chess-db.dart 文件,用这个类来代理与「象棋云库」的 http 通信工作:

import 'dart:convert';
		import 'dart:io';
		
		class ChessDB {
		  //
		  static const Host = 'www.chessdb.cn'; // 云库服务器
		  static const Path = '/chessdb.php'; // API 路径
		
		  // 向云库查询最佳着法
		  static Future query(String board) async {
		    //
		    Uri url = Uri(
		      scheme: 'http',
		      host: Host,
		      path: Path,
		      queryParameters: {
		        'action': 'queryall',
		        'learn': '1',
		        'showall': '1',
		        'board': board,
		      },
		    );
		
		    final httpClient = HttpClient();
		
		    try {
		      final request = await httpClient.getUrl(url);
		      final response = await request.close();
		      return await response.transform(utf8.decoder).join();
		      //
		    } catch (e) {
		      print('Error: $e');
		    } finally {
		      httpClient.close();
		    }
		
		    return null;
		  }
		
		  // 请求云库在后台计算指定局面的最佳着法
		  static Future requestComputeBackground(String board) async {
		    //
		    Uri url = Uri(
		      scheme: 'http',
		      host: Host,
		      path: Path,
		      queryParameters: {
		        'action': 'queue',
		        'board': board,
		      },
		    );
		
		    final httpClient = HttpClient();
		
		    try {
		      final request = await httpClient.getUrl(url);
		      final response = await request.close();
		      return await response.transform(utf8.decoder).join();
		      //
		    } catch (e) {
		      print('Error: $e');
		    } finally {
		      httpClient.close();
		    }
		
		    return null;
		  }
		}
		
		

其实 ChessDb 类就是按照「象棋云库」提供的 API 文档,将局面的着法查询和请求后台计算包装成了两个方法。

云库查询 API 需要一个表示局面的 board 参数,要求我们按棋软的常规方式 — FEN 标记当前的局面。

关于象棋局面的 FEN 表示方式,请参考这里

FEN 包含好几断内容,包括棋子分布、到谁走棋等,最后一段是「无吃子步数」和「总回合数」。要获取这两个数字,事情还得着落在 Phase 类身上,我们得在 Phase 的 move 方法被调用时,就记录一下无吃子步数和总回合数。

打开 Phase 类的实现文件,首先添加两个计数步数和回合数的变量:

// 无吃子步数、总回合数
		int halfMove = 0, fullMove = 0;
		

接着,我们修改 Phase 的 move 方法,在修改棋子布局前添加步数和回合数的计数,修改后的 move 方法:

...
		
		class Phase {
		
		  ...
		
		
		  bool move(int from, int to) {
		    //
		    if (!validateMove(from, to)) return false;
		
		    // 记录无吃子步数
		    if (_pieces[to] != 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 true;
		  }
		
		  ...
		}
		

然后,我们为 Phase 类添加 toFen 方法:

...
		
		class Phase {
		
		  ...
		
		  // 根据局面数据生成局面表示字符串(FEN)
		  String toFen() {
		    //
		    var fen = '';
		
		    for (var row = 0; row < 10; row++) {
		      //
		      var emptyCounter = 0;
		
		      for (var column = 0; column < 9; column++) {
		        //
		        final piece = pieceAt(row * 9 + column);
		
		        if (piece == Piece.Empty) {
		          //
		          emptyCounter++;
		          //
		        } else {
		          //
		          if (emptyCounter > 0) {
		            fen += emptyCounter.toString();
		            emptyCounter = 0;
		          }
		
		          fen += piece;
		        }
		      }
		
		      if (emptyCounter > 0) fen += emptyCounter.toString();
		
		      if (row < 9) fen += '/';
		    }
		
		    fen += ' $side';
		
		    // 王车易位和吃过路兵标志
		    fen += ' - - ';
		
		    // step counter
		    fen += '$halfMove $fullMove';
		
		    return fen;
		  }
		
		  ...
		}
		

好了,表示局面的 FEN 有了。

这会儿又有一个小问题出现了:引擎返回的招法用国际象棋 UCS 表示法,用 4 个字符表示一个着法,例如 b0c2 :

它的着法基于左下角坐标系,用 a ~ i 表示从左到右的 9 列,用 0 ~ 9 表示从下到上 10 行。因此 b0c2 表示从第 2 列第 1 行移动到第 3 列第 3 行

之前我们都是用 from … to 这样来表示一个着法,现以我们新建一个类 Move,由它来表示和解释、转换一个招法。

打开 cc-base.dart,我们在文件尾部添加一个 Move 类的定义:

...
		
		class Move {
		  //
		  static const InvalidIndex = -1;
		
		  // List(90) 中的索引
		  int from, to;
		
		  // 左上角为坐标原点
		  int fx, fy, tx, ty;
		
		  String captured;
		
		  // 'step' is the ucci engine's move-string
		  String step;
		
		  Move(this.from, this.to, {this.captured = Piece.Empty}) {
		    //
		    fx = from % 9;
		    fy = from ~/ 9;
		
		    tx = to % 9;
		    ty = to ~/ 9;
		
		    if (fx < 0 || fx > 8 || fy < 0 || fy > 9) {
		      throw "Error: Invlid Step (from:$from, to:$to)";
		    }
		
		    step = String.fromCharCode('a'.codeUnitAt(0) + fx) + (9 - fy).toString();
		    step += String.fromCharCode('a'.codeUnitAt(0) + tx) + (9 - ty).toString();
		  }
		
		  /// 引擎返回的招法用是 4 个字符表示的,例如 b0c2
		  /// 它的着法基于左下角坐标系
		  /// 用 a ~ i 表示从左到右的 9 列
		  /// 用 0 ~ 9 表示从下到上 10 行
		  /// 因此 b0c2 表示从第 2 列第 1 行移动到第 3 列第 3 行
		
		  Move.fromEngineStep(String step) {
		    //
		    this.step = step;
		
		    if (!validateEngineStep(step)) {
		      throw "Error: Invlid Step: $step";
		    }
		
		    fx = step[0].codeUnitAt(0) - 'a'.codeUnitAt(0);
		    fy = 9 - (step[1].codeUnitAt(0) - '0'.codeUnitAt(0));
		    tx = step[2].codeUnitAt(0) - 'a'.codeUnitAt(0);
		    ty = 9 - (step[3].codeUnitAt(0) - '0'.codeUnitAt(0));
		
		    from = fx + fy * 9;
		    to = tx + ty * 9;
		
		    captured = Piece.Empty;
		  }
		
		  // 验证引擎返回着法是否是合法的
		  static bool validateEngineStep(String step) {
		    //
		    if (step == null || step.length < 4) return false;
		
		    final fx = step[0].codeUnitAt(0) - 'a'.codeUnitAt(0);
		    final fy = 9 - (step[1].codeUnitAt(0) - '0'.codeUnitAt(0));
		    if (fx < 0 || fx > 8 || fy < 0 || fy > 9) return false;
		
		    final tx = step[2].codeUnitAt(0) - 'a'.codeUnitAt(0);
		    final ty = 9 - (step[3].codeUnitAt(0) - '0'.codeUnitAt(0));
		    if (tx < 0 || tx > 8 || ty < 0 || ty > 9) return false;
		
		    return true;
		  }
		}
		

发现一个小问题:在这之前,我们一直用 -1 来表示无效的棋盘上的索引值的,现在我们做个小重构,把代码中指示棋盘位置的所有 -1 都改成 Move.InvalidIndex,这样能减少魔术代码,提高代码的可读性。

使用 vscode 的全局查找(默认快捷键为 Cmd+Shift+F),在 PiecesPainter、Battle 和 BattlePage 类中都需要做替换,替换完成后别忘了导入 Move 所在的 cc-base.dar 文件。

接下来,我们基于 ChessDb 代理类来实现 CloudEngine 类,它是对云库引擎的封装类。我们在 lib/engine 文件夹下新建 cloud-engine.dart 文件:

import '../cchess/cc-base.dart';
		import '../cchess/phase.dart';
		import 'chess-db.dart';
		
		/// 引擎查询结果包裹
		/// type 为 move 时表示正常结果反馈,value 用于携带结果值
		/// type 其它可能值至少包含:timeout / nobestmove / network-error / data-error
		class EngineResponse {
		  final String type;
		  final dynamic value;
		  EngineResponse(this.type, {this.value});
		}
		
		class CloudEngine {
		  /// 向云库查询某一个局面的最结着法
		  /// 如果一个局面云库没有遇到过,则请求云库后台计算,并等待云库的计算结果
		  Future search(Phase phase, {bool byUser = true}) async {
		    //
		    final fen = phase.toFen();
		    var response = await ChessDB.query(fen);
		
		    // 发生网络错误,直接返回
		    if (response == null) return EngineResponse('network-error');
		
		    if (!response.startsWith('move:')) {
		      //
		      print('ChessDB.query: $response\n');
		      //
		    } else {
		      // 有着法列表返回
		      // move:b2a2,score:-236,rank:0,note:? (00-00),winrate:32.85
		      final firstStep = response.split('|')[0];
		      print('ChessDB.query: $firstStep');
		
		      final segments = firstStep.split(',');
		      if (segments.length < 2) return EngineResponse('data-error');
		
		      final move = segments[0], score = segments[1];
		
		      final scoreSegments = score.split(':');
		      if (scoreSegments.length < 2) return EngineResponse('data-error');
		
		      final moveWithScore = int.tryParse(scoreSegments[1]) != null;
		
		      // 存在有效着法
		      if (moveWithScore) {
		        //
		        final step = move.substring(5);
		
		        if (Move.validateEngineStep(step)) {
		          return EngineResponse(
		            'move',
		            value: Move.fromEngineStep(step),
		          );
		        }
		      } else {
		        // 云库没有遇到过这个局面,请求它执行后台计算
		        if (byUser) {
		          response = await ChessDB.requestComputeBackground(fen);
		          print('ChessDB.requestComputeBackground: $response\n');
		        }
		
		        // 这里每过2秒就查看它的计算结果
		        return Future.delayed(
		          Duration(seconds: 2),
		          () => search(phase, byUser: false),
		        );
		      }
		    }
		
		    return EngineResponse('unknown-error');
		  }
		}
		

在 cloud-engine.dart 文件中,我们先定义了一个简单对象 EngineResponse,用于向引擎使用者提供友好的返回值。接着我们实现了 CloudEngine 类,目前它只有一个有效的方法 — search。

在 search 方法中,我们使用 ChessDb 代理类访问网络,获取云库服务器地当前棋局的着法反馈。

如果服务器返回了以「move」打头的文字,表示「云库」有着法返回给我们。

如果一个着法携带的「score」值是一个数值,我们认为这是一个有效着法,会包装成 EngineResponse 反馈给引擎的调用者。

如果一个着法携带的「score」非数值,则这个着法可能是小白用户提交的学习局面,证明云库对此局面没有预先进行过计算。我们应该被忽略小白招法,请求服务器在后台对此局面使用旋风引擎进行计算,然后我们每过 2 秒种来查询一次引擎的计算结果是否就绪。

引擎还会遇到超时、出错等情况,我们统一出错处理就行了。

引擎这边就绪了!现在我们来使用引擎,让它与人对战!

使用云库引擎

为了展示对战后台的状态(例如引擎的工作状态),我们在 _BattlePageState 类中添加一个 status 字段,并添加修改此状态的方法:

class _BattlePageState extends State {
		  //
		  String _status = '';
		
		  ...
		
		  changeStatus(String status) => setState(() => _status = status);
		
		  ...
		}
		

这个状态将显示在 BattlePage 的标题之下,目前放了一个固定的字符串 '[游戏状态]' 的位置。

我们现在要呈现 _status 变量,在 _BattlePageState 类的 createPageHeader 方法中找到以下的代码:

child: Text('[游戏状态]', maxLines: 1, style: subTitleStyle),
		

将它修改成下边的样子:

child: Text(_status, maxLines: 1, style: subTitleStyle),
		

这样,当 _status 被修改后调用 setState,游戏的工作状态将显示在标题之下。

象棋对战过程中,两方交替行棋的,直至游戏分出胜负结果。意思是说,没有没有分出胜负之前,游戏双方才交替走棋。为此,我们在 cc-base.dart 文件中添加一个代表对战结果的枚举类型 BattleResult:

/// 对战结果:未决、赢、输、和
		enum BattleResult { Pending, Win, Lose, Draw }
		

在 Battle 类中,我们添加一个 scanBattleResult 方法:

BattleResult scanBattleResult() {
		  // TODO:
		  return BattleResult.Pending;
		}
		

这个方法现在先简单地返回对战结果为「未决」。

真正的象棋对战结果扫描是一件大工程,我们后续文章中再详情说明。

为了驱动引擎走棋,我们在 _BattlePageState 中添加 engineToGo 方法:

  engineToGo() async {
		    //
		    changeStatus('对方思考中...');
		
		    final response = await CloudEngine().search(Battle.shared.phase);
		
		    // 引擎返回'move'表示有最佳着法可用
		    if (response.type == 'move') {
		      //
		      final step = response.value;
		      Battle.shared.move(step.from, step.to);
		
		      final result = Battle.shared.scanBattleResult();
		
		      switch (result) {
		        case BattleResult.Pending:
		          changeStatus('请走棋...');
		          break;
		        case BattleResult.Win:
		          // todo:
		          break;
		        case BattleResult.Lose:
		          // todo:
		          break;
		        case BattleResult.Draw:
		          // todo:
		          break;
		      }
		      //
		    } else {
		      //
		      changeStatus('Error: ${response.type}');
		    }
		  }
		

我们修改 _BattlePageState 的 onBoardTap 方法驱动引擎思考,先找到以下代码:

if (Battle.shared.move(Battle.shared.focusIndex, index)) {
		  // todo: scan result
		}
		

将它修改为下这的样子:

if (Battle.shared.move(Battle.shared.focusIndex, index)) {
		  //
		  final result = Battle.shared.scanBattleResult();
		
		  switch (result) {
		    case BattleResult.Pending:
		      // 玩家走一步棋后,如果游戏还没有结束,则启动引擎走棋
		      engineToGo();
		      break;
		    case BattleResult.Win:
		      break;
		    case BattleResult.Lose:
		      break;
		    case BattleResult.Draw:
		      break;
		  }
		}
		

这到里,可以感受一下我们的开发成果了!在 vscode 中按 F5 运行产品,试试走几步棋,领略一下云库引擎的高明招法:

与云端主机对战

你已经实现了能人机对战的象棋游戏,而且此游戏的棋力可以秒杀绝大多数的人类棋手!

但我们知道,目前还有很多的细节有待完善,比如,你可以拿着棋子乱走一通,完全不管什么规则……

将代码提交到 git 仓库,这一节的任务到此完成!


		elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git add .
		elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git commit -m '使用象棋云库'
		[master 18c7e5e] 使用象棋云库
		 6 files changed, 315 insertions(+), 4 deletions(-)
		 create mode 100644 lib/engine/chess-db.dart
		 create mode 100644 lib/engine/cloud-engine.dart
		elapse@elapse-PC:~/Language/Flutter/chinese_chess$ sudo git push
		[sudo] elapse 的密码: 
		Username for 'https://rocketgit.com': elapse
		Password for 'https://[email protected]': 
		枚举对象: 22, 完成.
		对象计数中: 100% (22/22), 完成.
		使用 4 个线程进行压缩
		压缩对象中: 100% (12/12), 完成.
		写入对象中: 100% (13/13), 5.51 KiB | 2.75 MiB/s, 完成.
		总共 13 (差异 4),复用 0 (差异 0)
		remote: RocketGit: Info: == Welcome to RocketGit! ==
		remote: RocketGit: Info: you are connecting from IP 27.47.4.14 by http(s).
		remote: RocketGit: Info: date/time: 2020-08-28 01:49:13 (UTC), debug id 81af08.
		To https://rocketgit.com/user/elapse/chinese_chess
		   d55e6ca..18c7e5e  master -> master
		elapse@elapse-PC:~/Language/Flutter/chinese_chess$
		
		

小节回顾

本小节首先介绍了象棋云库,以及云库提供的 API 接口。为了使用云库 API,我们接着在 Phase 类中实现了将局面转换为 FEN 局面表示字符串的方法。为了能理解云库返回的着法,我们实现了对引擎着法 ICCS 的解析。

完成这些工作后,我们封装了云库提供的部分 API 接口,涉及到 Dart 的 html 类的一些基本使用。

最后,也是最复杂的一个过程,我们实现了对战局结果的表示,并在玩家移动棋子后调用云库引擎与玩家交替走子。这样就具体了人机对战的交替行棋的基本特征。

到此,我们有一个可以对战的象棋游戏了!