Flutter開(kāi)發(fā)之對(duì)角棋游戲?qū)崿F(xiàn)實(shí)例詳解
前沿
關(guān)于對(duì)角棋相信大家都不陌生,其憑借著規(guī)則簡(jiǎn)單又靈活多變成為我們童年不可缺少的益智游戲。
今天我將用Flutter來(lái)實(shí)現(xiàn)一個(gè)對(duì)角棋游戲,即鞏固自己Flutter的繪制和手勢(shì)知識(shí),也希望這篇文章對(duì)大家有所幫助。
演示效果
老規(guī)矩,我們先演示下實(shí)現(xiàn)的最終效果:

對(duì)角棋規(guī)則
首先我們還是回顧下對(duì)角棋游戲的規(guī)則,這里借用 百度百科 的規(guī)則說(shuō)明:
棋盤(pán):象棋棋盤(pán)中,將士所在的帶對(duì)角線的田字框。
棋子:雙方各持三子,顏色不同。
初始:如圖1所示,各自對(duì)立。
勝利條件:其中一方三子,占據(jù)一條對(duì)角線,或者對(duì)方?jīng)]有棋子可以移動(dòng)。
玩法:沿著棋盤(pán)劃線,雙方交互移動(dòng)棋子,一次一只能移動(dòng)一步,不包括交叉。

實(shí)現(xiàn)思路
- 棋盤(pán)。繪制棋盤(pán)
- 棋子。繪制棋子
- 手勢(shì)。處理點(diǎn)擊棋子及移動(dòng)位置手勢(shì)
- 規(guī)則。規(guī)則分為棋子移動(dòng)規(guī)則、游戲勝利規(guī)則兩部分

具體實(shí)現(xiàn)
1. 繪制棋盤(pán)
說(shuō)到繪制,我們需要先創(chuàng)建 CustomPaint 通過(guò)自定義 CustomPainter 來(lái)實(shí)現(xiàn)。
CustomPaint(
size: Size(width, height),
painter: DiagonalChessPainter(),
)
考慮到我們要適配不同的手機(jī)尺寸,因此我們先通過(guò) LayoutBuilder 測(cè)量整個(gè) Widget 的尺寸,并計(jì)算棋盤(pán)在屏幕上位置。
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,
),
),
);
},
)
我們這里定義棋盤(pán)的九個(gè)點(diǎn),從屏幕左上角開(kāi)始,代碼如下:
width = constraints.maxWidth;
height = constraints.maxHeight;
rWidth = width * 0.4;
rHeight = width * 0.6;
// 棋盤(pán)各個(gè)點(diǎn)
// 第一行,從左到右
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 中進(jìn)行繪制,先繪制一個(gè)矩形,然后繪制四條對(duì)角線完成整個(gè)棋盤(pán)的繪制,代碼如下:
// 繪制矩形
canvas.drawRect(
Rect.fromLTRB(-rWidth, -rHeight, rWidth, rHeight), _chessboardPaint);
// 繪制對(duì)角線
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);
棋盤(pán)展示效果:

2. 繪制棋子
我們先定義6個(gè)棋子,并添加必要的繪制用到的屬性。代碼如下:
// 定義棋子位置、顏色、文案
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"));
關(guān)于棋子的繪制,這里為了簡(jiǎn)化,繪制一個(gè)簡(jiǎn)單的圓+序號(hào)文案即可。
/// 繪制單個(gè)棋子
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();
}
文案的繪制。通過(guò)TextPainter進(jìn)行繪制,繪制時(shí)注意先textPainter.layout()測(cè)量后再計(jì)算偏移量。
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));
// 定義步數(shù),判斷那一方走下一步棋
int step = 0;
棋子展示效果:

