Flutter【手勢&繪製】圍棋棋盤

語言: CN / TW / HK

theme: juejin highlight: a11y-dark


前言

今天我們繼續探索繪製與手勢的組合的實踐,不知道掘金的朋友有沒有喜歡下圍棋的,其實下棋也是繪製和手勢結合的一個典型的實際應用,畢竟棋盤肯定是需要我們自定義繪製出來的,那麼今天我們就用Flutter來繼續繪製一個標準的圍棋棋盤吧。

繪製

繪製之前,我們先了解下,圍棋棋盤和棋子以及規則,在我們下棋時,每落下一顆棋子,棋盤是固定不變的,變化的只是棋盤上的棋子,那麼在下棋重新整理畫布時,我們只需重新整理棋盤上的棋子即可,棋子落子生根,落下不可隨意走動,那麼在手勢中處理中我們只需處理點選事件即可,沒有移動以及其他事件處理,Ok,有了這個思路,接下來我們開始繪製。

棋盤

第一步,畫棋盤,圍棋中,目前主流的遊戲規則有9、13、19路三種棋盤,9路適合新手入門,而19路則是圍棋發展至今的標準的圍棋棋盤,也是各種比賽預設的圍棋棋盤。

題外話:雖然只是19 * 19路的棋盤,但是就在這小小的圍棋棋盤上可出現的變化絕對是一個無法想象的天文數字,以至於圍棋發展了2000多年,古今棋局至今沒有完全相同的一局棋,正所謂千古無同局說的就是圍棋,這也是人類對圍棋為之著迷的原因,正是這樣的原因,人類相較於電腦一直統治著圍棋遊戲水平的天花板,直至2016年Google研發的Alphago問世之後,首次與當時人類頂尖棋手李世石的對局中,以4-1戰績橫掃人類,人類統治圍棋的神話也隨之崩塌了,而贏的那一局也成為了目前為止人類戰勝Alphago的唯一的一局棋,之後Alphago橫掃圍棋棋壇,對戰人類再無敗績,也側面說明了人類對於圍棋當中無窮奧祕的掌握首次被人工智慧超越,之後人類不服,約Alphago在17年與當時世界冠軍柯潔進行的三番局對弈,結果Alphago以3-0完勝柯潔,隨之Google宣佈,Alphago將永久退出圍棋屆所有比賽,並將原始碼開源,再之後,國內圍棋人工智慧如雨後春筍般的湧出,至此,人工智慧統治圍棋的時代到來。

哈哈,扯遠了,接下來我們開始畫棋盤了, 首先建立棋盤和棋子,棋盤為背景,棋子為前景,接下來首先繪製棋盤。

