Flutter象棋:10.记谱悔棋功能

在对战过程,玩家经常需要悔棋功能,这种让时间倒流的技能不会太简单的,这一节的任务就是让我们的象棋游戏支持悔棋!

本节概要

  • 重构 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 中抽取了棋谱记录的逻辑,创建了专门的棋谱记录工具类。

然后,依据棋谱记录的工具类,我们将局面回溯到之前的场景,实现了「悔棋」功能。

最后,我们使用根据行棋的着法,生成了当前对局的棋谱,并显示在对战页面的尾部(对于短屏幕手机,我们使用弹出框方式显示棋谱)。