3. 手勢(shì)處理
通常我們下棋時(shí),首先點(diǎn)擊某個(gè)棋子,然后點(diǎn)擊需要移動(dòng)到的位置。此時(shí),棋子先變成選中狀態(tài),然后移動(dòng)到選中的位置,完成棋子的移動(dòng)。
對(duì)于手勢(shì)的處理,F(xiàn)lutter通過(guò)GestureDetector來(lái)實(shí)現(xiàn)。我們先定義GestuerDetecotr,將child設(shè)為CustomPiant,我們?cè)趏nTapDown中處理用戶點(diǎn)擊。代碼如下:
GestureDetector(
onTapDown: _onTapDown,
child: CustomPaint(
size: Size(width, height),
painter: DiagonalChessPainter(),
),
);
通過(guò)手勢(shì)點(diǎn)擊的位置和棋子的位置進(jìn)行比較即可判斷當(dāng)前是否點(diǎn)擊的是棋子。代碼如下:
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)) {
// 更新棋子選中狀態(tài)
piecesIndex.value = index;
// debugPrint("piecesIndex:$piecesIndex");
return;
}
}
/// 是否是當(dāng)前點(diǎn)
bool _checkPoint(double dx1, double dy1, double dx2, double dy2) =>
(dx1 - dx2).abs() < 40 && (dy1 - dy2).abs() < 40;
若判斷當(dāng)前不是點(diǎn)擊的棋子,則判斷是否點(diǎn)擊的棋盤(pán)中9個(gè)點(diǎn)的位置,若是則判斷是否已選中棋子,若選中則修改棋子的Offset重新繪制。代碼如下:
// 若點(diǎn)擊是棋盤(pán)
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;
}
}
實(shí)現(xiàn)效果如下:

