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 本身框架极其复杂,我们很难把控时间(也是自己水平有限)。