游戏入口
这次的项目是我们前端课的课程大作业,我们选择做一个魔方,支持自动复原、用户自己染色等操作。具体的分工是负责前端部分魔方的绘制与操作,CalciferZh负责魔方的建模和求解工作。

3D绘制

绘制方面我们选择了three.js这个库进行绘制。由于图形库基本的用法都是大同小异,因此主要是需要了解基本的API操作。当然由于我本人也没有系统学习过图形学的相关知识,也借此机会进行了学习。要建模魔方,最基本的想法就是绘制出27个正方体,并支持视角的旋转和方块的旋转。根据three.js的绘制惯例,这里分为七步:initHTML(); initThree(); initCamera(); initScene(); initLight(); initObject(); render();

initHTML()

这里面所需要做的仅是添加一些与three.js无关的HTML元素如各式各样的按钮。同时也在这里注册了一系列鼠标与键盘事件以支持用户的交互。

initThree()

这一步建立了THREE.WebGLRenderer,设置相关的属性后添加入HTML中

initCamera()

这一步建立了THREE.PersepctiveCamera,并调整位置与方向。同时这里我们建立了一个THREE.OrbitControls这样一个控制器来控制相机的运动如缩放、旋转等。当然在后期我们也发现原生的控制器并不能很好地支持我们的需求,因此我们也对其进行了一定的修改。

initScene()

这一步建立了THREE.Scene,调用drawCubes()新建了27个正方体并往场景里面添加。

initLight()

这一步建立了THREE.AmbientLight,设置颜色后直接加入场景即可。

initObject()

一个空函数,并没有执行任何指令。

render()

清空之前的画布并重新渲染,这里使用了window.requestAnimFrame(render)进行动画效果的设置,这里的好处是使得整个网页的动画效果有一个统一的刷新机制,且其刷新频率与显示器的保持一致,同时当浏览器没有处于该标签页的时候不会继续刷新渲染。总之使用这样一个函数来管理动画比使用朴素的setTimeout有相当的优势。

至此,整个绘制的大框架已经建立完毕,接下来的工作一是补全drawCubes(),二就是支持相关的交互操作。

魔方的绘制

drawCubes()

每个小正方体自然都是一个THREE.Mesh对象,其中geometry是THREE.BoxGeometry,而纹理则需要分别计算(generateMaterial(i, j, k)),设置位置时根据ijk坐标可以很容易的计算出来。纹理的绘制方面则是通过canvas对象来实现。首先我们绘制了7个基本的canvas贴纸(正如市面上的魔方一样,我们使用了广泛采用的圆角正方形),并由此建立了THREE.texture,并进一步建立了THREE.MeshBasicMaterial对象。而在贴纹理的时候则是默认贴上黑色纹理,再根据ijk坐标确定“暴露”的面而贴上对应的纹理。

(PS. 由于正方体有6个面因此实际上应该画12个三角形的纹理,但three.js对此进行了一个封装,当只提供6个纹理的时候会自动为一个正方形上的两个三角形填充相同的纹理)

魔方的动作

rotate(objs, axis, rad, now, start, last)

首先是三个基本函数rotateX/Y/Z(objs, rad),执行的是对所有objs对象旋转指定的rad角度。而rotate(objs, axis, rad, now, start, last)函数则是负责渲染旋转的动画效果。这段操作直接看代码应该不难理解。now,start,last这三个时间参数就是用来将大的旋转拆分为若干细微的旋转所需要的指标。整个函数也是一个标准的使用window.requestAnimFrame()渲染的写法。

OP(op, rad)

根据不同的op值(L表示L面,R表示R面,LR则表示整个魔方采取与L/R旋转相同的轴),从27个cubes里面选择对应的cube。这里选择的方式是根据cube中心点的坐标。由于旋转的过程中可能会有细微的误差,经过若干次旋转后中心点很难再对齐在0或者$\pm$unit处,因此使用appro(lhs, rhs)实现约等于的比较。

opClosure(ops, i, end)

该函数会执行ops里面第i个操作。这里的每个OP可以是L2(L转180°),L’(L转-90°)这样的操作,因此需要对其进行判断,解析出对应的操作然后丢给OP()处理。而stepByStepRotate(ops)就是设定定时器,一步步执行ops的操作。这是用于自动恢复魔方的。

魔方的复原

魔方的复原算法采取的是 Two-Phase Algorithm Details算法,算法的具体原理详见链接不再赘述。在github上也能找到相关的实现,当然我们也给出了js的实现(backend.js)。

modeling()

根据算法的要求,将所有的方块的种类、位置、朝向等分别传给四个数组cp,co,ep,eo,包裹成一个对象然后送入求解器求解,最后返回对应的复原步数并使用opClosure()进行复原。同时我们在命令行里打出了复原的公式,用户可以使用开发者工具看到(如果你不知道怎么调出,先试试F12,或直接搜索您所用的浏览器所支持的打开方式)