dart CustomPaint( size: Size( // MediaQuery.of(context).size.width, MediaQuery.of(context).size.width), size, size), painter: _QpPainter(),// 棋盤 foregroundPainter: _QzPainter(),// 棋子 ), 我們知道圍棋棋盤由相同線條組成的是一個正方矩形。首先繪製背景以及豎線:
image.png
dart //eSide 為線之間的距離 for (int i = 0; i < qpSize; i++) { canvas.save(); canvas.translate(eSide * i, 0); if (i == 0 || i == qpSize - 1) { paint..strokeWidth = 2; } else { paint..strokeWidth = 1; } canvas.drawLine(Offset(0,0), Offset(0, size.height), paint..color = Colors.black); canvas.restore(); } 繪製橫線:
image.png

是不是很簡單,我們將外部周圍的線粗1個畫素單位,也就是在繪製橫豎線第一條和最後一條時畫筆加粗即可。

image.png

星位及天元

接下來我們繪製星位和天元,19路圍棋棋盤為例,一共有8個星位以及1個天元,分別是4個角星位,4個邊星位,加中間1個天元,,用大黑點標記,方便定位,

image.png

程式碼:
```dart double eSide = size.width / 18; //格子邊長

/// 星位 座標資料 List offsetXList = []; // 19 路 // 左星位 offsetXList.add(Offset(eSide * 3, eSide * 3)); offsetXList.add(Offset(eSide * 3, eSide * 9)); offsetXList.add(Offset(eSide * 3, eSide * 15)); // 中間 offsetXList.add(Offset(eSide * 9, eSide * 3)); offsetXList.add(Offset(eSide * 9, eSide * 9)); // 天元 offsetXList.add(Offset(eSide * 9, eSide * 15)); // 右星位 offsetXList.add(Offset(eSide * 15, eSide * 3)); offsetXList.add(Offset(eSide * 15, eSide * 9)); offsetXList.add(Offset(eSide * 15, eSide * 15));

for (var i = 0; i < offsetXList.length; i++) { canvas.drawCircle(offsetXList[i], 3, paint..style = PaintingStyle.fill); } ```

座標

接下來繪製棋盤座標,上面只是棋盤的核心區域,其實除了下棋區域,圍棋棋盤上還會標有座標,可以對某一個點進行精準的描述,不同棋盤座標位置不同,這裡我們將他定位到左邊和上邊,那麼我們上面的棋盤的下棋區域就不能佔據全部位置了,需要騰出點空間留給座標,比如我們將左邊和上邊分別留出40個畫素的值設為margin1,下方和右方留出20個畫素值設為margin2,變成這樣, 格子邊長計算公式就變為: dart double eSide = (size.width - margin1 - margin2) / (qpSize - 1); //格子邊長 棋盤樣式就變為這樣:
image.png

接下來我們就可以在左邊和上邊繪製座標了,我們先繪製左邊座標,座標為1-19數字對齊網格線。

首先我們再來回憶下繪製文字的方法, dart var textPainter = TextPainter( text: TextSpan( text: "888", style: TextStyle( fontSize: 40, foreground: Paint() ..style = PaintingStyle.fill ..strokeWidth = 1, )), textAlign: TextAlign.left, maxLines: 1, ellipsis: "...", textDirection: TextDirection.ltr); textPainter.layout(); textPainter.paint(canvas, Offset(0,0)); 上方程式碼繪製888文字,預設繪製區域為文字區域的左上角和原點重合,

image.png

這裡我們希望讓888對於原點居中,可以通過下方程式碼獲取文字Size大小,將文字向左上平移,即可讓文字和原點居中。 dart Size textSize = textPainter.size; textPainter.paint(canvas, Offset(-textSize.width / 2, -textSize.height / 2)); canvas.drawRect( Rect.fromLTRB(0, 0, textSize.width, textSize.height) .translate(-textSize.width / 2, -textSize.height / 2), _paint ..color = Colors.blue.withAlpha(88) ..style = PaintingStyle.fill); image.png
ok,有了以上了解,接下來我們就可以開始繪製座標了,按照以上方式,先繪製個1,因為我們的棋盤畫布原點是沒有經過平移的,所以在原始的左上角,是如下效果,
image.png
接著我們平移畫布,將文字對齊最下面的網格線,向下移動區域長度為 margin1 +18個格子邊長,

```dart

canvas.save(); canvas.translate( margin1 - 20, (size.height - margin2) - eSide * 18); canvas.drawLine(Offset(margin1, margin1), Offset(margin1, size.height - margin2), paint..color = Colors.black); canvas.restore();

``1`就跑到我們想要的位置了。

image.png
接下來的工作就變的簡單了,迴圈改變平移即可。

複製貼上迴圈以後最終得到了以下效果: image.png

棋盤的基本繪製到這裡就結束了。

棋子

接下來開始繪製棋子, 棋子其實還是比較簡單的,黑白兩個圓即可,

image.png

但是這麼看著太平面了,接下來這裡我們可以給棋子新增一點點細節,讓棋子看起來更加的飽滿立體些,首先棋子我們不要設定純黑和純白色,純黑和純白在裝置上顯示上有時不太友好,第二我們從棋子的左上角像右下角調整漸變色進行線性漸變,讓棋子有一些陰影過渡的感覺,這樣看起來就有些立體感了。

程式碼: dart canvas.save(); canvas.rotate(pi * 2 - pi / 4); canvas.drawPath( path, _paint ..shader = ui.Gradient.linear(Offset(0, -40), Offset(10, 10), [Color(0xFFa5a5a5), Color(0xFF333333)]) ..style = PaintingStyle.fill); // 白字 path.addOval(Rect.fromCenter(center: Offset.zero, width: 40, height: 40)); path.close(); canvas.translate(60, 0); canvas.rotate(pi * 2 - pi / 4); canvas.drawPath( path, _paint ..shader = ui.Gradient.linear(Offset(0, -40), Offset(10, 10), [Color(0xFFa5a5a5), Color(0xFFF3F3F3)]) ..style = PaintingStyle.fill); canvas.restore(); 調整完畢的效果:

image.png

看起來是不是比剛開始純黑白稍稍舒服了一點。

手數

下一步繪製棋子上的手數,我們都知道圍棋棋子上是可以顯示手數的,這裡我們只需要在棋子座標中心新增文字即可, 也非常的簡單。

效果:

image.png

插曲: 這裡遇到了一個小插曲,當我們的文字字號設定比較小的時候,例如fontSize = 7.2時,文字沒有在文字區域中居中, 以8數字為例,因為8是左右對稱數字,但是在字號較小時它出現了比較明顯的不對稱的效果,出現這樣的效果會導致,當棋子過小,手數的數字不會出現在棋子中央,會出現一些比較明顯的偏移,大一點是看不出來的。

字號7.2:
image.png

當我調整為7的時候,貌似左右對稱,但是上下間距和7.2時差距又不同,

字號7:明顯下邊距變大了
image.png

當我將字號調大一點時,例如14時的效果:

字號14:
image.png

好像確實沒有完全對稱,只是字號大一些沒有那麼明顯,不知道文字的區域是怎麼計算的,有知道的小夥伴可以告訴我下。感謝感謝~~

當前標記

接下來繼續繪製當前標記,圍棋下棋時當落子時,需要對剛落下的子進行一個標記,好讓對手一眼看到你下到了哪個地方,這裡我們就簡單的在棋子的下方繪製一個倒三角,三角形上底邊寬度為字號的大小,也是非常的簡單。

image.png

到這裡,棋子也畫完了,接下來我們就需要結合手勢將這些棋子下到棋盤上。

手勢

手勢這裡比較的簡單,我們只需要處理點選事件即可。首先我們先來確定手勢觸控區域的落子的範圍。

手勢點選觸發落子範圍

因為整個棋盤是我們的手勢觸控區域,由於座標原因,真正的手勢觸發區域應該是真正的棋盤,見下圖外圍紅框,可以看到但是當手指在外圍紅框內時,才認為是落子範圍,因為棋子是要下在橫豎線條的交叉點上的,所以這裡的觸控範圍我們設定為以交叉點為中心,邊長 = 棋盤格子邊長,見下圖藍色區域的範圍,對於邊角,我們需要向外擴充套件格子邊長的一半,見下方小紅框,這時我們就認為當前使用者準備落到區域中心的這個位置,其他點同理。
image.png
點選事件處理程式碼: dart onPanDown: (e) { double dx = e.localPosition.dx; double dy = e.localPosition.dy; double eSide = (widget.size - (margin1 + margin2)) / (widget.qpSize - 1); // 格子邊長 if (dx < margin1 - eSide / 2 || dy < margin1 - eSide / 2 || dx - (margin1 + ((widget.qpSize - 1) * eSide) + eSide / 2) > 0 || dy - (margin1 + ((widget.qpSize - 1) * eSide) + eSide / 2) > 0) { return; } print("邊長:$eSide "); // 將原點設定為上方紅框左上角 dx = dx - (margin1 - eSide / 2); dy = dy - (margin1 - eSide / 2); print("點選座標:dx= $dx dy= $dy"); dx = dx - dx % eSide; dy = dy - dy % eSide; print("點選計算後棋盤上的座標:dx= $dx dy= $dy"); for (var i = 0; i < qzList.length; i++) { double x = goList.value[i].dx; double y = goList.value[i].dy; if (dx == x && dy == y) { //說明這個位置已經有棋子 return return; } } qzList.add(Offset(dx, dy)); List<Offset> qList = []; qList.addAll(qzList); // 更新棋子 goList.value = qList; print("新增座標:dx= $dx dy= $dy"); }, 獲取到最終落子座標,接下來的工作就比較簡單了,只需更新資料,重新整理畫布將棋子、手數以及標記繪製到對應座標即可。

看下效果:

Jul-29-2022 17-33-10.gif

試下

如果在手機上操作,由於螢幕小的原因,免不了會有誤觸的時候,這時候首次點選棋盤時就需要有一個提示是否下在此處的功能,那麼我們就需要再增加一個試下的座標點資料, dart // 試下點資料 ValueNotifier<Offset?> tryOffset = ValueNotifier(null); 試下時,這裡將棋子原本顏色設定為了0.6的的透明度, dart if (qzType == QzType.black) { if (isTry) { qzColors = [ Color(0xFFa5a5a5).withOpacity(0.6), Color(0xFF333333).withOpacity(0.6) ]; } else { qzColors = [Color(0xFFa5a5a5), Color(0xFF333333)]; } } else { if (isTry) { qzColors = [ Color(0xFFa5a5a5).withOpacity(0.6), Color(0xFFF3F3F3).withOpacity(0.6) ]; } else { qzColors = [Color(0xFFa5a5a5), Color(0xFFF3F3F3)]; } }

圍棋講究落子生根,所以這裡沒有加悔棋功能,悔棋功能加的話也很簡單,只需將棋子陣列的最後一條資料刪除重新整理畫布即可。

之後我們將棋盤大小、棋盤路數、是否顯示手數等欄位對外暴露,就是一個支援9、13、19路的圍棋棋盤了,現在離真正的下棋就差規則演算法了,這個以後有時間再加。

最終演示:

Jul-29-2022 17-40-28.gif

圍棋棋盤的繪製以及手勢互動到這裡就基本完成了, 因為棋盤用的Flutter的純UI繪製,所以天然的支援跨端展示,之後有時間加上圍棋的遊戲規則,連個網,就可以對戰了。

總結

本篇文章主要介紹了圍棋棋盤的繪製以及落子的手勢互動,並不能直接下棋哈,因為我還沒加遊戲演算法規則,主要圍棋的規則雖然簡單,但是算起來還是有點複雜的,這個有時間再研究下,有機會可以做一個內網聯機圍棋對戰平臺,嘿嘿,那本篇文章到這裡就結束了,希望對你有所幫助~

我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