4. 游戲規(guī)則
1. 棋子移動(dòng)規(guī)則
我們下棋時(shí),每一方只能走一步交替進(jìn)行下棋,且棋子只能按照棋盤(pán)規(guī)則行走。代碼如下:
// 棋盤(pán)各個(gè)點(diǎn)可移動(dòng)位置
final moveVisibleList = [
[1, 3, 4],
[0, 2, 4],
[1, 4, 5],
[0, 4, 6],
[0, 1, 2, 3, 5, 6, 7, 8],
[2, 4, 8],
[3, 4, 7],
[4, 6, 8],
[4, 5, 7], //第9個(gè)點(diǎn)可移動(dòng)位置
];
我們分別在點(diǎn)擊棋子和棋盤(pán)位置時(shí)判斷是否當(dāng)前一方的棋子走,若是當(dāng)前棋子是否可以走到該棋盤(pán)位置。代碼如下:
// 更新棋子選中狀態(tài)
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. 比賽勝利規(guī)則
我們首先根據(jù)對(duì)角棋的勝利規(guī)則定義比賽勝利需要移動(dòng)到的位置。代碼如下:
// 勝利的位置
final winPositions = [
[0, 4, 8],
[2, 4, 6]
];
在棋子每次發(fā)生移動(dòng)后來(lái)校驗(yàn)當(dāng)前棋子是否匹配勝利的位置,若匹配則彈窗提示勝利方。代碼如下:
/// 獲取勝利的狀態(tài)
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;
}
}
最后,當(dāng)一方無(wú)法走下一步時(shí),自動(dòng)判斷另外一方勝利。代碼如下:
/// 獲取勝利的狀態(tài)
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;
}
// 判斷另外一方是否已無(wú)法走棋
if (isOtherNotMoveVisible(
[index1, index2, index3], [otherIndex1, otherIndex2, otherIndex3])) {
return i;
}
}
return -1;
}
/// 另一方是否無(wú)法走下一步
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;
}
至此,我們完成了整個(gè)游戲的實(shí)現(xiàn)!??ヽ(°▽°)ノ?
優(yōu)化
上面已經(jīng)把對(duì)角棋游戲的整個(gè)功能都實(shí)現(xiàn)了,但仔細(xì)思考還是有可以優(yōu)化的點(diǎn)。
1. 對(duì)手視角棋子調(diào)整
前面我們都是以自己的視角來(lái)實(shí)現(xiàn)棋子,但實(shí)際使用時(shí)對(duì)手應(yīng)該對(duì)方的視角來(lái)觀察。因此,我們需要把對(duì)手的棋子順序和文案進(jìn)行倒序處理。代碼如下:
// 對(duì)手棋子倒序顯示
piecesOffsetList
.add(PiecesBean(boardOffsetList[0], Colors.greenAccent, "3"));
piecesOffsetList
.add(PiecesBean(boardOffsetList[1], Colors.greenAccent, "2"));
piecesOffsetList
.add(PiecesBean(boardOffsetList[2], Colors.greenAccent, "1"));
/// 繪制單個(gè)棋子
void _drawChessPiece(
Canvas canvas, PiecesBean bean, bool reverse, bool isSelected) {
...
canvas.save();
canvas.translate(offset.dx, offset.dy);
// 對(duì)手棋子旋轉(zhuǎn)180度,文案倒序顯示
if (reverse) canvas.rotate(pi);
...
canvas.restore();
}
2. CustomPainter刷新機(jī)制優(yōu)化
正常我們使用setStatus進(jìn)行Widget刷新,但考慮到我們只需要對(duì) CustomPainter 進(jìn)行刷新,我們可以使用 Listenable 對(duì)象來(lái)控制畫(huà)布的刷新,這樣是最高效的方式。對(duì)于多個(gè) Listenable 對(duì)象使用 Listenable.merge 來(lái)合并。代碼如下:
// 選擇棋子序號(hào)
ValueNotifier<int> piecesIndex = ValueNotifier<int>(-1);
// 點(diǎn)擊棋盤(pán)位置
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;
}
總結(jié)
雖然對(duì)角棋看起來(lái)非常簡(jiǎn)單,但我們完全實(shí)現(xiàn)卻沒(méi)有那么容易。中間用到了 Canvas 的 translate 、rotate 、save/restore 、矩形 線段 文本 的繪制、CustomPainter 的 Listenable 對(duì)象刷新、手勢(shì)的處理等知識(shí),算是對(duì) Canvas 的繪制有一個(gè)大概的回顧。
實(shí)踐出真知!看十遍相關(guān)資料不如敲一遍代碼。后續(xù)我也會(huì)繼續(xù)出相關(guān)系列文章,如果大家喜歡的話,請(qǐng)關(guān)注一下吧!
最后附上 項(xiàng)目源碼地址
以上就是Flutter開(kāi)發(fā)之對(duì)角棋游戲?qū)崿F(xiàn)實(shí)例詳解的詳細(xì)內(nèi)容,更多關(guān)于Flutter 對(duì)角棋游戲的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android編程實(shí)現(xiàn)創(chuàng)建,刪除,判斷快捷方式的方法
這篇文章主要介紹了Android編程實(shí)現(xiàn)創(chuàng)建,刪除,判斷快捷方式的方法,結(jié)合實(shí)例形式分析了Android編程針對(duì)快捷方式的常用操作技巧,需要的朋友可以參考下2017-02-02
ubuntu用wifi連接android調(diào)試程序的步驟
這篇文章主要介紹了ubuntu用wifi連接android調(diào)試程序的步驟,需要的朋友可以參考下2014-02-02
Android自定義頂部導(dǎo)航欄控件實(shí)例代碼
這篇文章主要介紹了Android自定義頂部導(dǎo)航欄控件實(shí)例代碼,需要的朋友可以參考下2017-12-12
解析android創(chuàng)建快捷方式會(huì)啟動(dòng)兩個(gè)應(yīng)用的問(wèn)題
本篇文章是對(duì)關(guān)于android創(chuàng)建快捷方式會(huì)啟動(dòng)兩個(gè)應(yīng)用的問(wèn)題進(jìn)行了詳細(xì)的分析介紹,需要的朋友參考下2013-06-06
Android如何自定義EditText光標(biāo)與下劃線顏色詳解
在android開(kāi)發(fā)中 EditTextText是我們經(jīng)常用到的,我們使用時(shí)會(huì)有一些小問(wèn)題,下面這篇文章主要給大家介紹了關(guān)于利用Android如何自定義EditText光標(biāo)與下劃線顏色的相關(guān)資料,需要的朋友可以參考借鑒,下面來(lái)一起看看吧。2017-08-08
Android實(shí)現(xiàn)熱門標(biāo)簽的流式布局
這篇文章主要介紹了Android實(shí)現(xiàn)熱門標(biāo)簽的流式布局的詳細(xì)方法,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2015-12-12
Android編程設(shè)計(jì)模式之責(zé)任鏈模式詳解
這篇文章主要介紹了Android編程設(shè)計(jì)模式之責(zé)任鏈模式,詳細(xì)分析了Android設(shè)計(jì)模式中責(zé)任鏈模式的概念、原理、應(yīng)用場(chǎng)景、使用方法及相關(guān)操作技巧,需要的朋友可以參考下2017-12-12
Android 添加系統(tǒng)設(shè)置屬性的實(shí)現(xiàn)及步驟
這篇文章主要介紹了Android 添加系統(tǒng)設(shè)置屬性的實(shí)現(xiàn)及步驟的相關(guān)資料,需要的朋友可以參考下2017-07-07

