【自己给自己题目做】:如何用裸的 Canvas 实现魔方效果
最终demo -> 3d魔方
体验方法:
- 浮动鼠标找到合适的位置,按空格键暂停
- 选择要翻转的3*3模块,找到相邻两个正方体,鼠标点击第一个正方体,并且一直保持鼠标按下的状态直到移到第二个正方体后放开,比如下图:
(鼠标点击1处,然后一直移动到2处松开,中间一行的3*3模块绕图示方向发生转动)
- 按空格键,魔方恢复转动,继续寻找下一个要翻动的目标
示意图如下(请尽量使用chrome):
正方体绘制回顾
Canvas之蛋疼的正方体绘制体验 说到了如何用canvas在画布上绘制三维效果的正方体,并且最终给出了一个多正方体的demo -> 多正方体
具体的过程可以参照前文,这里简要的再做个概括。
代码定义了四个对象,分别是garden(场景)、cube(正方体)、face(面)、ball(点),从属关系如下:
而魔方demo中,一个场景有27个正方体,每个正方体有6个面和8个点,每个面有4个点;每帧的渲染中先根据cube的体心排序(前文中说了这不是最佳方案),然后根据排序后的结果绘制每个cube的可见面。归根结底,每帧的渲染就是对每个正方体8个点的渲染!
有了这部分经验,绘制一个无交互的魔方demo就可以手到擒来了 -> 无交互魔方
无交互魔方demo和前面的多正方体demo最大的区别就是面的颜色,其实很简单,在初始化的时候可以传入一个color数组,比如这样:
// 红 橙 蓝 绿 黄 白 // 0 1 2 3 4 5 window.colors = ['#ff0000', '#ff6600', '#0000ff', '#00ff00', '#ffff00', '#ffffff']; var color = [ // 第一排 [0, 5, 5, 3, 5, 5], [0, 5, 5, 5, 5, 5], [0, 2, 5, 5, 5, 5], [0, 5, 5, 3, 5, 5], [0, 5, 5, 5, 5, 5], [0, 2, 5, 5, 5, 5], [0, 5, 5, 3, 5, 4], [0, 5, 5, 5, 5, 4], [0, 2, 5, 5, 5, 4], // 第二排 [5, 5, 5, 3, 5, 5], [5, 5, 5, 5, 5, 5], [5, 2, 5, 5, 5, 5], [5, 5, 5, 3, 5, 5], [5, 5, 5, 5, 5, 5], [5, 2, 5, 5, 5, 5], [5, 5, 5, 3, 5, 4], [5, 5, 5, 5, 5, 4], [5, 2, 5, 5, 5, 4], // 第三排 [5, 5, 1, 3, 5, 5], [5, 5, 1, 5, 5, 5], [5, 2, 1, 5, 5, 5], [5, 5, 1, 3, 5, 5], [5, 5, 1, 5, 5, 5], [5, 2, 1, 5, 5, 5], [5, 5, 1, 3, 5, 4], [5, 5, 1, 5, 5, 4], [5, 2, 1, 5, 5, 4], ];
初始化每个cube时多传入一个参数,这样就能实现你要的颜色了。
问题的关键
如何交互,如何实现玩家想要的3*3模块的旋转才是问题的关键。
我最终想到的是像demo一样选择两个相邻的正方体,然后一个监听mousedown事件,另一个监听mouseup事件,表面看上去,两个有顺序的正方体似乎能确定了那个想要旋转的3*3模块了(其实不然)。而在寻找3*3模块之前,我们首先要解决的是如何确定这两个正方体。
-
两个正方体的确定
因为我们在画布上展现出来的图案其实都是h5的原生api绘上去的,并不像dom一样能写个事件监听。如何得到这两个正方体,思来想去我觉得唯一方法就是点的判断。
遍历27个正方体在二维空间的6*27个面,判断鼠标点击是否在面内。这里可以把场景内的cubes倒排,因为cubes在每帧中都要根据体心重新排序,越后面的越先绘制,而鼠标点击的cubes按多数情况下应该是离视点近的,所以可以从后到前遍历,这样可以加快寻找速度;而遍历一个正方体6个面时,不可见面也不用判断。这个问题的最后就是二维系上一个点在一个凸四边形内的判断。具体可以参考 -> 判断一个点是否在给定的凸四边形内
我用了博文的第一种方法。
由于数学能力的欠缺,一开始我把叉积当做点积了,debug了良久才发现。
鼠标监听:
document.addEventListener('mousedown', function(event){ window.rotateArray = []; var obj = canvas.getBoundingClientRect(); // 鼠标点击的地方在canvas上的(x,y)坐标 var x = event.clientX - obj.left; var y = event.clientY - obj.top; var v = new Vector2(x, y) var ans = getCubeIndex(v); if(ans) window.rotateArray.push(ans); });
getCubeIndex函数就是遍历27个cube和每个cube中6个面的一个两层循环。
点在凸四边形的判断:
// 判断点m是否在顺时针方向的a,b,c,d四个点组成的凸四边形内 function isPointIn(a, b, c, d, m) { var f = b.minus(a).dot(m.minus(a)); if(f <= 0) return false; var f = c.minus(b).dot(m.minus(b)); if(f <= 0) return false; var f = d.minus(c).dot(m.minus(c)); if(f <= 0) return false; var f = a.minus(d).dot(m.minus(d)); if(f <= 0) return false; return true; }
至此,2个被点击的正方体在27个cube中的位置已经找出。
-
3*3模块的确定
接着需要寻找由两个正方体确定的3*3模块。
我们知道,玩魔方每次旋转的肯定是个3*3的模块,而这样的模块在一个魔方中有3*3=9个。而2个相邻的正方体能不能确定唯一的3*3模块?答案是不能的,如下图:
上图1和2两个正方体确定了图示的两个3*3模块。其实如果两个正方体的位置是在魔方的棱上,那么就能确定两个。我们暂时不管它,一个也好,两个也罢,先把它找出来。
怎么找?最开始我想到的是维护一个三维数组,初始化给每个cube一个index值,值和三维数组值相对应,每次魔方旋转时同时改变三维数组的值,这样找到这个3*3的模块就是遍历三维数组的三个维度,找到任一维度的3*3=9个正方体中如果有包含点击得到的两个正方体,则为一组解。后来被我放弃了,三维数组的维护实在是太麻烦了。
最后我用深度搜索来解,寻找一条长度为8的闭合回路。已经确定了前两个值,因为这条闭合回路不会经过魔方最中心的那个正方体,所以每个点的下一个点的取值最多只有4种情况,最大复杂度也就O(4^6),完全在可控范围之内。而且搜过的点标记掉不用继续搜索,答案几乎秒出。
深度搜索如下:
function dfs(index) { var cubes = garden.cubes; if(index === 8) { var dis = cubes[window.rotateArray[0]].pos3.getDistance(cubes[window.rotateArray[7]].pos3); if(Math.abs(dis - 60) > 10) return; // 判断8个点在一个平面 var cubes = garden.cubes; var a = cubes[window.rotateArray[1]].pos3.minus(cubes[window.rotateArray[0]].pos3); var b = cubes[window.rotateArray[7]].pos3.minus(cubes[window.rotateArray[6]].pos3); // 找一个面的法向量 var v = undefined; for(var i = 0; i < 27; i++) { var c = cubes[i].pos3; if(a.isPerpTo(c) && b.isPerpTo(c)) { v = c; break; } if(i === 26 && v === undefined) return; } // 判断任意相邻向量是否垂直法向量 for(var i = 0; i < 7; i++) { var a = cubes[window.rotateArray[i]].pos3.minus(cubes[window.rotateArray[i + 1]].pos3); if(!a.isPerpTo(v)) return; } //////////////////////////////////////////////// // 如果是最前面的面,return var zz = 0; for(var i = 0; i < 8; i++) zz += cubes[window.rotateArray[i]].pos3.z; zz /= 8; if(zz < -40) return; // 如果是俄罗斯方块那种类型 var vv = new Vector3(); for(var i = 0; i < 8; i+=2) { vv.x += cubes[window.rotateArray[i]].pos3.x; vv.y += cubes[window.rotateArray[i]].pos3.y; vv.z += cubes[window.rotateArray[i]].pos3.z; } vv.x /= 4; vv.y /= 4; vv.z /= 4; var flag = false; for(var i = 0; i < 27; i++) { var vvv = cubes[i].pos3 if(vv.getDistance(vvv) > 5) continue; flag = true; break; } if(!flag) return; for(var i = 0; i < 8; i++) { window.isFindRoute = true; window.rotateFinalArray[i] = window.rotateArray[i]; } return; } if(window.isFindRoute) return; for(var i = 0; i < 27; i++) { if(window.hash[i]) continue; // 魔方中点不找,待会应该判断魔方中点,不应该直接赋值 if(cubes[i].pos3.isEqual(new Vector3())) continue; var front = window.rotateArray[index - 1]; var dis = cubes[front].pos3.getDistance(cubes[i].pos3); if(Math.abs(dis - 60) > 10) continue; window.rotateArray[index] = i; window.hash[i] = true; dfs(index + 1); window.hash[i] = false; } }
我是先找一条长度为8的闭合回路,找到后再进行判断:(其实边找边判断效率会更高)
1、判断8个点是否在同一个面上。 可以任选两条不平行的向量做分别垂直于这两条向量的法向量,如果这8个点成面,则该法向量垂直于平面内两点组成的任意向量。
2、如果是最前面的面,则return。 这个判断有点坑爹,先看下图:
如果操作的是1和2两个正方体,得到两条回路如图。我们想要的应该是上面那个3*3模块的操作,剔除的是前面一块,这里我根据平均的z值进行判断,如果z太小(距离视点太近,认为是前面一块),则剔除。其实这是不准确的,所以demo有时会出错,而这点也是操作正方体体心无法解决的,如果要解决,程序复杂度可能要上升一个级别,要精确到对面的判断。所以这里采用了模糊判断。这也是最前面说的有两条回路如何选择的方法。
3、找到了同一平面的闭合回路,但是不符合要求,如下:
因为闭合回路所组成的3*3模块的中心肯定是魔方上某正方体的体心,这里就根据此近似判断。
至此,我们得到了需要翻转的3*3=9个正方体。
-
旋转轴的确定
得到了需要翻转的正方体,最后只需要得到翻转轴即可。
我们已经得到绕x轴和y轴旋转后的坐标变化,那么是否有绕任意轴的坐标变化公式呢?luckily,答案是有的 -> 三维空间里一个点绕矢量旋转后的新的点的坐标
这样就好办了,我们可以获取需要翻转面的法向量,然后单位化即可。而这条法向量其实肯定经过27个正方体中某个的体心,遍历即可。但是一个面的法向量有两条,还好我们获取的闭合回路是有方向的,因为翻转的角度肯定是90度,我们可以知道3*3模块中某个正方体翻转90度后的实际位置,其实就是闭合回路往前两个的正方体的位置;我们获取的任一法向量,将值代入函数中进行计算,选择某个正方体,如果该正方体绕该法向量旋转90度后得到的值就是正确的位置,即这条法向量为正解。(实际上另一条需要旋转270度)
于是我们写成一个rotateP函数:
rotateP: function() { if(this.cube.isRotate) { this.cube.index++; // 一个点达到60改变isRotate值?应该8个点全部达到吧 if(this.cube.index === 480) { this.cube.isRotate = false; this.cube.index = 0; } var c = Math.cos(this.cube.garden.angleP); var s = Math.sin(this.cube.garden.angleP); // (x,y,z)为经过原点的单位向量 var x = this.cube.rotateVector.x; var y = this.cube.rotateVector.y; var z = this.cube.rotateVector.z; var new_x = (x * x * (1 - c) + c) * this.pos3.x + (x * y * (1 - c) - z * s) * this.pos3.y + (x * z * (1 - c) + y * s) * this.pos3.z; var new_y = (y * x * (1 - c) + z * s) * this.pos3.x + (y * y * (1 - c) + c) * this.pos3.y + (y * z * (1 - c) - x * s) * this.pos3.z; var new_z = (x * z * (1 - c) - y * s) * this.pos3.x + (y * z * (1 - c) + x * s) * this.pos3.y + (z * z * (1 - c) + c) * this.pos3.z; this.pos3.reset(new_x, new_y, new_z); }
这样在每帧的渲染中,需要旋转的cube的点的坐标的位置也会随着rotateP函数改变,于是出现旋转效果。
总结
完整代码:
1 <!DOCTYPE html> 2 <html> 3 <head> 4 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> 5 <title> 3d魔方 </title> 6 <script> 7 window.onload = function() { 8 var canvas = document.getElementById('canvas'); 9 var ctx = canvas.getContext('2d'); 10 var garden = new Garden(canvas); 11 window.garden = garden; 12 13 // 0红 1橙 3蓝 4绿 5黄 6白 // face面绘制顺序 前 右 后 左 上 下 14 window.colors = ['#ff0000', '#ff6600', '#0000ff', '#00ff00', '#ffff00', '#ffffff']; 15 16 // 记录鼠标操作的两个cube的index值 17 window.rotateArray = []; 18 window.isStill = false; 19 20 // 设置二维视角原点(一般为画布中心) 21 garden.setBasePoint(500, 250); 22 23 var color = [ 24 // 第一排 25 [2, 5, 5, 5, 5, 5], 26 [0, 5, 5, 5, 0, 5], 27 [2, 0, 5, 5, 4, 5], 28 [0, 5, 5, 4, 5, 5], 29 [4, 5, 5, 5, 5, 5], 30 [5, 3, 5, 5, 5, 5], 31 [3, 5, 5, 5, 5, 0], 32 [0, 5, 5, 5, 5, 3], 33 [1, 4, 5, 5, 5, 2], 34 35 // 第二排 36 [5, 5, 3, 5, 1, 5], 37 [5, 5, 3, 5, 2, 5], 38 [5, 5, 3, 5, 0, 5], 39 [5, 5, 5, 0, 5, 5], 40 [5, 5, 5, 5, 5, 5], 41 [5, 1, 1, 5, 5, 5], 42 [5, 5, 0, 3, 5, 4], 43 [5, 5, 5, 5, 5, 3], 44 [5, 1, 3, 5, 5, 3], 45 46 // 第三排 47 [5, 5, 3, 2, 4, 5], 48 [5, 5, 1, 5, 4, 5], 49 [5, 2, 0, 5, 4, 5], 50 [5, 5, 1, 3, 5, 5], 51 [5, 5, 1, 5, 5, 5], 52 [5, 2, 3, 5, 5, 5], 53 [5, 5, 1, 4, 5, 5], 54 [5, 5, 1, 5, 5, 2], 55 [5, 2, 5, 5, 5, 1], 56 ]; 57 58 var r = 60; 59 var num = 0; 60 var a = [-r, 0, r]; 61 62 // 初始化 63 for(var l = 0; l < 3; l++) // z轴 64 for(var j = 0; j < 3; j++) // y轴 65 for(var i = 0; i < 3; i++) { // x轴 66 var v = new Vector3(a[i], a[j], a[l]); 67 garden.createCube(v, r / 2 - 2, color[num++]); // 初始化cube的index值 68 } 69 70 garden.setListener(); 71 addListener(); 72 73 // 渲染 74 setInterval(function() {garden.render();}, 1000 / 60); 75 }; 76 77 function addListener() { 78 document.addEventListener('mousedown', function(event){ 79 window.rotateArray = []; 80 var obj = canvas.getBoundingClientRect(); 81 // 鼠标点击的地方在canvas上的(x,y)坐标 82 var x = event.clientX - obj.left; 83 var y = event.clientY - obj.top; 84 var v = new Vector2(x, y) 85 var ans = getCubeIndex(v); 86 if(ans) 87 window.rotateArray.push(ans); 88 }); 89 90 document.addEventListener('mouseup', function(event){ 91 var obj = canvas.getBoundingClientRect(); 92 // 鼠标点击的地方在canvas上的(x,y)坐标 93 var x = event.clientX - obj.left; 94 var y = event.clientY - obj.top; 95 var v = new Vector2(x, y) 96 var ans = getCubeIndex(v); 97 if(ans) 98 window.rotateArray.push(ans); 99 100 window.isFindRoute = false; 101 window.hash = []; 102 window.hash[window.rotateArray[0]] = window.hash[window.rotateArray[1]] = true; 103 104 // 保存回路答案 105 window.rotateFinalArray = []; 106 dfs(2); 107 108 // 计算中间点在cube数组中的位置 109 var index = getMiddleCube(); 110 rotateFinalArray.push(index); 111 112 // 必定是体心指向某个cube中心的一条向量,返回该cube的index 113 var index2 = getRotateVector(); 114 115 var cubes = garden.cubes; 116 for(var i = 0; i < rotateFinalArray.length; i++) { 117 cubes[rotateFinalArray[i]].isRotate = true; 118 cubes[rotateFinalArray[i]].rotateVector = cubes[index2].pos3.normalize(); 119 } 120 }); 121 122 document.onkeydown = function(e) { 123 if(e.keyCode === 32) { 124 window.isStill = !window.isStill; 125 } 126 } 127 } 128 129 function dfs(index) { 130 var cubes = garden.cubes; 131 if(index === 8) { 132 var dis = cubes[window.rotateArray[0]].pos3.getDistance(cubes[window.rotateArray[7]].pos3); 133 if(Math.abs(dis - 60) > 10) 134 return; 135 136 // 判断同一平面 137 var cubes = garden.cubes; 138 var a = cubes[window.rotateArray[1]].pos3.minus(cubes[window.rotateArray[0]].pos3); 139 var b = cubes[window.rotateArray[7]].pos3.minus(cubes[window.rotateArray[6]].pos3); 140 141 // 找一个面的法向量,如果8点成面,那么肯定有两条符合的向量 142 var v = undefined; 143 for(var i = 0; i < 27; i++) { 144 var c = cubes[i].pos3; 145 if(a.isPerpTo(c) && b.isPerpTo(c)) { 146 v = c; 147 break; 148 } 149 if(i === 26 && v === undefined) return; 150 } 151 152 // 判断任意相邻向量是否垂直法向量 153 for(var i = 0; i < 7; i++) { 154 var a = cubes[window.rotateArray[i]].pos3.minus(cubes[window.rotateArray[i + 1]].pos3); 155 if(!a.isPerpTo(v)) return; 156 } 157 158 // 如果是最前面的面,return 159 var zz = 0; 160 for(var i = 0; i < 8; i++) 161 zz += cubes[window.rotateArray[i]].pos3.z; 162 zz /= 8; 163 if(zz < -40) return; 164 165 // 如果是俄罗斯方块那种类型 166 var vv = new Vector3(); 167 for(var i = 0; i < 8; i+=2) { 168 vv.x += cubes[window.rotateArray[i]].pos3.x; 169 vv.y += cubes[window.rotateArray[i]].pos3.y; 170 vv.z += cubes[window.rotateArray[i]].pos3.z; 171 } 172 vv.x /= 4; 173 vv.y /= 4; 174 vv.z /= 4; 175 var flag = false; 176 for(var i = 0; i < 27; i++) { 177 var vvv = cubes[i].pos3 178 if(vv.getDistance(vvv) > 5) continue; 179 flag = true; 180 break; 181 } 182 if(!flag) return; 183 184 for(var i = 0; i < 8; i++) { 185 window.isFindRoute = true; 186 window.rotateFinalArray[i] = window.rotateArray[i]; 187 } 188 return; 189 } 190 191 if(window.isFindRoute) return; 192 193 for(var i = 0; i < 27; i++) { 194 if(window.hash[i]) continue; 195 // 魔方中点不找 196 if(cubes[i].pos3.isEqual(new Vector3())) continue; 197 var front = window.rotateArray[index - 1]; 198 var dis = cubes[front].pos3.getDistance(cubes[i].pos3); 199 if(Math.abs(dis - 60) > 10) continue; 200 window.rotateArray[index] = i; 201 window.hash[i] = true; 202 dfs(index + 1); 203 window.hash[i] = false; 204 } 205 } 206 207 // 不在同一条直线的两个向量才能确定一个平面 208 function getRotateVector() { 209 // 垂直于rotate面的任意两条向量 210 var cubes = garden.cubes; 211 var a = cubes[window.rotateFinalArray[1]].pos3.minus(cubes[window.rotateFinalArray[0]].pos3); 212 var b = cubes[window.rotateFinalArray[7]].pos3.minus(cubes[window.rotateFinalArray[6]].pos3); 213 214 // 这里应该有两个 215 for(var i = 0; i < 27; i++) { 216 var c = cubes[i].pos3; 217 // 因为有两个向量,所以通过istrue函数判断是否是答案所要的向量 218 if(a.isPerpTo(c) && b.isPerpTo(c) && isTrue(i)) 219 return i; 220 } 221 } 222 223 // 判断window.rotateFinalArray里的第0个cube经过90度旋转是否能到达第2个cube的位置,判断体心即可 224 function isTrue(index) { 225 var cubes = garden.cubes; 226 // 旋转向量 227 var v = cubes[index].pos3; 228 // 单位化 229 v = v.normalize(); 230 231 var a = cubes[window.rotateFinalArray[0]]; 232 var c = Math.cos(Math.PI / 2); 233 var s = Math.sin(Math.PI / 2); 234 // (x,y,z)为经过原点的单位向量 235 var x = v.x; 236 var y = v.y; 237 var z = v.z; 238 var new_x = (x * x * (1 - c)+c) * a.pos3.x + (x*y*(1-c)-z*s) * a.pos3.y + (x*z*(1-c)+y*s) * a.pos3.z; 239 var new_y = (y*x*(1-c)+z*s) * a.pos3.x + (y*y*(1-c)+c) * a.pos3.y + (y*z*(1-c)-x*s) * a.pos3.z; 240 var new_z = (x*z*(1-c)-y*s) * a.pos3.x + (y*z*(1-c)+x*s) * a.pos3.y + (z*z*(1-c)+c) * a.pos3.z; 241 var b = new Vector3(new_x, new_y, new_z); 242 243 // 判断旋转后所得的b向量是否和rotateArray[2]相同 244 var f = b.isEqual(cubes[window.rotateFinalArray[2]].pos3); 245 return f; 246 } 247 248 function getMiddleCube() { 249 var v = new Vector3(); 250 var cubes = garden.cubes; 251 for(var i = 0; i < 8; i += 2) { 252 v.x += cubes[window.rotateFinalArray[i]].pos3.x; 253 v.y += cubes[window.rotateFinalArray[i]].pos3.y; 254 v.z += cubes[window.rotateFinalArray[i]].pos3.z; 255 } 256 257 v.x /= 4; 258 v.y /= 4; 259 v.z /= 4; 260 for(var i = 0; i < 27; i++) { 261 if(v.isEqual(cubes[i].pos3)) 262 return i; 263 } 264 } 265 266 function getCubeIndex(v) { 267 var length = garden.cubes.length; 268 var cubes = garden.cubes; 269 // 遍历cube,因为经过排序前面的cube先绘,所以倒着判断 270 var num = 0; 271 for(var i = length -1 ; i >= 0; i--) { 272 // 遍历六个面 273 for(var j = 5; j>=0; j--) { 274 num ++; 275 var f = cubes[i].f[j]; 276 if(f.angle < 0) continue; // 夹角大于90不可见 277 // 可见则判断 278 var isFound = isPointIn(f.a.pos2, f.d.pos2, f.c.pos2, f.b.pos2, v); 279 if(isFound) { // 找到了 280 // 越大越晚绘,所以越前面 281 return i; 282 } 283 } 284 } 285 } 286 287 // 判断点m是否在顺时针方向的a,b,c,d四个点组成的凸四边形内 288 function isPointIn(a, b, c, d, m) { 289 var f = b.minus(a).dot(m.minus(a)); 290 if(f <= 0) return false; 291 292 var f = c.minus(b).dot(m.minus(b)); 293 if(f <= 0) return false; 294 295 var f = d.minus(c).dot(m.minus(c)); 296 if(f <= 0) return false; 297 298 var f = a.minus(d).dot(m.minus(d)); 299 if(f <= 0) return false; 300 return true; 301 } 302 303 // Garden类 304 function Garden(canvas) { 305 this.canvas = canvas; 306 this.ctx = this.canvas.getContext('2d'); 307 308 // 三维系在二维上的原点 309 this.vpx = undefined; 310 this.vpy = undefined; 311 this.cubes = []; 312 this.angleY = Math.PI / 180 * 0; 313 this.angleX = Math.PI / 180 * 0; 314 this.angleP = Math.PI / 180 * 1.5; 315 } 316 317 Garden.prototype = { 318 setBasePoint: function(x, y) { 319 this.vpx = x; 320 this.vpy = y; 321 }, 322 323 createCube: function(v, r, color, index) { 324 this.cubes.push(new Cube(this, v, r, color)); 325 }, 326 327 render: function() { 328 this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); 329 this.cubes.sort(function (a, b) { 330 if(b.pos3.z !== a.pos3.z) 331 return b.pos3.z - a.pos3.z; 332 else if(b.pos3.x !== a.pos3.x) { 333 if(b.pos3.x >= 0 && a.pos3.x >= 0 || b.pos3.x <= 0 && a.pos3.x <= 0) 334 return Math.abs(b.pos3.x) - Math.abs(a.pos3.x); 335 else return b.pos3.x - a.pos3.x; 336 } else { 337 if(b.pos3.y >= 0 && a.pos3.y >= 0 || b.pos3.y <= 0 && a.pos3.y <= 0) 338 return Math.abs(b.pos3.y) - Math.abs(a.pos3.y); 339 else return b.pos3.y - a.pos3.y; 340 } 341 }); 342 343 for(var i = 0; i < this.cubes.length; i++) { 344 this.cubes[i].render(); 345 } 346 }, 347 348 setListener: function() { 349 var that = this; 350 document.addEventListener('mousemove', function(event){ 351 var obj = canvas.getBoundingClientRect(); 352 var x = event.clientX - obj.top - that.vpx; 353 var y = event.clientY - obj.left - that.vpy; 354 that.angleY = -x * 0.0001; 355 that.angleX = y * 0.0001; 356 }); 357 } 358 }; 359 360 // Cube类 361 function Cube(garden, v, r, color) { 362 this.garden = garden; 363 364 // 正方体中心和半径 365 this.pos3 = v; 366 this.r = r; 367 368 // this.angleX = Math.PI / 180 * 1; 369 // this.angleY = Math.PI / 180 * 1; 370 371 // cube的8个点 372 this.p = []; 373 374 // cube的6个面 375 this.f = []; 376 377 // 6个面的颜色集 378 this.colors = color; // color数组 379 380 // 是否在玩家需要翻转的3*3矩形中 381 this.isRotate = false; 382 383 // rotateP函数中围绕的轴的单位向量 384 this.rotateVector = new Vector3(1, 0, 0); 385 386 // 已翻转的次数,每次翻转1.5度,需要翻转60次 387 this.index = 0; 388 389 this.init(); 390 } 391 392 Cube.prototype = { 393 init: function() { 394 // 正方体的每个顶点都是一个ball类实现 395 this.p[0] = new Ball(this, this.pos3.x - this.r, this.pos3.y - this.r, this.pos3.z - this.r); 396 this.p[1] = new Ball(this, this.pos3.x - this.r, this.pos3.y + this.r, this.pos3.z - this.r); 397 this.p[2] = new Ball(this, this.pos3.x + this.r, this.pos3.y + this.r, this.pos3.z - this.r); 398 this.p[3] = new Ball(this, this.pos3.x + this.r, this.pos3.y - this.r, this.pos3.z - this.r); 399 this.p[4] = new Ball(this, this.pos3.x - this.r, this.pos3.y - this.r, this.pos3.z + this.r); 400 this.p[5] = new Ball(this, this.pos3.x - this.r, this.pos3.y + this.r, this.pos3.z + this.r); 401 this.p[6] = new Ball(this, this.pos3.x + this.r, this.pos3.y + this.r, this.pos3.z + this.r); 402 this.p[7] = new Ball(this, this.pos3.x + this.r, this.pos3.y - this.r, this.pos3.z + this.r); 403 404 // 正方体6个面 405 this.f[0] = new Face(this, this.p[0], this.p[1], this.p[2], this.p[3], this.colors[0]); 406 this.f[1] = new Face(this, this.p[3], this.p[2], this.p[6], this.p[7], this.colors[1]); 407 this.f[2] = new Face(this, this.p[6], this.p[5], this.p[4], this.p[7], this.colors[2]); 408 this.f[3] = new Face(this, this.p[4], this.p[5], this.p[1], this.p[0], this.colors[3]); 409 this.f[4] = new Face(this, this.p[0], this.p[3], this.p[7], this.p[4], this.colors[4]); 410 this.f[5] = new Face(this, this.p[5], this.p[6], this.p[2], this.p[1], this.colors[5]); 411 }, 412 413 render: function() { 414 for(var i = 0; i < 8; i++) 415 this.p[i].render(); 416 417 // 八个点的坐标改变完后,改变cube体心坐标,为下一帧cube的排序作准备 418 this.changeCoordinate(); 419 420 for(var i = 0; i < 6; i++) 421 this.f[i].angle = this.f[i].getAngle(); 422 423 // 从小到大排 424 // 不排序会闪 425 this.f.sort(function (a, b) { 426 return a.angle > b.angle; 427 }); 428 429 for(var i = 0; i < 6; i++) { 430 // 夹角 < 90,绘制 431 if(this.f[i].angle > 0) 432 this.f[i].draw(); 433 } 434 }, 435 436 // cube体心坐标改变 437 changeCoordinate: function() { 438 this.pos3.x = this.pos3.y = this.pos3.z = 0; 439 for(var i = 0; i < 8; i++) { 440 this.pos3.x += this.p[i].pos3.x; 441 this.pos3.y += this.p[i].pos3.y; 442 this.pos3.z += this.p[i].pos3.z; 443 } 444 this.pos3.x /= 8; 445 this.pos3.y /= 8; 446 this.pos3.z /= 8; 447 } 448 }; 449 450 // Face类 451 // a, b, c, d为四个ball类 452 // color为数字 453 function Face(cube, a, b, c, d, color) { 454 this.cube = cube; 455 this.a = a; 456 this.b = b; 457 this.c = c; 458 this.d = d; 459 // this.color = '#' + ('00000' + parseInt(Math.random() * 0xffffff).toString(16)).slice(-6); 460 this.color = window.colors[color]; 461 // 面的法向量和面心到视点向量的夹角的cos值 462 this.angle = undefined; 463 } 464 465 Face.prototype = { 466 draw: function() { 467 var ctx = this.cube.garden.ctx; 468 ctx.beginPath(); 469 ctx.fillStyle = this.color; 470 ctx.moveTo(this.a.pos2.x, this.a.pos2.y); 471 ctx.lineTo(this.b.pos2.x, this.b.pos2.y); 472 ctx.lineTo(this.c.pos2.x, this.c.pos2.y); 473 ctx.lineTo(this.d.pos2.x, this.d.pos2.y); 474 ctx.closePath(); 475 ctx.fill(); 476 }, 477 478 // 获取面的法向量和z轴夹角 479 getAngle: function() { 480 var x = (this.a.pos3.x + this.b.pos3.x + this.c.pos3.x + this.d.pos3.x) / 4 - this.cube.pos3.x; 481 var y = (this.a.pos3.y + this.b.pos3.y + this.c.pos3.y + this.d.pos3.y) / 4 - this.cube.pos3.y; 482 var z = (this.a.pos3.z + this.b.pos3.z + this.c.pos3.z + this.d.pos3.z) / 4 - this.cube.pos3.z; 483 // 面的法向量 484 var v = new Vector3(x, y, z); 485 486 // 视点设为(0,0,-500) 487 var x = 0 - (this.a.pos3.x + this.b.pos3.x + this.c.pos3.x + this.d.pos3.x) / 4; 488 var y = 0 - (this.a.pos3.y + this.b.pos3.y + this.c.pos3.y + this.d.pos3.y) / 4; 489 var z = - 500 - (this.a.pos3.z + this.b.pos3.z + this.c.pos3.z + this.d.pos3.z) / 4; 490 // 面心指向视点的向量 491 var v2 = new Vector3(x, y, z); 492 return v.dot(v2); 493 } 494 }; 495 496 // Ball类 497 function Ball(cube, x, y, z) { 498 this.cube = cube; 499 500 // 三维上坐标 501 this.pos3 = new Vector3(x, y, z) 502 503 // 二维上坐标 504 this.pos2 = new Vector2(); 505 } 506 507 Ball.prototype = { 508 // 绕y轴变化,得出新的x,z坐标 509 rotateY: function() { 510 if(window.isStill) return; 511 var cosy = Math.cos(this.cube.garden.angleY); 512 var siny = Math.sin(this.cube.garden.angleY); 513 var x1 = this.pos3.z * siny + this.pos3.x * cosy; 514 var z1 = this.pos3.z * cosy - this.pos3.x * siny; 515 this.pos3.reset(x1, this.pos3.y, z1); 516 }, 517 518 // 绕x轴变化,得出新的y,z坐标 519 rotateX: function() { 520 if(window.isStill) return; 521 var cosx = Math.cos(this.cube.garden.angleX); 522 var sinx = Math.sin(this.cube.garden.angleX); 523 var y1 = this.pos3.y * cosx - this.pos3.z * sinx; 524 var z1 = this.pos3.y * sinx + this.pos3.z * cosx; 525 this.pos3.reset(this.pos3.x, y1, z1); 526 }, 527 528 // 绕任意穿过原点的轴旋转 529 rotateP: function() { 530 if(this.cube.isRotate) { 531 this.cube.index++; 532 // 8 * 60 533 if(this.cube.index === 480) { 534 this.cube.isRotate = false; 535 this.cube.index = 0; 536 } 537 538 var c = Math.cos(this.cube.garden.angleP); 539 var s = Math.sin(this.cube.garden.angleP); 540 // (x,y,z)为经过原点的单位向量 541 var x = this.cube.rotateVector.x; 542 var y = this.cube.rotateVector.y; 543 var z = this.cube.rotateVector.z; 544 var new_x = (x * x * (1 - c)+c) * this.pos3.x + (x*y*(1-c)-z*s) * this.pos3.y + (x*z*(1-c)+y*s) * this.pos3.z; 545 var new_y = (y*x*(1-c)+z*s) * this.pos3.x + (y*y*(1-c)+c) * this.pos3.y + (y*z*(1-c)-x*s) * this.pos3.z; 546 var new_z = (x*z*(1-c)-y*s) * this.pos3.x + (y*z*(1-c)+x*s) * this.pos3.y + (z*z*(1-c)+c) * this.pos3.z; 547 this.pos3.reset(new_x, new_y, new_z); 548 } 549 }, 550 551 getPositionInTwoDimensionalSystem: function(a) { 552 // focalLength 表示当前焦距,一般可设为一个常量 553 var focalLength = 300; 554 // 把z方向扁平化 555 var scale = focalLength / (focalLength + this.pos3.z); 556 this.pos2.x = this.cube.garden.vpx + this.pos3.x * scale; 557 this.pos2.y = this.cube.garden.vpy + this.pos3.y * scale; 558 }, 559 560 render: function() { 561 this.rotateX(); 562 this.rotateY(); 563 this.rotateP(); 564 this.getPositionInTwoDimensionalSystem(); 565 } 566 }; 567 568 // 向量 569 function Vector3(x, y, z) { 570 this.x = x || 0; 571 this.y = y || 0; 572 this.z = z || 0; 573 } 574 575 Vector3.prototype.reset = function(x, y, z) { 576 this.x = x; 577 this.y = y; 578 this.z = z; 579 } 580 581 // 向量点积,大于0为0~90度 582 Vector3.prototype.dot = function(v) { 583 return this.x * v.x + this.y * v.y + this.z * v.z; 584 }; 585 586 Vector3.prototype.length = function() { 587 return Math.sqrt(this.sqrLength()); 588 }; 589 590 Vector3.prototype.sqrLength = function() { 591 return this.x * this.x + this.y * this.y + this.z * this.z; 592 }; 593 594 Vector3.prototype.getDistance = function(v) { 595 var dis = (this.x - v.x) * (this.x - v.x) + (this.y - v.y) * (this.y - v.y) + (this.z - v.z) * (this.z - v.z); 596 return Math.sqrt(dis); 597 }; 598 599 // 近似判断两个向量是否是同一个 600 // 因为程序中基本上是判断3*3*3的27个点是否是同一个,不同的点距离实在太远 601 Vector3.prototype.isEqual = function(v) { 602 if(this.getDistance(v) < 30) return true; 603 else return false; 604 }; 605 606 // 标准化,单位长度为1 607 Vector3.prototype.normalize = function() { 608 var inv = 1 / this.length(); 609 return new Vector3(this.x * inv, this.y * inv, this.z * inv); 610 } 611 612 // 是否垂直,点积为0 613 Vector3.prototype.isPerpTo = function(v) { 614 var ans = this.dot(v); 615 if(Math.abs(ans) < 5) return true; 616 return false; 617 } 618 619 // 向量ab,即为b向量减去a向量返回的新向量 620 Vector3.prototype.minus = function(v) { 621 return new Vector3(this.x - v.x, this.y - v.y, this.z - v.z); 622 } 623 624 //////////////////////////////////////// 625 // 二维向量 626 function Vector2(x, y) { 627 this.x = x || 0; 628 this.y = y || 0; 629 } 630 631 Vector2.prototype.reset = function(x, y) { 632 this.x = x; 633 this.y = y; 634 } 635 636 // 向量叉乘 637 Vector2.prototype.dot = function(v) { 638 return this.x * v.y - v.x * this.y; 639 }; 640 641 Vector2.prototype.minus = function(v) { 642 return new Vector2(this.x - v.x, this.y - v.y); 643 } 644 </script> 645 </head> 646 <body bgcolor='#000'> 647 <canvas id='canvas' width=1000 height=600 style='background-color:#000'> 648 This browser does not support html5. 649 </canvas> 650 </body> 651 </html>
其实这是蛮坑爹的体验,h5原生api不适合做这种3d效果。但重要的是思考过程,不是结果。
这只是一个demo,如果要做一个真正的魔方游戏,还需要以下几点:
- 魔方颜色初始化
现在魔方的颜色我是随意设置的,如果是个可玩的游戏,先得初始化复原后的魔方颜色,然后在游戏loading过程中随机打乱。
- 3*3模块的精确判断
之前我也说了,3*3模块的判断是不精确的,更极端的例子见下图:
此时我鼠标操作的是1和2区域,我想旋转的是黑色箭头围成的模块,但是实际程序中旋转了黄色箭头围成的3*3,这就是因为我的模糊判断。我无法确定到底是哪一个,因为我一直是根据体心来判断的,如果要得到正确的结果,就要上升到正方体面的判断,我不知道代码量要增加几倍。(所以demo操作时尽量操作离视点近的面)
- 游戏结果判断
如果在确定3*3步骤使用维护三维数组的方法,这里判断相对简单;但是如果不,又得回到面的判断上,同上,很复杂。
- 其他
增加loading、计时等等。
如果有更好的方法或建议欢迎与我交流~