Flutter象棋:4.处理行棋

上一节,我们的棋盘棋子都非常漂亮地呈现在屏幕上了,但还不支持走棋动作。这一节,我们要记让棋子可以支持行棋动作,这将依赖于手势检测 Feature。

检测到用户点击后,我们将结合棋类游戏的棋子移动方案,处理移动棋子和吃子逻辑。

本节提要
检测棋盘点击事件
将点击坐标换算成棋盘上的棋子位置
谁是棋盘点击事件的接管者
处理行棋和吃子逻辑
检测棋盘点击
在 Flutter 中,要检测用户的点击、长按、拖拽等操作是很简单的事情,只需要用 GestureDetector 包裹你想要查检测手势的 Widget 即可。

我们修改一下 BoardWidget 的 build 方法,用 GestureDetector 包裹我们的棋盘,将它修改成下边这样:

		@override
		  Widget build(BuildContext context) {
		    final boardContainer = Container(
		      width: width,
		      height: height,
		      decoration: BoxDecoration(
		        borderRadius: BorderRadius.circular(5),
		        color: ColorConsts.BoardBackground,
		      ),
		      child: CustomPaint(
		        painter: BoardPainter(width: width), //背景一层绘制横盘上的线格
		        /* 前景一层绘制棋子 */
		        foregroundPainter: PiecesPainter(
		          width: width,
		          phase: Phase.defaultPhase(),
		        ),
		        /* CustomPaint 的 child 用于布置其上的子组件,这里放置是我们的「河界」、「路数」等文字信息 */
		        child: Container(
		          margin: EdgeInsets.symmetric(
		            vertical: Padding,
		            horizontal: (width - Padding * 2) / 9 / 2 +
		                Padding -
		                WordsOnBoard.DigitsFontSize / 2,
		          ),
		          child: WordsOnBoard(),
		        ),
		      ),
		    );
		
		    /* 用 GestureDetector 组件包裹我们的 board 组件,用于检测 board 上的点击事件 */
		    return GestureDetector(
		      child: boardContainer,
		      onTapUp: (details) {
		        print("点击了,坐标:");
		        print(details.localPosition);
		      },
		    );
		  }
		
		
上边的代码中,我们仅仅是把原来的构造 widget 的代码赋值给了 boardContainer 变量,然后用 GestureDetector 包裹了代表 Widget 的变量 boardContainer。

Board 的点击事件,我们关心的不是哪个坐标点被用户点击了,关心的应该是棋盘上的哪位交叉点被用户点击了。这些知识是 BoardWiget 内部的,因为棋盘知道自己的格子多大、边界在哪。因此我们在 GestureDetector的 onTapUp 回调中,将坐标点解释成相应的棋盘上的交叉位置的索引。

我们将 GestureDetector 的 onTapUp 回调方法的内容由下边的样子:

		  ...
		  return GestureDetector(
		      child: boardContainer,
		      onTapUp: (details) {
		        print("点击了,坐标:");
		        print(details.localPosition);
		      },
		    );
		...
		
		
修改为:

		  return GestureDetector(
		      child: boardContainer,
		      onTapUp: (details) {
		        final gridWidth = (width - Padding * 2) / 9 * 8; //棋盘线(网格)总宽
		        final squareSide = (width - Padding * 2) / 9; //棋盘每个格子的边长
		
		        /* 点击的坐标 */
		        final dx = details.localPosition.dx, dy = details.localPosition.dy;
		
		        /* 把点击的坐标转换成棋盘上的行、列 */
		        final row = (dy - Padding - DigitsHeight) ~/ squareSide;
		        final column = (dx - Padding) ~/ squareSide;
		
		        if (row < 0 || row > 9) {
		          print('点击了棋盘外上边或下边,并没有点击棋盘交叉点');
		          return;
		        }
		
		        if (column < 0 || column > 8) {
		          print('点击了棋盘线外的左边或者右边,并没有点到棋盘下棋艺的交叉点');
		          return;
		        }
		        print('点了棋盘行row:$row,棋盘的列:$column');
		      },
		    );
		
		
接管棋盘上的点击事件
现在需要考虑的一个问题:Board 上的点击事件应该由谁来接管呢?

Board 上的用户行棋点击事件,应该是 Board 创建者关心的事情。因此,点击事件的回调应该由外部传入。我们为 BoardWidget 添加一个回调,并修改 BoardWiget 的构造函数。

