目录
- 前沿
- 演示效果
- 对角棋规则
- 实现思路
- 具体实现
- 1. 绘制棋盘
- 2. 绘制棋子
- 3. 手势处理
- 4. 游戏规则
- 优化
- 总结
前沿
关于对角棋相信大家都不陌生,其凭借着规则简单又灵活多变成为我们童年不可缺少的益智游戏。
今天我将用Flutter来实现一个对角棋游戏,即巩固自己Flutter的绘制和手势知识,也希望这篇文章对大家有所帮助。
演示效果
老规矩,我们先演示下实现的最终效果:
对角棋规则
首先我们还是回顾下对角棋游戏的规则,这里借用 百度百科 的规则说明:
棋盘:象棋棋盘中,将士所在的带对角线的田字框。
棋子:双方各持三子,颜色不同。
初始:如图1所示,各自对立。
胜利条件:其中一方三子,占据一条对角线,或者对方没有棋子可以移动。
玩法:沿着棋盘划线,双方交互移动棋子,一次一只能移动一步,不包括交叉。
实现思路
- 棋盘。绘制棋盘
- 棋子。绘制棋子
- 手势。处理点击棋子及移动位置手势
- 规则。规则分为棋子移动规则、游戏胜利规则两部分
具体实现
1. 绘制棋盘
说到绘制,我们需要先创建 CustomPaint 通过自定义 CustomPainter 来实现。
CustomPaint( | |
size: Size(width, height), | |
painter: DiagonalChessPainter(), | |
) |
考虑到我们要适配不同的手机尺寸,因此我们先通过 LayoutBuilder 测量整个 Widget 的尺寸,并计算棋盘在屏幕上位置。
LayoutBuilder( | |
builder: (BuildContext context, BoxConstraints constraints) { | |
initPosition(constraints); | |
return GestureDetector( | |
onTapDown: _onTapDown, | |
child: CustomPaint( | |
size: Size(width, height), | |
painter: DiagonalChessPainter( | |
rWidth: rWidth, | |
rHeight: rHeight, | |
boardOffsetList: boardOffsetList, | |
), | |
), | |
); | |
}, | |
) |
我们这里定义棋盘的九个点,从屏幕左上角开始,代码如下:
width = constraints.maxWidth; | |
height = constraints.maxHeight; | |
rWidth = width * 0.4; | |
rHeight = width * 0.6; | |
// 棋盘各个点 | |
// 第一行,从左到右 | |
boardOffsetList.add(Offset(-rWidth, -rHeight)); | |
boardOffsetList.add(Offset(0, -rHeight)); | |
boardOffsetList.add(Offset(rWidth, -rHeight)); | |
// 第二行,从左到右 | |
boardOffsetList.add(Offset(-rWidth, 0)); | |
boardOffsetList.add(Offset.zero); | |
boardOffsetList.add(Offset(rWidth, 0)); | |
// 第二行,从左到右 | |
boardOffsetList.add(Offset(-rWidth, rHeight)); | |
boardOffsetList.add(Offset(0, rHeight)); | |
boardOffsetList.add(Offset(rWidth, rHeight)); |
在自定义的 DiagonalChessPainter 中进行绘制,先绘制一个矩形,然后绘制四条对角线完成整个棋盘的绘制,代码如下:
// 绘制矩形 | |
canvas.drawRect( | |
Rect.fromLTRB(-rWidth, -rHeight, rWidth, rHeight), _chessboardPaint); | |
// 绘制对角线 | |
Path path = Path() | |
// P1-P9 | |
..moveTo(boardOffsetList[0].dx, boardOffsetList[0].dy) | |
..lineTo(boardOffsetList[8].dx, boardOffsetList[8].dy) | |
// P2-P8 | |
..moveTo(boardOffsetList[1].dx, boardOffsetList[1].dy) | |
..lineTo(boardOffsetList[7].dx, boardOffsetList[7].dy) | |
// P3-P7 | |
..moveTo(boardOffsetList[2].dx, boardOffsetList[2].dy) | |
..lineTo(boardOffsetList[6].dx, boardOffsetList[6].dy) | |
// P4-P6 | |
..moveTo(boardOffsetList[3].dx, boardOffsetList[3].dy) | |
..lineTo(boardOffsetList[5].dx, boardOffsetList[5].dy); | |
canvas.drawPath(path, _chessboardPaint); |
棋盘展示效果:
2. 绘制棋子
我们先定义6个棋子,并添加必要的绘制用到的属性。代码如下:
// 定义棋子位置、颜色、文案 | |
piecesOffsetList.clear(); | |
piecesOffsetList | |
.add(PiecesBean(boardOffsetList[0], Colors.greenAccent, "1")); | |
piecesOffsetList | |
.add(PiecesBean(boardOffsetList[1], Colors.greenAccent, "2")); | |
piecesOffsetList | |
.add(PiecesBean(boardOffsetList[2], Colors.greenAccent, "3")); | |
piecesOffsetList.add(PiecesBean(boardOffsetList[6], Colors.redAccent, "1")); | |
piecesOffsetList.add(PiecesBean(boardOffsetList[7], Colors.redAccent, "2")); | |
piecesOffsetList.add(PiecesBean(boardOffsetList[8], Colors.redAccent, "3")); |
关于棋子的绘制,这里为了简化,绘制一个简单的圆+序号文案即可。
/// 绘制单个棋子 | |
void _drawChessPiece( | |
Canvas canvas, PiecesBean bean, bool reverse, bool isSelected) { | |
var offset = bean.offset; | |
var color = bean.color; | |
double radius = 25; | |
canvas.save(); | |
canvas.translate(offset.dx, offset.dy); | |
canvas.drawCircle(Offset.zero, radius, _chessPiecesPaint..color = color); | |
_drawChessPieceText(canvas, bean, isSelected); | |
canvas.restore(); | |
} |
文案的绘制。通过TextPainter进行绘制,绘制时注意先textPainter.layout()测量后再计算偏移量。
var textPainter = TextPainter( | |
text: TextSpan( | |
text: bean.text, | |
style: TextStyle( | |
fontSize: isSelected ? 35 : 30, | |
color: Colors.white, | |
fontWeight: FontWeight.bold, | |
)), | |
textAlign: TextAlign.center, | |
textDirection: TextDirection.ltr, | |
); | |
textPainter.layout(); | |
var textSize = textPainter.size; | |
textPainter.paint( | |
canvas, Offset(textSize.width * -0.5, textSize.height * -0.5)); | |
// 定义步数,判断那一方走下一步棋 | |
int step = 0; |
棋子展示效果:
3. 手势处理
通常我们下棋时,首先点击某个棋子,然后点击需要移动到的位置。此时,棋子先变成选中状态,然后移动到选中的位置,完成棋子的移动。
对于手势的处理,Flutter通过GestureDetector来实现。我们先定义GestuerDetecotr,将child设为CustomPiant,我们在onTapDown中处理用户点击。代码如下:
GestureDetector( | |
onTapDown: _onTapDown, | |
child: CustomPaint( | |
size: Size(width, height), | |
painter: DiagonalChessPainter(), | |
), | |
); |
通过手势点击的位置和棋子的位置进行比较即可判断当前是否点击的是棋子。代码如下:
var offset = details.globalPosition; | |
var dx = offset.dx - width * 0.5; | |
var dy = offset.dy - height * 0.5; | |
for (MapEntry<int, PiecesBean> entry in piecesOffsetList.asMap().entries) { | |
var bean = entry.value; | |
var index = entry.key; | |
var piecesOffset = bean.offset; | |
if (_checkPoint(piecesOffset.dx, piecesOffset.dy, dx, dy)) { | |
// 更新棋子选中状态 | |
piecesIndex.value = index; | |
// debugPrint("piecesIndex:$piecesIndex"); | |
return; | |
} | |
} | |
/// 是否是当前点 | |
bool _checkPoint(double dx1, double dy1, double dx2, double dy2) => | |
(dx1 - dx2).abs() < 40 && (dy1 - dy2).abs() < 40; |
若判断当前不是点击的棋子,则判断是否点击的棋盘中9个点的位置,若是则判断是否已选中棋子,若选中则修改棋子的Offset重新绘制。代码如下:
// 若点击是棋盘 | |
for (MapEntry<int, Offset> entry in boardOffsetList.asMap().entries) { | |
var offset = entry.value; | |
var index = entry.key; | |
if (_checkPoint(offset.dx, offset.dy, dx, dy)) { | |
if (piecesIndex.value > -1) { | |
var bean = piecesOffsetList[piecesIndex.value]; | |
bean.offset = boardOffsetList[index]; | |
} | |
// debugPrint("boardsIndex:$index"); | |
return; | |
} | |
} |
实现效果如下:
4. 游戏规则
1. 棋子移动规则
我们下棋时,每一方只能走一步交替进行下棋,且棋子只能按照棋盘规则行走。代码如下:
// 棋盘各个点可移动位置 | |
final moveVisibleList = [ | |
[ | ],|
[ | ],|
[ | ],|
[ | ],|
[ | ],|
[ | ],|
[ | ],|
[ | ],|
[//第9个点可移动位置 | ],|
]; |
我们分别在点击棋子和棋盘位置时判断是否当前一方的棋子走,若是当前棋子是否可以走到该棋盘位置。代码如下:
// 更新棋子选中状态 | |
if (step % 2 == 1 && index < 3 || step % 2 == 0 && index >= 3) { | |
piecesIndex.value = index; | |
} | |
// 判断棋子是否可以走到该位置 | |
if (piecesIndex.value > -1 && | |
isMoveViable(piecesIndex.value, index) && | |
(step % 2 == 1 && piecesIndex.value < 3 || | |
step % 2 == 0 && piecesIndex.value >= 3)) { | |
var bean = piecesOffsetList[piecesIndex.value]; | |
bean.offset = boardOffsetList[index]; | |
boardIndex.value = index; | |
step++; | |
} |
2. 比赛胜利规则
我们首先根据对角棋的胜利规则定义比赛胜利需要移动到的位置。代码如下:
// 胜利的位置 | |
final winPositions = [ | |
[ | ],|
[ | ]|
]; |
在棋子每次发生移动后来校验当前棋子是否匹配胜利的位置,若匹配则弹窗提示胜利方。代码如下:
/// 获取胜利的状态 | |
int getWinState() { | |
for (int i = 0; i < piecesOffsetList.length / 3; i++) { | |
var offset1 = piecesOffsetList[i * 3 + 0].offset; | |
var offset2 = piecesOffsetList[i * 3 + 1].offset; | |
var offset3 = piecesOffsetList[i * 3 + 2].offset; | |
if (isWinPosition(offset1, offset2, offset3)) { | |
return i; | |
} | |
} | |
return -1; | |
} | |
/// 是否是符合胜利的位置 | |
bool isWinPosition(Offset offset1, Offset offset2, Offset offset3) { | |
var position1 = boardOffsetList.indexOf(offset1); | |
var position2 = boardOffsetList.indexOf(offset2); | |
var position3 = boardOffsetList.indexOf(offset3); | |
for (var positionList in winPositions) { | |
if (positionList.contains(position1) && | |
positionList.contains(position2) && | |
positionList.contains(position3)) { | |
return true; | |
} | |
} | |
return false; | |
} | |
/// 判断是否有一方胜利 | |
void checkWinState() { | |
var winState = getWinState(); | |
switch (winState) { | |
case 0: // 绿色方胜利 | |
_showDialogTip("绿色方胜利!"); | |
break; | |
case 1: // 红色放胜利 | |
_showDialogTip("红色方胜利!"); | |
break; | |
default: | |
break; | |
} | |
} |
最后,当一方无法走下一步时,自动判断另外一方胜利。代码如下:
/// 获取胜利的状态 | |
int getWinState() { | |
for (int i = 0; i < piecesOffsetList.length / 3; i++) { | |
var index1 = piecesOffsetList[i * 3 + 0].boardIndex; | |
var index2 = piecesOffsetList[i * 3 + 1].boardIndex; | |
var index3 = piecesOffsetList[i * 3 + 2].boardIndex; | |
var lastIndex = piecesOffsetList.length - 1; | |
var otherIndex1 = piecesOffsetList[lastIndex - (i * 3 + 0)].boardIndex; | |
var otherIndex2 = piecesOffsetList[lastIndex - (i * 3 + 1)].boardIndex; | |
var otherIndex3 = piecesOffsetList[lastIndex - (i * 3 + 2)].boardIndex; | |
// 判断一方是否已胜利 | |
if (isWinPosition(index1, index2, index3)) { | |
return i; | |
} | |
// 判断另外一方是否已无法走棋 | |
if (isOtherNotMoveVisible( | |
[ | ], [otherIndex1, otherIndex2, otherIndex3])) {|
return i; | |
} | |
} | |
return -1; | |
} | |
/// 另一方是否无法走下一步 | |
bool isOtherNotMoveVisible(List<int> list1, List<int> list2) { | |
List<int> list = [...list1, ...list2]; | |
for (var index in list2) { | |
for (var moveIndex in moveVisibleList[index]) { | |
if (!list.contains(moveIndex)) { | |
return false; | |
} | |
} | |
} | |
return true; | |
} |
至此,我们完成了整个游戏的实现!✿✿ヽ(°▽°)ノ✿
优化
上面已经把对角棋游戏的整个功能都实现了,但仔细思考还是有可以优化的点。
1. 对手视角棋子调整
前面我们都是以自己的视角来实现棋子,但实际使用时对手应该对方的视角来观察。因此,我们需要把对手的棋子顺序和文案进行倒序处理。代码如下:
// 对手棋子倒序显示 | |
piecesOffsetList | |
.add(PiecesBean(boardOffsetList[0], Colors.greenAccent, "3")); | |
piecesOffsetList | |
.add(PiecesBean(boardOffsetList[1], Colors.greenAccent, "2")); | |
piecesOffsetList | |
.add(PiecesBean(boardOffsetList[2], Colors.greenAccent, "1")); | |
/// 绘制单个棋子 | |
void _drawChessPiece( | |
Canvas canvas, PiecesBean bean, bool reverse, bool isSelected) { | |
... | |
canvas.save(); | |
canvas.translate(offset.dx, offset.dy); | |
// 对手棋子旋转180度,文案倒序显示 | |
if (reverse) canvas.rotate(pi); | |
... | |
canvas.restore(); | |
} |
2. CustomPainter刷新机制优化
正常我们使用setStatus进行Widget刷新,但考虑到我们只需要对 CustomPainter 进行刷新,我们可以使用 Listenable 对象来控制画布的刷新,这样是最高效的方式。对于多个 Listenable 对象使用 Listenable.merge 来合并。代码如下:
// 选择棋子序号 | |
ValueNotifier<int> piecesIndex = ValueNotifier<int>(-1); | |
// 点击棋盘位置 | |
ValueNotifier<int> boardIndex = ValueNotifier<int>(-1); | |
CustomPaint( | |
size: Size(width, height), | |
painter: DiagonalChessPainter( | |
... | |
piecesIndex: piecesIndex, | |
boardIndex: boardIndex, | |
repaint: Listenable.merge([piecesIndex, boardIndex]), | |
), | |
) | |
@override | |
bool shouldRepaint(covariant DiagonalChessPainter oldDelegate) { | |
return oldDelegate.repaint != repaint; | |
} |
总结
虽然对角棋看起来非常简单,但我们完全实现却没有那么容易。中间用到了 Canvas 的 translate 、rotate 、save/restore 、矩形 线段 文本 的绘制、CustomPainter 的 Listenable 对象刷新、手势的处理等知识,算是对 Canvas 的绘制有一个大概的回顾。
实践出真知!看十遍相关资料不如敲一遍代码。后续我也会继续出相关系列文章,如果大家喜欢的话,请关注一下吧!
最后附上 项目源码地址