事件的处理

键盘事件
  • 根据是否按下shift键以及触发的键位(LRUDFB)等进行基本的旋转
鼠标事件

鼠标事件的处理相对比较复杂,这牵扯到了鼠标点击的位置、滑动的方向、魔方的状态等信息。在这里我们使用了一个名为last的对象来处理鼠标事件。total记录着鼠标滑过的总像素,最后会被换算为角度并且计算出正确的角度实现“磁力定位”;x,y储存着上次的鼠标事件的x,y值;rotatingNineCubes,rotatingAllCubes记录着魔方的旋转类型(单层转还是整个魔方转);objs记录转动的方块;axis记录着转动轴;main指示着目前的旋转是根据鼠标的x位移还是y位移;intersectFace记录着鼠标点击的面的方向(xyz);sgn是用来表示顺时针or逆时针旋转(键盘事件则根据shift,鼠标事件则根据total的符号);interfacePoint则记录着点击的方块的中心点,这被用来计算旋转的九个方块。

魔方示意图

可以看到,当点击角块的时候,其实有三个面的点击方式。我们规定,当点击面为黄色的时候,只能进行蓝色面的旋转(对应于鼠标在x方向的拖拽)和红色面的旋转(对应于鼠标在y方向的拖拽),这样就可以比较合理地处理鼠标的拖拽事件并给出自然的响应。

鼠标单击事件

当且仅当canRotate且不为rotating的时候才会执行接下来的操作。先用clientX,clientY值记录鼠标点击的坐标(需要处理鼠标事件和触屏事件)。如果是右键单击或双指滑动,则last.rotatingAllCubes为true,记录last的x,y值,并将相机的enableRotate置为false。否则则还需要获取点击到的方块,记录下last.intersectPoing, last.intersectFace,last.x,last.y,然后同样的将相机的enableRotate置为false,将last.rotatingNineCubes置为true。并将canRotate置为false。

鼠标拖拽事件

首先自然还是获取xy坐标。如果旋转的方式已经确定,简单地计算x或者y的diff值即可。将diff值加入last.total中,然后根据diff值渲染细微的动画即可。但旋转的方式未定时则需要有一系列的处理与计算。

当刚开始从单击到初步移动的时候,由于移动的像素太少,我们不能准确的得到用户是在x方向拖拽还是y方向拖拽,因此这段时间内last.x和last.y都不会被更新,保存着最开始单击的坐标。一旦在x方向或y方向的绝对值大于阈值(这里设为20)我们才开始进行处理。后面进行的操作就是根据已有的信息确定旋转的方块以及旋转轴,剩下的事就是动画的渲染问题了。

鼠标释放事件

如果是视角的旋转(controller.enableRotate==true),则执行恢复视角的代码:以当前视角和原始视角的叉积为旋转轴,点积的反余弦为角度调用cameraRoteta()方法。

否则,将controller.enableRotate置为true(释放后说明当前操作结束,允许用户改变视角观察)。如果是单层的旋转,则将魔方定位回最近的$\pi/2$的整数倍的角度,执行动画,并将last里相关的指标置空。旋转所有方块也是类似的操作,只不过每次都必定旋转$\pm\pi/2$,就不需要“磁力定位”的处理。

相机的处理

在我们的视线里,相机的旋转是交由一个controller实现的。因此在背景旋转的时候我们无需进行额外的代码编写,three.js已经帮我们完成了相关的功能。当然,我们不希望旋转魔方的时候还会让controller调整我们的视角,因此上面进行了controller.enableRotate置为false的操作。但我们在其上面也做了一定的修改,一个是在为touch注册listener的时候加入{passive:false},而不是用stopPropagation()。

额外的功能

打乱魔方

生成一个随机操作序列然后调用opClosure()即可。

魔方的染色

将方块里所有的颜色都置空,然后根据选择的颜色以及点击的面进行染色、去染色等。我们限制了每个颜色只能染九次,但仍然无法阻止用户生成一个不可能的状态的魔方。所幸,我们的魔方求解算法可以在求解的时候检测出当前的魔方违反了哪些性质并给出警告。

一个有趣的操作是,选择顶层的三个角块并且都逆时针扭转一下,这样的魔方仍然是合法的并可以被求解。强调这一点是为了说明,仅通过三个面的信息是无法唯一确定魔方的状态的,因此我们没法支持仅靠用户上传的现实中魔方的一张图(最多看到三个面)就给出求解的方式,当然,再给一张图是可以的,有兴趣的读者可以fork出去继续开发相关功能。

适配移动端

支持touchEvent,以及根据屏幕的大小渲染合理布局。

Bugs & 还可以加的功能

由于求解魔方使用了setTimeout,而整体的动画使用window.requestAnimFrame,因此切换标签页后会出问题。

另外还可以做如下改进:

  • 求解的过程中暂停
  • 根据两张图片完成魔方的染色