具体操作为 — 在 BoardWidget 头部找到以下部分代码:

		class BoardWidget extends StatelessWidget {
		
		  ...
		
		  final double width, height; //棋盘宽与高
		
		  /* 由于横盘上的小格子都是正方形,因素宽度确定后,棋盘的高度也就确定了 */
		  BoardWidget({@required this.width})
		      : height = (width - Padding * 2) / 9 * 10 + (Padding + DigitsHeight) * 2;
		
		  ...
		}
		
		
添加回调成员,更新构造方法,将它修改为:

		class BoardWidget extends StatelessWidget {
		  //
		  ...
		
		  final double width, height; //棋盘宽与高
		
		  final Function(BuildContext, int) onBoardTap;
		
		  /* 由于横盘上的小格子都是正方形,因素宽度确定后,棋盘的高度也就确定了 */
		  BoardWidget({@required this.width, @required this.onBoardTap})
		      : height = (width - Padding * 2) / 9 * 10 + (Padding + DigitsHeight) * 2;
		
		  ...
		}
		
		
接着,我们在 GestureDetector 的 onTapUp 回调中,将用户点击棋盘交叉点位的事件,回调 onBoardTap 方法。修改后 BoardWidget 的 build 方法代码如下:

		@override
		  Widget build(BuildContext context) {
		
		    ...
		
		    /* 用 GestureDetector 组件包裹我们的 board 组件,用于检测 board 上的点击事件 */
		    return GestureDetector(
		      child: boardContainer,
		      onTapUp: (details) {
		        final gridWidth = (width - Padding * 2) / 9 * 8; //棋盘线(网格)总宽
		        final squareSide = (width - Padding * 2) / 9; //棋盘每个格子的边长
		
		        /* 点击的坐标 */
		        final dx = details.localPosition.dx, dy = details.localPosition.dy;
		
		        /* 把点击的坐标转换成棋盘上的行、列 */
		        final row = (dy - Padding - DigitsHeight) ~/ squareSide;
		        final column = (dx - Padding) ~/ squareSide;
		
		        if (row < 0 || row > 9) {
		          print('点击了棋盘外上边或下边,并没有点击棋盘交叉点');
		          return;
		        }
		
		        if (column < 0 || column > 8) {
		          print('点击了棋盘线外的左边或者右边,并没有点到棋盘下棋艺的交叉点');
		          return;
		        }
		
		        print('点了棋盘行row:' +
		            (row + 1).toString() +
		            ',棋盘的列:' +
		            (column + 1).toString());
		
		        onBoardTap(context, row * 9 + column); //点击棋盘的回调
		      },
		    );
		  }
		
		
代码中的「row * 9 + column」是棋盘位置表示的一个基本约定。它表示的意思是:

从上到下、从左到右,第 row 行 column 列的棋子,在棋盘局面 Phase 类的棋子列表 List(90) 中的存放索引值是 row * 9 + column。

关于棋盘上的行列和 Phase 中的棋子列表索引的转换关系是比较重要的,这种转换在后边的代码中还会多次出现,请大家注意!

处理行棋逻辑
为了反映棋盘变动状态,首先我们将 BattlePage 由 StatelessWidget 的修改为 StatefulWidget。

要在 vscode 中,如果需要将一个 StatelessWidget 修改为 StatefulWidget,可以先将光标移动到 BattlePage 类声明语句上,然后按「Cmd+.」,在弹出的菜单中点 「Convert to StatefulWidget」 菜单项。

之前,我们为 BoardWidget 的默认构造方法添加了onBoardTap 的回调参数。现在 BattlePage 的 State 类 _BattlePageState 中,我们添加一个 onBoardTap 方法,并在 BoardWidget 的构造方法中,将它传递给 BoardWidget。这样 BoardWidget 的点击事件就会被传递给 BattlePage 的 onBoardTap 方法了,这是合理的!

