react 版跳棋

FounderIsShadowWalker發表於2019-02-27

react 版跳棋

        最近在學校閒著也是閒著,打算複習一下react,想寫點什麼東西,最後決定寫一個跳棋打發閒暇的時光。最後按照自己設想的寫完了,由於是基於create-react-app的架子,不能放在codepen上有一點遺憾,不過本文最後給了線上地址和github地址,大家感興趣可以看看,歡迎批評指正。

效果圖

chess

總體思路

我們把跳棋這個專案先拆分為以下步驟

  1. 畫出棋盤和棋子 (UI 層面)
  2. 判斷棋子的可跳路徑 (邏輯層面)
  3. 跳棋的動畫(UI + 邏輯層面)

關於畫出棋盤(UI)

        我們仔細觀察棋盤, 首先棋盤是由6個等邊三角形(棋子)和中間一個正六邊形(空閒的棋盤)組成。這裡就教大家怎麼畫出這6個等邊三角形吧, 先給個示意圖吧。

keyboard

        在畫這些棋子之前我們先做出如下思考,首先這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點。

react 版跳棋

	我們來推導一些公式 (點的旋轉公式)
	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邊形,畫個示意圖吧。

img

所以我們需要把跳棋上的點表示成3元組。例如正六邊形斜上方的點就該表示成chess-1-2-2 單位是當前軸上兩個點的距離。

這裡乾脆也把給棋子編號的方法也告訴大家吧。其實也很簡單,就是利用點到直線間距離公式( d = Math.abs(AX + BY + C) / Math.sqrt(A^2+B^2); )

我們對一個點分別向3條軸計算三次距離,距離一樣的就在一條線上。

看一下編號結束後的棋盤吧。

img

計算棋子的落點(廣度優先)

這裡我們需要明確一下跳棋的規則,跳棋是既可以向周圍滾一步,也可以隔著棋子跳的。 為了標示棋盤該點已被佔用,我們需要引入一個屬性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: []
}]
複製程式碼

上面的資料對應的示意圖如下,大致為一個聯通圖。

access

假設我們從起點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,總要先讓動畫做完把。

img

但是這裡我並不認為這個方案可行,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目錄下,感興趣的同學可以提供自己的解決方案。

一些零散的問題

  1. 比如怎麼判斷輸贏

    這個問題我們可以在初始化棋盤就解決掉,比如假設現在執棋方是綠色,那麼它的目的地是粉色,一開始的時候就把各個執棋方的目的地的位置計算好,每走一步,就check一下。

  2. 比如怎麼做到棋手輪流下

    這個我們需要一個狀態位控制,表示當前棋手,下完一步,加1對所有選手取餘就好了。

關於react動畫的一點思考

以下為本人個人觀點,不保證正確。

  1. react做這種需要一定計算的網頁,最讓我擔心的是效能,每走一步就涉及到多個狀態,比如isOccupy 佔位,下一跳的座標。要是setstate({}) 肯定不行,因為這是非同步的,會批量處理。所以只能setstate((prevState, prevProps) => {}),這樣大量的diff,對效能肯定是個挑戰。這裡作者是沒有實時更新資料的,計算完一次更新,但是這樣就不方便state 除錯,而且redux寫多了,資料一旦不更新,心裡就很慌。

  2. react 由於資料驅動,確實程式碼更加簡潔,但是相比之前寫的原生動畫,狀態太多,所有的狀態都擠在state裡,邏輯會很的很混亂(也有可能是自己水平有限)

  3. 我覺得react並不適應動畫場景,我們知道jquery 的animate本身也是基於setInterval實現的,而react 本身框架極其複雜,我們很難把控時間(也是自己水平有限)。

專案相關

github地址

線上地址

相關文章