象棋游戏总是需要人和电脑之间进行对战的,毕竟象棋就是一种双向对抗游戏!一般来说,象棋人机对战需要实现一套人工智能的算法来为电脑侧的 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 类的一些基本使用。
最后,也是最复杂的一个过程,我们实现了对战局结果的表示,并在玩家移动棋子后调用云库引擎与玩家交替走子。这样就具体了人机对战的交替行棋的基本特征。
到此,我们有一个可以对战的象棋游戏了!