完成这一步后,完整的 battle-page.dart 文件的代码是这样的:

		import 'package:flutter/material.dart';
		import '../board/board-widget.dart';
		
		class BattlePage extends StatefulWidget {
		  static const BoardMarginV = 10.0, BoardMarginH = 10.0;
		  @override
		  _BattlePageState createState() => _BattlePageState();
		}
		
		class _BattlePageState extends State {
		  /* 由 BattlePage 的 State 类来处理棋盘的点击事件 */
		  onBoardTap(BuildContext context, int pos) {
		    //
		    print('棋盘的index: $pos');
		  }
		
		  @override
		  Widget build(BuildContext context) {
		    final windowSize = MediaQuery.of(context).size;
		    final boardHeight = windowSize.width - BattlePage.BoardMarginH * 2;
		
		    return Scaffold(
		      appBar: AppBar(
		        title: Text('棋盘'),
		      ),
		      body: Container(
		        margin: const EdgeInsets.symmetric(
		          horizontal: BattlePage.BoardMarginH,
		          vertical: BattlePage.BoardMarginV,
		        ),
		        child: BoardWidget(width: boardHeight, onBoardTap: onBoardTap),
		      ),
		    );
		  }
		}
		
		
回顾一下前边的棋盘绘制逻辑,我们会发现,棋盘上的棋子分布情况是由 Phase 类持有的。

用户点击棋盘行棋,如果想要将行棋动作反映到棋子分布位置的调整上,肯定是要修改 Phase 类的棋子存放列表 List _pieces; 对象。

之前,我们在 PiecesPainter 类中直接用 Phase.defaultPhase() 构造了一个象棋的初始局面。但回到一个游戏整体的宏观面上,我们应考虑的问题是:Phase 对象应该被谁来持有呢?

到目标为止,BoardWidget 的持有者 BattlePage 应该敢是 Phase 的持有者,目前这是一个「直觉」的选择。

如果我们我们由 BattlePage 持有 Phase 对象,那使用过程是什么样子的呢?

第一步,我们在 BattlePage 里创建 Phase 实例
第二步,我们将 Phase 实例传递给 BoardWidget
第三步,BoardWidget 将 Phase 实例传递给 PiecesPainter
这样做能通,但感觉 Phase 传递的路径有点长,并且这个传递链接上的 BoardWidget 其实是没有必要知道 Phase 是什么东西的。

应对这个问题,我们可以新建一个 Battle 类,并将其做成一个全局可以访问的单例对象,BoardWidget 可以在创建 PiecesPainter 时直接通过单例方式访问棋盘数据!

我们新建 lib/game 文件夹,在其中创建一个 battle.dart 文件,实现一个单例模式的 Battle 类型,文件内容如下:

		import '../cchess/phase.dart';
		
		/* 集中管理横盘上的棋子、对战结果、引擎调用等事务 */
		class Battle {
		  static Battle _instance;
		
		  static get shared {
		    _instance ??= Battle();
		    return _instance;
		  }
		
		  Phase _phase;
		
		  init() {
		    _phase = Phase.defaultPhase();
		  }
		
		  get phase => _phase;
		}
		
		
接下来,我们在 _BattlePageState 中覆盖 initState 方法,在其中对 Battle 进行初始化:

		...
		class _BattlePageState extends State {
		  @override
		  void initState() {
		    super.initState();
		    Battle.shared.init(); //使用默认“新局”初始化棋子分布
		  }
		...
		}
		
		
		
