react 版跳棋
最近在學校閒著也是閒著,打算複習一下react,想寫點什麼東西,最後決定寫一個跳棋打發閒暇的時光。最後按照自己設想的寫完了,由於是基於create-react-app的架子,不能放在codepen上有一點遺憾,不過本文最後給了線上地址和github地址,大家感興趣可以看看,歡迎批評指正。
效果圖
總體思路
我們把跳棋這個專案先拆分為以下步驟
- 畫出棋盤和棋子 (UI 層面)
- 判斷棋子的可跳路徑 (邏輯層面)
- 跳棋的動畫(UI + 邏輯層面)
關於畫出棋盤(UI)
我們仔細觀察棋盤, 首先棋盤是由6個等邊三角形(棋子)和中間一個正六邊形(空閒的棋盤)組成。這裡就教大家怎麼畫出這6個等邊三角形吧, 先給個示意圖吧。
在畫這些棋子之前我們先做出如下思考,首先這6個三角形是對稱的,即可以通過繞某一點旋轉得到,其次任意兩個棋子的距離是相同的。
第一步: 畫出輪廓
即需要畫出 AEI 和 CMG 這兩個等邊三角形。
這一步可以用border實現,這也是比較常規的方法,然後CMG就是AEI旋轉180deg得到的圖形。這裡要注意一下,旋轉的中心點是O點,大家要設定好transform-origin.
當然最最重要的一點,棋盤是要適配的,即它的寬度不能寫死,我們把它寫成一個變數最好了,為了大家看的清楚,我擷取一段scss給大家看看。
$width: 250px;
$height: $width * sqrt(3);
$rotateY: round(($width * 2 * 2 / 3 ) * sqrt(3) / 2);
$containerX: 2 * $width;
$containerY: 2 * $rotateY;
$radius: getGap($width, 0.4) / 2; //0.4 是gap 和 直徑的比
$gap: 2 * 0.4 * $radius;
複製程式碼
這裡width和rotateY分別指示意圖中加粗黑框寬的1/2和,高的1/2。 黑框的寬高分別為上述的containerX,containerY。radius指小球的半徑,gap指棋子之間的間距。這裡所有的屬性只依賴於變數width,方便棋盤的放大和縮小,我們可以寫下如下式子。
第二步: 畫出棋子(乾貨來了)
我們首先畫出角BAN上的10個棋子,我們從上往下畫,一共四層,每一層為當前層數個棋子。我們把AE上的棋子做為每一層的起始點。
width 黑色容器的寬 也為三角形邊長 = A E
而三角形的每條邊上平均放置了12個棋子,即棋子間距為 width / 12
第一層 chess-0-0 起始點(width/2, 0)
第二層 chess-0-0 起始點(width/2 - 棋子間距/2, 棋子間距 * Math.sqrt(3)/2)
chess-0-1 (chess-0-0.x + gap, chess-0-0.y)
...
@for $i from 0 to 4{
@for $j from 0 to ($i+1){
left: $width - $width * 2 / (4 * 3 * 2) * $i + $j * $width * 2 / (4 * 3);
top: $i * $gap + 2 * $radius * $i) * sqrt(3) / 2
}
}
複製程式碼
這時候棋子單邊的棋子就出來了,可是我們需要6邊的棋子呀,難道我們要一邊一邊畫嗎? 答案肯定是No NO No啊!
好,我們現在按照我們之前的思路把角依次BAN旋轉60deg。首先我們有幾個注意點:
-
我們在繪製棋子的時候left為棋子的左上角,這個左上角並不是棋盤的頂點,我們需要通過css(transform: translate(-50% -50%))將球的左上角的點移至棋盤上。
-
我們棋子的父標籤是那個黑色的container,而我們旋轉的中心點是上圖中的O點。
我們來推導一些公式 (點的旋轉公式)
A 點座標 (x1,y1) 與 x 軸夾角為 b
B 點座標 (x2, y2) 與 AO 夾角為 c
這裡換算成極座標
則 x1 = rcosb y1 = rsinb
x2 = rcos(b+c) = rcosbcosc - rsinbsinc = x1cosc - y1sinc
y2 = rsin(b+c) = rsinbcosc + rcoscsinb = x1sinc + y1cosc
複製程式碼
但是我們的中心點預設是容器的左上角,不是容器的中心點呀。容易,我們座標平移一下就好了。
x2 = (x - w)cosc - (y - h)sinc
y2 = (x - w)sinc + (y - h)cisc
這時候的x2,y2 是相對於O中心點旋轉後的座標, 我們再返到之前的座標系中。
x2 = (x - w)cosc - (y - h)sinc + w
y2 = (x - w)sinc + (y - h)cisc + h
複製程式碼
沒錯,就是這樣,我們現在對BAN旋轉吧,貼上scss的程式碼(話說三層迴圈真是有一點麻煩呢!)
@for $k from 0 to 6{
@for $i from 0 to 4{
@for $j from 0 to ($i+1){
.chess-#{$k}-#{$i}-#{$j}{
left: cos(60deg * $k) * ($width - $width * 2 / (4 * 3 * 2) * $i + $j * $width * 2 / (4 * 3) - $width) - sin(60deg * $k) * (($i * $gap + 2 * $radius * $i) * sqrt(3) / 2 - $rotateY) + $width;
top: sin(60deg * $k) * ($width - $width * 2 / (4 * 3 * 2) * $i + $j * $width * 2 / (4 * 3) - $width) + cos(60deg * $k) * (($i * $gap + 2 * $radius * $i) * sqrt(3) / 2 - $rotateY) + $rotateY;
}
}
}
}
複製程式碼
最後棋盤就是下面這樣了(掘金不支援iframe 大家戳開連結看codepen吧)!!! 是不是很有趣呢 :)
See the Pen chessBoard by shadowwalkerzero (@shadowwalkerzero) on CodePen.第三步 畫出棋盤
我們現在需要畫出棋盤上的點,即棋子可以放的點。拆分一下棋盤,棋盤是由中心的正六邊形和那6個角組成,正6邊形按照我們之前的方法繪製是不是很簡單呢? 就是把三角形上的點繪出來,然後旋轉6次就好了。這裡就不贅述了。
計算棋子可跳路徑(邏輯)
因為棋子都是絕對定位的,我們要計算下一跳的點,必然要計算出它的精確座標呀。可是我該怎麼表示這些點呢?拿二維座標嗎?當然可以了,畢竟是2d,但是這樣就太笨了,太笨了!
我們需要觀察一下棋盤,其實棋子可以跳的點最終可以表現為6邊形,畫個示意圖吧。
所以我們需要把跳棋上的點表示成3元組。例如正六邊形斜上方的點就該表示成chess-1-2-2 單位是當前軸上兩個點的距離。
這裡乾脆也把給棋子編號的方法也告訴大家吧。其實也很簡單,就是利用點到直線間距離公式( d = Math.abs(AX + BY + C) / Math.sqrt(A^2+B^2); )
我們對一個點分別向3條軸計算三次距離,距離一樣的就在一條線上。
看一下編號結束後的棋盤吧。
計算棋子的落點(廣度優先)
這裡我們需要明確一下跳棋的規則,跳棋是既可以向周圍滾一步,也可以隔著棋子跳的。 為了標示棋盤該點已被佔用,我們需要引入一個屬性isOccupy來標示。這裡給出棋盤上的點的資料結構。
{
key: `,
isChess: ,
locate: '',
style: {
background: ,
left: ,
top:,
zIndex: 2,
transform:
}
複製程式碼
這裡解釋一下各個屬性 isChess 用來區分棋盤上的點和棋子,locate表示棋子或棋盤上的點的編號。style標示棋子或棋盤上的點座標,還有一些輔助屬性,比如當前要走的棋子會顯得大一點。既然我們已經獲取到了關於棋子和棋盤上的所有資訊,下一步就是要讓棋子跳起來了。
我們再畫一個簡單的示意圖
X 0 (0) X 0
我們以 0 表示棋子, X表示棋盤上的空點。(0) 表示正要跳的棋子。
顯然流程異常的簡單:
1. 從當前(0) 位置分別向左,右搜尋,直至找到左邊和右邊的距離最近0(注意我們是三條軸,分別向三條軸搜尋)。
2. 以剛找到的點為基點,當正要跳的棋子和找到的點距離為長度,找出對稱的點,即棋子的 落點。
3. 將上一步的落點做為當前點。
回到第一步
複製程式碼
稍微分析一下 會發現是很簡單的遞迴,發現從當前點向左右搜尋找點,真是和二叉樹一模一樣,問題就轉變為二叉樹的遍歷上了。
當讓遍歷方法非常多,深度優先演算法和廣度優先演算法都可以,但是作者這裡推薦廣度優先演算法,因為廣度優先演算法除錯更方便,層數淺,我也是基於廣度優先演算法實現的。
我們這裡簡單縷一下廣度優先演算法的思路,寫一下偽碼。
思路是 一個佇列 path: []
壓入左右搜尋的點 path.push[A.left, A.right]
壓出left path: [A.right]
壓入A.left 左右搜尋的點 path: [A.right, A.left.left, A.left.right]
//itemMove 指當前要跳的棋子
//position 放置了棋盤上的點和棋子
// 廣度優先佇列
//passNode 收集棋子的落點
calculatePath = (itemMove, position, allPath, passNode) => {
let path = getValidPoint(itemMove) //獲取三條軸上的落點
allPath.push(...path);
if(allPath.length > 1){
let nextJump = allPath[0];
allPath.splice(0, 1);
passNode.push(nextJump); //這就是下一跳了
}
return nextJump ? calculatePath(nextJump, position, allPath, passNode) : passNode;
}
複製程式碼
當然這裡有一個小問題,即成環的問題,你跳過去,下一跳又給你跳回來,就會死迴圈。這個問題解決的方法也很多,把走過的路徑節點都標示一下,參照上面的偽碼,所有的路徑節點都在pressNode下,只要這個節點走過了,就不允許再走一遍。
現在要讓棋子真的跳起來(深度優先)
為了更好的互動,讓跳棋跳起來是必須的!我們先捋捋我們現有的資料
- 跳棋的起跳點和最終的落點,
- 以及中間的過渡點(就是上一節中跳棋的所有落點)。
然後我們的問題: 就是當使用者點選任意一個落點時,要讓跳棋一級一級的跳過去。
為了把路徑確定出來,我們必須把這些過渡點連線起來,當使用者點選任意一個落點時候,我們需要計算從起跳點和落點的距離。
1.來把這些落點連線起來吧
在確定跳棋的落點的時候,我們檢索出了一個棋子的所有落點。為了不讓這些資料丟失, 我們可以用記錄一下。
nextJump.parent = startJump
複製程式碼
nextJump 就是startJump 的所有落點,我們用parent來儲存它們的聯絡。現在我們就要把它們串起來了,先從簡單的例子出發吧。在設定為parent後,我們大概得到了一組類似這樣的資料。
let points = [{
name: 'A',
parents: ['C']
},{
name: 'C',
parents: ['D']
}, {
name: 'D',
parents: ['E']
}, {
name: 'E',
parents: ['L', 'F']
}, {
name: 'F',
parents: ['C']
},
{
name: 'L',
parents: []
}]
複製程式碼
上面的資料對應的示意圖如下,大致為一個聯通圖。
假設我們從起點A出發要到終點L,求出A - L 的路徑。常規方法就是深度優先了。我們簡單描述一下流程(主要注意成環的問題)。
1.路徑佇列 [L] 當前節點 L
2.獲得 L 的 parent [E]
3.E進棧 [L,E]
4.E. 做為下一個節點,要是 E 沒有 parent 或者成環 E 出棧
重複1 直至找到A
附上程式碼
let flag = false;
function scanPath(start, end, path) {
let nextLists = getParents(start); //獲取節點的parent
let nextJump = false;
for (let i = 0; i < nextLists.length; i++) {
nextJump = nextLists[i];
if (path.indexOf(nextJump) < 0) {
!flag && path.push(nextJump);
if (nextJump === end) {
flag = true;
}
!flag && scanPath(nextJump, end, path);
}
}
!flag && path.pop();
return path;
}
複製程式碼
這裡我們就把起始點和落點的路徑找出來了,現在就要讓棋子做動畫了。
2.棋子跳吧(作者沒有很好的解決)
我們描述一下我們上一步獲得的路徑,大致為 ['11-2-4', '6-8-13', '14-8-9', '9-3-8']。這裡的元素對應上述我們對棋盤編號的三元組。 表示 棋子要從 11-2-4 -> 6-8-13 -> 14-8-9 -> 9-3-8 一路跳過去。
似乎實現也不難,在我們剛學前端的時候,不借助react也可以做到,對dom做tiansition動畫,然後監聽onTransitionEnd事件,在這裡面繼續做下一步動畫,自己也試著用這種最土的方法做。只是在react中一切都是state了。
比如當前節點要跳 4跳,我們拿到路徑陣列 ['11-2-4', '6-8-13', '14-8-9', '9-3-8'] 起跳點 11-2-4 我們 找到11-2-4的棋子 把它的style 設定成 陣列的[index] 就好了,我這裡的解決方案是。
styles.map((item, index) => {
setTimeout(() => {
this.setState({
nowStyle: styles[index]
})
}, 600 * (index + 1));
});
複製程式碼
styles 就是路徑陣列裡路徑節點的style,主要是left,top。nowStyle 就是起跳的棋子要不斷應用的style。放一張自己的測試圖,時間為600ms的原因是因為transition的時間是 500ms,總要先讓動畫做完把。
但是這裡我並不認為這個方案可行,react的diff render時間 還有不同瀏覽器效能的時間都不可控,settimeout真是下下策。
中間也求助過一些很優秀的react動畫庫,比如react-motion。發現它能做一組動畫的只有StaggeredMotion,但是在文件中,作者寫明瞭:
(No onRest for StaggeredMotion because we haven't found a good semantics for it yet. Voice your support in the issues section.)
就是對組動畫不提供回撥,也就是說我們沒法監聽這組動畫裡的某一個動畫,真是遺憾。
由於作者並不覺得這個解決方案很好,所以沒有放在應用在專案的線上中,但是放在github目錄下,感興趣的同學可以提供自己的解決方案。
一些零散的問題
-
比如怎麼判斷輸贏
這個問題我們可以在初始化棋盤就解決掉,比如假設現在執棋方是綠色,那麼它的目的地是粉色,一開始的時候就把各個執棋方的目的地的位置計算好,每走一步,就check一下。
-
比如怎麼做到棋手輪流下
這個我們需要一個狀態位控制,表示當前棋手,下完一步,加1對所有選手取餘就好了。
關於react動畫的一點思考
以下為本人個人觀點,不保證正確。
-
react做這種需要一定計算的網頁,最讓我擔心的是效能,每走一步就涉及到多個狀態,比如isOccupy 佔位,下一跳的座標。要是setstate({}) 肯定不行,因為這是非同步的,會批量處理。所以只能setstate((prevState, prevProps) => {}),這樣大量的diff,對效能肯定是個挑戰。這裡作者是沒有實時更新資料的,計算完一次更新,但是這樣就不方便state 除錯,而且redux寫多了,資料一旦不更新,心裡就很慌。
-
react 由於資料驅動,確實程式碼更加簡潔,但是相比之前寫的原生動畫,狀態太多,所有的狀態都擠在state裡,邏輯會很的很混亂(也有可能是自己水平有限)
-
我覺得react並不適應動畫場景,我們知道jquery 的animate本身也是基於setInterval實現的,而react 本身框架極其複雜,我們很難把控時間(也是自己水平有限)。