在对战过程,玩家经常需要悔棋功能,这种让时间倒流的技能不会太简单的,这一节的任务就是让我们的象棋游戏支持悔棋!
本节概要
- 重构 Phase 类,抽取记谱功能
- 依据棋谱记录添加悔棋逻辑
- 显示对战着法的棋谱
Phase 重构
目前,在我们的 Phase 类中已经存在一些记录之前走棋过程的能力,我们将重构这一部分的设计,在一个专门的记录工具类中实现更完善的行棋记录功能,以为悔棋的实现作好准备。
我们在 lib/cchess 下新建一个 cc-recorder.dart 文件,计划由它来记录走棋的整个过程:
class CCRecorder {
//
// 无吃子步数、总回合数
int halfMove, fullMove;
String lastCapturedPhase;
final _history = [];
CCRecorder({this.halfMove = 0, this.fullMove = 0, this.lastCapturedPhase});
}
大家可以发现这个类中的三个成员变量都在 Phase 类中出现过了,我们现在这里创建 Recorder 工具,稍后我们从 Phase 中将这部分功能转换到新建的 Recorder 中来。
接下来,我们为 CCRecorder 类添加几个方法,用于记录新的行其步骤以及悔棋时,删除掉尾部添加的着法:
void stepIn(Move move, Phase phase) {
//
if (move.captured != Piece.Empty) {
halfMove = 0;
} else {
halfMove++;
}
if (fullMove == 0) {
fullMove++;
} else if (phase.side != Side.Black) {
fullMove++;
}
_history.add(move);
if (move.captured != Piece.Empty) {
lastCapturedPhase = phase.toFen();
}
}
Move removeLast() {
if (_history.isEmpty) return null;
return _history.removeLast();
}
get last => _history.isEmpty ? null : _history.last;
「悔棋」需要从当前局面恢复到之前的局面,我们需要一个方法取出自上一次吃子以来的着法列表,并反向应用到当前棋盘局面上。这个方法用于在悔棋时,基于当前局面一步一步地「还原」走这一步之前的棋盘局面。
继续在 CCRecorder 类中添加方法 reverseMovesToPrevCapture:
// 自上一个咋子局面后的着法列表,反向存放在返回列表中
List reverseMovesToPrevCapture() {
//
List moves = [];
for (var i = _history.length - 1; i >= 0; i--) {
if (_history[i].captured != Piece.Empty) break;
moves.add(_history[i]);
}
return moves;
}
FEN 串的最后两上数字表示了「无吃步数」和「全局回合数」。这两个数字跟着行棋过程一直变化,悔棋时也要一步一步地进行恢复。为此我们要改造一下 Move 类,在其中增加一个成员变量,并在构造方法中体现对这个成员变量的传值初始化:
class Move {
...
// 这一步走完后的 FEN 记数,用于悔棋时恢复 FEN 步数 Counter
String counterMarks;
Move(this.from, this.to, {
this.captured = Piece.Empty, this.counterMarks = '0 0'}) {
...
}
...
}
回头我们还要在 CCRecorder 添加一个命名构造方法,并覆盖 toString 方法,前者从 counterMarks 里面解析出无吃子步数和回合数,另一个将出无吃子步数和回合数转换成一个 counterMarks 字符串:
class CCRecorder
...
// 从 FEN 尾部两个字段解析无吃子步数和总回合数
CCRecorder.fromCounterMarks(String marks) {
//
var segments = marks.split(' ');
if (segments.length != 2) {
throw 'Error: Invalid Counter Marks: $marks';
}
halfMove = int.parse(segments[0]);
fullMove = int.parse(segments[1]);
if (halfMove == null || fullMove == null) {
throw 'Error: Invalid Counter Marks: $marks';
}
}
...
@override
String toString() {
return '$halfMove $fullMove';
}
}
现在 Recorder 类准备好了,我们需要在 Phase 类中将 Recorder 使用起来。
先将 Phase 类中的以下三个成员变量的定义删除,交添加 Recorder 类的实例,并添加几个基于 Recorder 的 getter。修改前:
// 无吃子步数、总回合数
int halfMove = 0, fullMove = 0;
// 最近一个吃子局面
String lastCapturedPhase;
final _history = [];
...
修改后:
CCRecordder _recorder;
...
get halfMove => _recorder.halfMove;
get fullMove => _recorder.fullMove;
get lastMove => _recorder.last;
get lastCapturedPhase => _recorder.lastCapturedPhase;
修改 Phase.clone 构造方法的尾部两行代码,修改前:
Phase.clone(Phase other) {
...
halfMove = other.halfMove;
fullMove = other.fullMove;
}
修改后:
Phase.clone(Phase other) {
...
_recorder = other._recorder;
}
修改 Phase 类的 initDefaultPhase 方法尾部代码,修改前:
initDefaultPhase() {
...
lastCapturedPhase = toFen();
halfMove = fullMove = 0;
_history.clear();
}
修改后:
initDefaultPhase() {
...
_recorder = CCRecorder(lastCapturedPhase: toFen());
}
将 Phase 类的 move 方法修改成下边的样子:
String move(int from, int to) {
//
if (!validateMove(from, to)) return null;
final captured = _pieces[to];
// 修改棋盘
_pieces[to] = _pieces[from];
_pieces[from] = Piece.Empty;
_recorder.stepIn(Move(from, to, captured: captured), this);
// 交换走棋方
_side = Side.oppo(_side);
return captured;
}
修改 Phase 类的 toFen 方法的尾部,修改前:
String toFen() {
//
...
// step counter
fen += '$halfMove $fullMove';
return fen;
}
修改后:
String toFen() {
//
...
// step counter
fen += '${_recorder?.halfMove ?? 0} ${_recorder?.fullMove ?? 0}';
return fen;
}
为了保护好 Recorder 的内部结构,我们不直接暴露 Recorder 内部的成员变量,仅在 CCRecorder 类中添加两个数据获取方法:
class CCRecorder {
...
stepAt(int index) => _history[index];
get stepsCount => _history.length;
}
最后在 Phase 中添加 movesSinceLastCaptured 方法如下:
String movesSinceLastCaptured() {
//
var steps = '', posAfterLastCaptured = 0;
for (var i = _recorder.stepsCount - 1; i >= 0; i--) {
if (_recorder.stepAt(i).captured != Piece.Empty) break;
posAfterLastCaptured = i;
}
for (var i = posAfterLastCaptured; i < _recorder.stepsCount; i++) {
steps += ' ${_recorder.stepAt(i).step}';
}
return steps.length > 0 ? steps.substring(1) : '';
}
添加悔棋逻辑
重构完成了,必要的工具方法也准备就绪,我们在 Phase 类中添加悔棋方法:
bool regret() {
// 首先撤销最后一条行棋记录
final lastMove = _recorder.removeLast();
if (lastMove == null) return false;
// 还原到最后一步行棋之前的局面
_pieces[lastMove.from] = _pieces[lastMove.to];
_pieces[lastMove.to] = lastMove.captured;
// 回调行棋方
_side = Side.oppo(_side);
// 还原着法计数器
final counterMarks = CCRecorder.fromCounterMarks(lastMove.counterMarks);
_recorder.halfMove = counterMarks.halfMove;
_recorder.fullMove = counterMarks.fullMove;
/// 更新最近一个咋子着法
/// 这儿有点逻辑,因为引擎理解局面需要传递上一次的吃子局面,以及此后的无咋子着法列表
/// 所以如果刚撤销的着法是吃子着法,我们就需要再向前找上一个吃子着法
if (lastMove.captured != Piece.Empty) {
//
// 查找上一个吃子局面(或开局),NativeEngine 需要
final tempPhase = Phase.clone(this);
final moves = _recorder.reverseMovesToPrevCapture();
moves.forEach((move) {
//
tempPhase._pieces[move.from] = tempPhase._pieces[move.to];
tempPhase._pieces[move.to] = move.captured;
tempPhase._side = Side.oppo(tempPhase._side);
});
_recorder.lastCapturedPhase = tempPhase.toFen();
}
// 将游戏结果重新设置为未决
// 例如引擎已经将你杀败,你点击悔棋后,需要将游戏结果从失败变回未决
result = BattleResult.Pending;
return true;
}
前边所做的事情,都是为「悔棋」这个功能做的铺垫,现在是正主登场的时刻了!
在 Battle 类中,添加一个 regret 方法,这个方法带一个 steps 参数,表示棋盘上要回退的步数,一般来说悔棋要先撤销对方走的棋和自己上一步着法,所以默认一次回退两步:
bool regret({steps = 2}) {
//
// 轮到自己走棋的时候,才能悔棋
if (_phase.side != Side.Red) {
Audios.playTone('invalid.mp3');
return false;
}
var regreted = false;
/// 悔棋一回合(两步),才能撤回自己上一次的动棋
for (var i = 0; i < steps; i++) {
//
if (!_phase.regret()) break;
final lastMove = _phase.lastMove;
if (lastMove != null) {
//
_blurIndex = lastMove.from;
_focusIndex = lastMove.to;
//
} else {
//
_blurIndex = _focusIndex = Move.InvalidIndex;
}
regreted = true;
}
if (regreted) {
Audios.playTone('regret.mp3');
return true;
}
Audios.playTone('invalid.mp3');
return false;
}
的 createOperatorBar 方法中,找到以下代码:
FlatButton(child: Text('悔棋', style: buttonStyle), onPressed: () {}),
修改它,在 onPressed 上调用悔棋方法:
FlatButton(
child: Text('悔棋', style: buttonStyle),
onPressed: () {
Battle.shared.regret(steps: 2);
setState(() {});
},
),
现在可以时间穿梭吃后悔药了,运行起来试试,由于可以无限悔棋,人类基本也就无敌啦!
更新悔棋代码到Git:
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git add .
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git commit -m 'bug'
[master 95c31ba] bug
5 files changed, 211 insertions(+), 19 deletions(-)
create mode 100644 lib/cchess/cc-recorder.dart
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ sudo git push
[sudo] elapse 的密码:
Username for 'https://rocketgit.com': elpase
Password for 'https://[email protected]':
枚举对象: 20, 完成.
对象计数中: 100% (20/20), 完成.
使用 4 个线程进行压缩
压缩对象中: 100% (10/10), 完成.
写入对象中: 100% (11/11), 3.59 KiB | 3.59 MiB/s, 完成.
总共 11 (差异 6),复用 0 (差异 0)
remote: RocketGit: Info: == Welcome to RocketGit! ==
remote: RocketGit: Info: you are connecting from IP 27.47.11.191 by http(s).
remote: RocketGit: Info: date/time: 2020-09-04 07:13:32 (UTC), debug id 849ee7.
remote: RocketGit: Error: refs/heads/master
remote: RocketGit: Error: You have no rights to push bad whitespace:
remote: RocketGit: Error: lib/cchess/phase.dart:189: trailing whitespace.
remote: RocketGit: Error: + /*
remote: RocketGit: Error: lib/cchess/phase.dart:209: trailing whitespace.
remote: RocketGit: Error: + /*
remote: error: hook declined to update refs/heads/master
To https://rocketgit.com/user/elapse/chinese_chess
! [remote rejected] master -> master (hook declined)
error: 推送一些引用到 'https://rocketgit.com/user/elapse/chinese_chess' 失败
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ sudo git push
Username for 'https://rocketgit.com': elapse
Password for 'https://[email protected]':
枚举对象: 20, 完成.
对象计数中: 100% (20/20), 完成.
使用 4 个线程进行压缩
压缩对象中: 100% (10/10), 完成.
写入对象中: 100% (11/11), 3.59 KiB | 3.59 MiB/s, 完成.
总共 11 (差异 6),复用 0 (差异 0)
remote: RocketGit: Info: == Welcome to RocketGit! ==
remote: RocketGit: Info: you are connecting from IP 27.47.11.191 by http(s).
remote: RocketGit: Info: date/time: 2020-09-04 07:17:24 (UTC), debug id 2632c2.
To https://rocketgit.com/user/elapse/chinese_chess
46f767f..95c31ba master -> master
elapse@elapse-PC:~/Language/Flutter/chinese_chess$
显示棋谱
之前做屏幕适配的时候,我们有一点遗留问题 — 还没有显示棋谱信息!现在是时候补上这一块了!
首先,我们在Move类中stepName来记录棋谱:
class Move {
...
String stepName; //棋谱招法
...
}
然后在“cchess”文件夹中添加文件“step-name.dart”,加如下内容:
import 'cc-base.dart';
import 'phase.dart';
import '../common/math-ext.dart';
/* 棋谱步骤名 */
class StepName {
static const RedColNames = '九八七六五四三二一';
static const BlackColNames = '123456789';
static const RedDigits = '零一二三四五六七八九';
static const BlackDigits = '0123456789';
static translate(Phase phase, Move step) {
final colNames = [RedColNames, BlackColNames];
final digits = [RedDigits, BlackDigits];
final side = Side.of(phase.pieceAt(step.from));
final sideIndex = (side == Side.Red) ? 0 : 1;
final chessName = nameOf(phase, step);
String result = chessName;
if (step.ty == step.fy) {
result += '平${colNames[sideIndex][step.tx]}';
} else {
final direction = (side == Side.Red) ? -1 : 1;
final dir = ((step.ty - step.fy) * direction > 0) ? '进' : '退';
final piece = phase.pieceAt(step.from);
final specialPieces = [
Piece.RedKnight,
Piece.BlackKnight,
Piece.RedBishop,
Piece.BlackBishop,
Piece.RedAdvisor,
Piece.BlackAdvisor,
];
String targetPos;
if (specialPieces.contains(piece)) {
targetPos = '${colNames[sideIndex][step.tx]}';
} else {
targetPos = '${digits[sideIndex][abs(step.ty - step.fy)]}';
}
result += '$dir$targetPos';
}
return step.stepName = result;
}
static nameOf(Phase phase, Move step) {
final colNames = [RedColNames, BlackColNames];
final digits = [RedDigits, BlackDigits];
final side = Side.of(phase.pieceAt(step.from));
final sideIndex = (side == Side.Red) ? 0 : 1;
final piece = phase.pieceAt(step.from);
final chessName = Piece.Names[piece]; //棋子名
/*
士相由于行动行动路径有限,不会出现同一列两个士相都可以进或退的情况
所以一般不说「前士、前相」之类的,根据「进、退」动作即可判断是前一个还是后一个
*/
if (piece == Piece.RedAdvisor ||
piece == Piece.RedBishop ||
piece == Piece.BlackAdvisor ||
piece == Piece.BlackBishop) {
return '$chessName${colNames[sideIndex][step.fx]}';
}
/*
此 Map 的 Key 为「列」, Value 为此列上出现所查寻棋子的 y 坐标(row)列表
返回结果中进行了过滤,如果某一列包含所查寻棋子的数量 < 2,此列不包含在返回结果中
*/
final Map> cols = findPieceSameCol(phase, piece);
final fyIndexes = cols[step.fx];
/* 正在动棋的这一列不包含多个同类棋子 */
if (fyIndexes == null) {
return '$chessName${colNames[sideIndex][step.fx]}';
}
/* 只有正在动棋的这一列包含多个同类棋子 */
if (cols.length == 1) {
var order = fyIndexes.indexOf(step.fy);
if (side == Side.Black) order = fyIndexes.length - 1 - order;
if (fyIndexes.length == 2) {
return '${'前后'[order]}$chessName';
}
if (fyIndexes.length == 3) {
return '${'前中后'[order]}$chessName';
}
return '${digits[sideIndex][order]}$chessName';
}
/*
这种情况表示有两列都有两个或以上正在查寻的棋子
这种情况下,从右列开始为棋子指定序数(从前到后),然后再左列
*/
if (cols.length == 2) {
//
final fxIndexes = cols.keys.toList();
fxIndexes.sort((a, b) => a - b);
// 已经按列的 x 坐标排序,当前动子列是否是在右边的列
final currentColStart = (step.fx == fxIndexes[1 - sideIndex]);
if (currentColStart) {
//
var order = fyIndexes.indexOf(step.fy);
if (side == Side.Black) order = fyIndexes.length - 1 - order;
return '${digits[sideIndex][order]}$chessName';
//
} else {
// 当前列表在左边,后计序数
final fxOtherCol = fxIndexes[sideIndex];
var order = fyIndexes.indexOf(step.fy);
if (side == Side.Black) order = fyIndexes.length - 1 - order;
return '${digits[sideIndex][cols[fxOtherCol].length + order]}$chessName';
}
}
return '=招法错误=';
}
static findPieceSameCol(Phase phase, String piece) {
//
final map = Map>();
for (var row = 0; row < 10; row++) {
for (var col = 0; col < 9; col++) {
//
if (phase.pieceAt(row * 9 + col) == piece) {
//
var fyIndexes = map[col] ?? [];
fyIndexes.add(row);
map[col] = fyIndexes;
}
}
}
final Map> result = {};
map.forEach((k, v) {
if (v.length > 1) result[k] = v;
});
return result;
}
}
接着将修改 Phase 类中的 move 方法修改成下边的样子:
String move(int from, int to) {
//
if (!validateMove(from, to)) return null;
final captured = _pieces[to];
final move = Move(from, to, captured: captured);
// 翻译着法为中文,后续实现
StepName.translate(this, move);
_recorder.stepIn(move, this);
// 修改棋盘
_pieces[to] = _pieces[from];
_pieces[from] = Piece.Empty;
// 交换走棋方
_side = Side.oppo(_side);
return captured;
}
接着,我们在 CCRecorder 类中添加一个方法:
// 从着法列表生成双列文字,供对战局面显示
String buildManualText({cols = 2}) {
//
var manualText = '';
for (var i = 0; i < _history.length; i++) {
manualText += '${i < 9 ? ' ' : ''}${i + 1}. ${_history[i].stepName} ';
if ((i + 1) % cols == 0) manualText += '\n';
}
if (manualText.isEmpty) {
manualText = '<暂无招法>';
}
return manualText;
}
然后,我们在 Phase 类中添加一个 getter,转调用刚建立的 buildManualText 方法:
get manualText => _recorder.buildManualText();
最后,我们在 _BattlePageState 的 buildFooter 方法中找到下边的一行代码:
final manualText = '<暂无棋谱>';
将它修改成下边的样子:
final manualText = Battle.shared.phase.manualText;
现在可以显示走棋的着法列表了!运行产品试试看!
别忘了提交代码到 git 仓库,本节目标达成!
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git add .
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git commit -m '显示棋谱'
[master c574648] 显示棋谱
5 files changed, 186 insertions(+), 2 deletions(-)
create mode 100644 lib/cchess/step-name.dart
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ sudo git push
[sudo] elapse 的密码:
Username for 'https://rocketgit.com': elapse
Password for 'https://[email protected]':
枚举对象: 18, 完成.
对象计数中: 100% (18/18), 完成.
使用 4 个线程进行压缩
压缩对象中: 100% (10/10), 完成.
写入对象中: 100% (10/10), 3.03 KiB | 3.03 MiB/s, 完成.
总共 10 (差异 6),复用 0 (差异 0)
remote: RocketGit: Info: == Welcome to RocketGit! ==
remote: RocketGit: Info: you are connecting from IP 27.47.4.53 by http(s).
remote: RocketGit: Info: date/time: 2020-09-04 16:08:48 (UTC), debug id 9f8b76.
To https://rocketgit.com/user/elapse/chinese_chess
95c31ba..c574648 master -> master
elapse@elapse-PC:~/Language/Flutter/chinese_chess$
小节回顾
本节内容中,我们从 Phase 中抽取了棋谱记录的逻辑,创建了专门的棋谱记录工具类。
然后,依据棋谱记录的工具类,我们将局面回溯到之前的场景,实现了「悔棋」功能。
最后,我们使用根据行棋的着法,生成了当前对局的棋谱,并显示在对战页面的尾部(对于短屏幕手机,我们使用弹出框方式显示棋谱)。