然后,我们在 BoadWidget 中修改 CustomPainter 的创建部分代码,将其修改为下边的样子:

		class BoardWidget extends StatelessWidget {
		 ...
		
		  @override
		  Widget build(BuildContext context) {
		    final boardContainer = Container(
		      width: width,
		      height: height,
		      decoration: BoxDecoration(
		        borderRadius: BorderRadius.circular(5),
		        color: ColorConsts.BoardBackground,
		      ),
		      child: CustomPaint(
		        painter: BoardPainter(width: width), //背景一层绘制横盘上的线格
		        /* 前景一层绘制棋子 */
		        foregroundPainter: PiecesPainter(
		          width: width,
		          // phase: Phase.defaultPhase(),
		          phase: Battle.shared.phase, //管理棋子分布的 Phase 对象,从 Battle 单例中获取
		        ),
		        /* CustomPaint 的 child 用于布置其上的子组件,这里放置是我们的「河界」、「路数」等文字信息 */
		        child: Container(
		          margin: EdgeInsets.symmetric(
		            vertical: Padding,
		            horizontal: (width - Padding * 2) / 9 / 2 +
		                Padding -
		                WordsOnBoard.DigitsFontSize / 2,
		          ),
		          child: WordsOnBoard(),
		        ),
		      ),
		    );
		   ...
		
		
现在好了,BoardWidget 不用知道 Phase 的细节了,它管理好自己的棋盘绘制、坐标翻译和点击事件上报就完事了。

在继续我们的棋盘事件处理流程前。我们有个象棋软件的常识要向大家介绍一下:

当我们点击自己一方的某个棋子时,被点中的棋子会有被「选中」的效果,这是给玩家的一个「状态可知」的交互反馈。
我们选中棋子后再点击目的地移动棋子后,移动前、后的位置都有指示效果
当前位置以及移动前的位置

为此,我们为 Battle 类添加两成员变量 _focusIndex 和 _blurIndex,用于标记当前位置和前一个位置,在 init 方法中对它们进行初始化,并为其添加修改和访问方法,修改后的 Phase 类代码如下:

		import '../cchess/phase.dart';
		
		/* 集中管理横盘上的棋子、对战结果、引擎调用等事务 */
		class Battle {
		  static Battle _instance;
		  Phase _phase;
		  int _focusIndex, _blurIndex;
		
		  static get shared {
		    _instance ??= Battle();
		    return _instance;
		  }
		
		  init() {
		    _phase = Phase.defaultPhase();
		    _focusIndex = _blurIndex = -1;
		  }
		
		  /* 
		  点击选中一个棋子,使用 _focusIndex 来标记此位置
		  棋子绘制时,将在这个位置绘制棋子的选中效果
		  */
		  select(int pos) {
		    _focusIndex = pos;
		    _blurIndex = -1;
		  }
		
		  /* 
		  从 from 到 to 位置移动棋子,使用 _focusIndex 和 _blurIndex 来标记 from 和 to 位置
		  棋子绘制时,将在这两个位置分别绘制棋子的移动前的位置和当前位置
		  */
		  move(int from, int to) {
		    _blurIndex = from;
		    _focusIndex = to;
		  }
		
		  /* 清除棋子的选中和移动前的位置指示 */
		  clear() {
		    _focusIndex = _blurIndex = -1;
		  }
		
		  get phase => _phase;
		  get focusIndex => _focusIndex;
		  get blurIndex => _blurIndex;
		}
		
		
这里 Battle 类中可以访问或修改两个位置标记变量的值 _focusIndex 和 _blurIndex,但 move 方法中并未真正修改 Phase 中的棋子列表。

我们需要先将棋子的选择与移动标记的绘制工作处理一下。

在 PiecesPainter 类中,我们添加两个成员变量 focusIndex 和 blurIndex,并修改构造方法,要求 PiecesPainter 的持有者传入这两个参数:

		class PiecesPainter extends PainterBase {
		  final Phase phase; //表示棋局面
		  double pieceSide; //棋子的大小
		  final int focusIndex, blurIndex; //棋盘上的棋子移动、选择位置指示
		
		  PiecesPainter({
		    @required double width,
		    @required this.phase,
		    this.focusIndex = -1,
		    this.blurIndex = -1,
		  }) : super(width: width) {
		    pieceSide = squareSide * 0.9; //计算棋子的大小
		  }
		
		  ...
		}
		
		
回到 BoardWidget 的 CustomPaint 构造方法中,我们将 focusIndex 和 blurIndex 作为构造参数传递给 PiecesPainter 类:

		...
		
		class BoardWidget extends StatelessWidget {
		
		  ...
		
		  @override
		  Widget build(BuildContext context) {
		    final boardContainer = Container(
		      width: width,
		      height: height,
		      decoration: BoxDecoration(
		        borderRadius: BorderRadius.circular(5),
		        color: ColorConsts.BoardBackground,
		      ),
		      child: CustomPaint(
		        painter: BoardPainter(width: width), //背景一层绘制横盘上的线格
		        /* 前景一层绘制棋子 */
		        foregroundPainter: PiecesPainter(
		          width: width,
		          phase: Battle.shared.phase, //管理棋子分布的 Phase 对象,从 Battle 单例中获取
		          focusIndex: Battle.shared.focusIndex, //将棋盘上的选中棋子位置传给painter
		          blurIndex: Battle.shared.blurIndex, //将棋盘上要移动棋子位置传给painter
		        ),
		        /* CustomPaint 的 child 用于布置其上的子组件,这里放置是我们的「河界」、「路数」等文字信息 */
		        child: Container(
		          margin: EdgeInsets.symmetric(
		            vertical: Padding,
		            horizontal: (width - Padding * 2) / 9 / 2 +
		                Padding -
		                WordsOnBoard.DigitsFontSize / 2,
		          ),
		          child: WordsOnBoard(),
		        ),
		      ),
		    );
		
		    ...
		  }
		}
		
		
接着,对 PiecesPainter 的 doPaint 方法我们添加两个对应参数:

		static doPaint(
		    Canvas canvas,
		    Paint paint, {
		    Phase phase, //局面
		    double gridWidth, //横盘上线格的总宽度
		    double squareSide, //每个格子的边长
		    double pieceSide,
		    double offsetX,
		    double offsetY,
		    int focusIndex = -1,
		    int blurIndex = -1,
		  }) {
		   ...
		  }
		
		
对应的,我们在 PiecesPainter 的 paint 方法中修改对 doPaint 方法的调用代码:

		@override
		  void paint(Canvas canvas, Size size) {
		    //
		    doPaint(
		      canvas,
		      thePaint,
		      phase: phase,
		      gridWidth: gridWidth, //总宽度
		      squareSide: squareSide, //每一个格子的边长
		      pieceSide: pieceSide, //棋子的大小
		      offsetX: BoardWidget.Padding + squareSide / 2,
		      offsetY: BoardWidget.Padding + BoardWidget.DigitsHeight + squareSide / 2,
		      focusIndex: focusIndex,
		      blurIndex: blurIndex,
		    );
		  }
		
		
接下来的事情,当然就是在 doPaint 方法中添加位置指示的绘制代码。
在 PiecesPainter 的 doPaint 方法尾部添加对选择和移动位置指示的绘制代码:

		class PiecesPainter extends PainterBase {
		  ...
		  static doPaint(
		    Canvas canvas,
		    Paint paint, {
		    Phase phase, //局面
		    double gridWidth, //横盘上线格的总宽度
		    double squareSide, //每个格子的边长
		    double pieceSide,
		    double offsetX,
		    double offsetY,
		    int focusIndex = -1,
		    int blurIndex = -1,
		  }) {
		    /* 绘制棋子的选定效果 */
		    if (focusIndex != -1) {
		      final int row = focusIndex ~/ 9, column = focusIndex % 9;
		
		      paint.color = ColorConsts.FocusPosition;
		      paint.style = PaintingStyle.stroke;
		      paint.strokeWidth = 2;
		
		      canvas.drawCircle(
		        Offset(left + column * squareSide, top + row * squareSide),
		        pieceSide / 2,
		        paint,
		      );
		    }
		
		    /* 绘制棋子移动效果 */
		    if (blurIndex != -1) {
		      final int row = blurIndex ~/ 9, column = blurIndex % 9;
paint.color = ColorConsts.BlurPosition; paint.style = PaintingStyle.fill; canvas.drawCircle( Offset(left + column * squareSide, top + row * squareSide), pieceSide / 2 * 0.8, paint, ); } }
棋盘的位置指示位置绘制就这样了,现在开始接近最本质的内容了 — 修改棋盘上的棋子位置。

让我们回到 _BattlePageState 类的 onBoardTap 方法,我们给其中添加棋子选中、移动的基本逻辑:

		class _BattlePageState extends State {
		
		  ...
		
		  /* 由 BattlePage 的 State 类来处理棋盘的点击事件 */
		  onBoardTap(BuildContext context, int pos) {
		    //
		    print('棋盘的index: $pos');
		
		    final phase = Battle.shared.phase;
		
		    /* 现在只限定红方能行棋 */
		    if (phase.side != Side.Red) return;
		
		    final tapedPiece = phase.pieceAt(pos); //查询选中的坐标的的棋子
		
		    /* 之前是否有选择棋子,以及是否选中的是红方 */
		    if (Battle.shared.focusIndex != -1 &&
		        Side.of(phase.pieceAt(Battle.shared.focusIndex)) == Side.Red) {
		      if (Battle.shared.focusIndex == pos) return; //当前点击的棋子和之前已经选择的是同一个位置
		
		      //之前已经选择的棋子和现在点击的棋子是同一边的,说明是选择另外一个棋子
		      final focusPiece = phase.pieceAt(Battle.shared.focusIndex);
		      if (Side.sameSide(focusPiece, tapedPiece)) {
		        Battle.shared.select(pos); //设置新的选中棋子
		      } else if (Battle.shared.move(Battle.shared.focusIndex, pos)) {
		        print('现在点击的棋子和上一次选择棋子不同边,要么是吃子,要么是移动棋子到空白处');
		      }
		      //
		    } else {
		      print('之前未选择棋子,现在点击就是选择棋子');
		      if (tapedPiece != Piece.Empty) Battle.shared.select(pos);
		    }
		  }
		
		  ...
		}
		
		
onBoardTap 方法中存在一些细腻的逻辑,却并不是复杂的东西,大家可以看下代码上的注释文字。

棋子的移动动作计划是在 Battle 类的 move 方法中进行的,但棋子列表是由 Phase 类持有的。为了尽可能地降低逻辑耦合,我们在 Phase 类中执行真正的棋子移动操作,后边 Battle 将在 move 方法中调用 Phase 类的 move 方法。

我们先在 Phase 类中添加 move 方法的实现:

		...
		
		class Phase {
		
		  ...
		
		  bool move(int from, int to) {
		    if (!validateMove(from, to)) return false;
		
		    //修改棋盘
		    _pieces[to] = _pieces[from];
		    _pieces[from] = Piece.Empty;
		
		    // _side = Side.oppo(_side); //交换走棋方
		
		    return true;
		  }
		
		  /// 验证移动棋子的着法是否合法
		  bool validateMove(int from, int to) {
		    // TODO:
		    return true;
		  }
		  ...
		}
		
		
接下来的事情就简单了,我们将 Battle 类的 move 方法修改为下边的样子:

		...
		
		class Battle {
		
		  ...
		
		  /* 
		  从 from 到 to 位置移动棋子,使用 _focusIndex 和 _blurIndex 来标记 from 和 to 位置
		  棋子绘制时,将在这两个位置分别绘制棋子的移动前的位置和当前位置
		  */
		  move(int from, int to) {
		    if (!_phase.move(from, to)) return false;
		
		    /* 移动棋子时,更新这两个标志位置,然后的绘制会把它们展示在界面上 */
		    _blurIndex = from;
		    _focusIndex = to;
		
		    return true;
		  }
		
		  ...
		}
		
		
数据层面的棋子移动逻辑都完成了!
最后一步让界面呈现棋盘数据状态的改动 — 我们在 _BattlePageState 的 onBoardTap 方法尾部添加一行代码:

		setState(() {}); //更新状态,重新绘制棋子
		
		
现在又到了检验成果的时间了,在 vscode 中按 F5,试试在棋盘上点击棋子走棋看看?

提交代码到 git 仓库吧,本节目标达成!

		elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git add .
		elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git commit -m '处理行棋'
		[master 7d14a21] 处理行棋
		 5 files changed, 201 insertions(+), 12 deletions(-)
		 create mode 100644 lib/game/battle.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% (10/10), 完成.
		写入对象中: 100% (12/12), 4.59 KiB | 4.59 MiB/s, 完成.
		总共 12 (差异 4),复用 0 (差异 0)
		remote: RocketGit: Info: == Welcome to RocketGit! ==
		remote: RocketGit: Info: you are connecting from IP 112.94.53.74 by http(s).
		remote: RocketGit: Info: date/time: 2020-08-25 05:56:00 (UTC), debug id 51e2e5.
		To https://rocketgit.com/user/elapse/chinese_chess
		   8c71acb..7d14a21  master -> master
		elapse@elapse-PC:~/Language/Flutter/chinese_chess$ 
		
		

本节回顾
本节课程中,我们首先使用 GestureDetector 组件来检查用户在组件上的点击事件。通过 onTapUp 事件回调,我们可以获取点击事件的位置信息,接着将这个位置信息转换成了棋盘上的棋子位置。

接下来,我们将棋盘事件的接收者抽象出来,由棋盘组件的创建者在创建棋盘时一并传入棋盘组件。发生点击棋盘事件时,向接收者调用回调方法。

很关键的一步,我们实现了 Battle 类,将在其中管理了 Phase 类的对象以及棋盘上的选定棋子移动棋子的位置指示。

Phase 类的实例决定了棋盘上的某个棋子所在的具体位置,我们在其中实现了棋子的移动、咋子等逻辑。