上一节,我们的棋盘棋子都非常漂亮地呈现在屏幕上了,但还不支持走棋动作。这一节,我们要记让棋子可以支持行棋动作,这将依赖于手势检测 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 类的实例决定了棋盘上的某个棋子所在的具体位置,我们在其中实现了棋子的移动、咋子等逻辑。