简介:一份基于纯JavaScript实现的魔方模拟程序资源包,面向前端学习者、算法爱好者及魔方玩家。项目集中展示DOM操作、事件监听、函数封装、递归回溯算法与动画渲染等知识点,作者对获取的原始代码做了精简,去除冗余后更易阅读,适合说明复杂逻辑如何落地为交互页面。压缩包共21个文件,以15个js脚本为主,配合4个css样式、1个html入口和1张静态图片。js脚本中既有Three.js、tween.js等基础库,也有cuber.js、main.js等核心模块,分别负责魔方建模、旋转控制、过渡动画和界面交互;css统一风格,html结构简洁,255KB的体积便于直接打开运行。资源已有779人学习,适合想结合实战掌握JavaScript动画、三维渲染与递归算法的人群,可对照源码快速理解魔方程序的设计与优化思路。 不瞒你说,这个项目的起因特别简单:有个朋友问我,能不能不装任何依赖、不用框架,就靠浏览器原生能力写一个能转的魔方。我当时第一反应是“用Three.js不就完事了”,但转念一想,纯JS版反而更有挑战性——所有3D数学、交互判定、动画状态都要自己扛。于是就有了这个纯前端魔方项目:打开页面就是一个完整的3x3x3魔方,支持鼠标拖拽旋转视角、点击中间层转动、一键打乱,还能手动还原。
这个项目适合三类人:一是刚学完JS基础、想找一个综合性实战项目练手的前端新人;二是习惯用Three.js等库、想搞懂底层变换原理的开发者;三是单纯想在工作之余做一个能拿得出手的玩具项目的朋友。你不需要懂复杂的线性代数,我会把每一块原理揉碎了讲,包括数据结构怎么搭、旋转动画怎么做、拖拽判定怎么算,以及我踩过的那些坑。
1. 魔方状态如何建模:数据结构的核心选择
1.1 块模型与面片模型的区别
动手写代码前,最该想明白的不是渲染,而是状态管理。魔方看似简单,26个小块在三维空间里转来转去,怎么在内存里表示它们,直接决定了后面所有逻辑的复杂度。
社区里常见的有两种方案:面片模型和块模型。
面片模型把魔方看成一堆固定位置的小面片,一共54个,每个小面片有固定的空间位置和朝向。转动某一层时,把小面片的数据做对应位置的搬移。这种方案的优点在于状态校验直观——你随时能知道每个面片上是什么颜色;缺点是转动的数据搬移规则非常绕,映射表写起来容易出错。
块模型则相反:把26个(或27个,算上中心块)小方块当作独立个体,每个块携带自身的空间坐标和旋转矩阵。转动时,变化的只是块的朝向和位置,渲染层直接读状态去绘制即可。我最终选了块模型,因为它和“人理解魔方”的方式一致:一个块转到了哪里、变成了什么朝向,清清楚楚。
两者的取舍可以用一个表格概括:
| 维度 | 面片模型 | 块模型 |
|---|---|---|
| 状态读取 | 直接读54个位置的颜色 | 需要从块的位置和朝向推导 |
| 转动实现 | 搬移数据,规则繁琐 | 更新块的坐标与矩阵,逻辑统一 |
| 3D渲染 | 位置固定,依赖相机变换 | 每个块独立变换,更灵活 |
| 还原校验 | 简单 | 需要额外维护“标准面”信息 |
| 代码量 | 前期少,后期麻烦 | 前期多一点,后期省心 |
如果你只是做一个展示颜色的静态魔方,面片模型够用;但要做交互和动画,我建议直接上块模型。
1.2 用三维坐标和旋转矩阵表示每一块
每个块,我用一个包含position和rotation的对象表示。position是它在魔方坐标系里的落位,取值是-1、0、1三个数字的组合,比如(1, -1, 0)表示右上中层的位置。rotation则是一个3x3旋转矩阵,记录这个块当前朝向。
为什么不用欧拉角而是用矩阵?因为欧拉角存在万向锁问题,而且在连续旋转后,三个轴的顺序会变得极其混乱。矩阵虽然看着不够直观,但它做旋转合成时就是一次矩阵乘法,稳定可靠。
一个块的初始rotation是单位矩阵。当执行一次转动,比如“绕X轴转动顶层”,我需要找出所有position.y === 1的块,把它们的位置绕着X轴做一次90度旋转,同时在rotation上左乘一个对应的旋转矩阵。这样一来,每个块在任何时刻都精确记录了它“在哪”和“朝向哪”。
这里有个很重要的细节:块的position更新和rotation更新必须是同步且原子的。我曾经先改了position再改rotation,结果动画过程中块的位置和朝向对不上,转着转着就“穿模”了。解决方案是把一次转动的所有状态变更包成一个事务:先算好每个块的新position和新rotation,再一次性写入。
1.3 旋转时坐标变换的数学原理
旋转四个角点看起来很复杂,其实核心就是三维旋转矩阵公式。绕X轴转90度,坐标变化是:
- 新y = 旧y * cos(θ) - 旧z * sin(θ)
- 新z = 旧y * sin(θ) + 旧z * cos(θ)
θ等于90度时,cos为0,sin为1,公式简化为交换坐标再加个符号。我就用这个简化公式处理了一个超级常见的场景:顶层的块转90度后,(1,1,0)会变成(0,1,-1),因为绕Y轴还是X轴要看你的转动轴定义。
为了方便管理,我没有硬编码每种转动的映射表,而是直接封装了一个rotatePosition(pos, axis, angle)函数,统一走旋转矩阵。哪怕以后扩展2x2魔方、异形魔方,这个函数都不需要改。
2. 不引入Three.js,如何完成3D渲染
2.1 CSS 3D Transform方案还是Canvas方案
渲染层的选型,我纠结过一阵子。Canvas的方案是手写透视投影矩阵、深度排序、背面剔除,代码量直接爆炸;WebGL门槛更高,而且标题说好了“纯JS”,再引入WebGL封装库就失去意义了;最后我选择了CSS 3D Transform,因为浏览器已经把透视、矩阵乘法、渲染管线都优化好了,我只需要摆好元素的位置和变换,剩下的交给GPU。
具体来说,就是用transform-style: preserve-3d构建一个3D场景,每个魔方块是一个独立的div立方体,放进一个带perspective的父容器里。只要在父容器上设置合适的perspective,子元素就能呈现出近大远小的立体感。
2.2 用DOM构建魔方的六个面
每个小方块有6个面,我用一个ul立方体来搭。每个面都是绝对定位的div,通过不同的rotate和translateZ定位到立方体的六个方位。关键参数是块的大小,我设成50px,每个面的translateZ距离就是25px。
构建54个小面的时候要特别小心面的朝向和颜色对应。标准魔方配色是:前白、后黄、左橙、右红、上蓝、下绿,但为了视觉更柔和,我调成了渐变色,用CSS的linear-gradient做了高光模拟。这不算什么高深技术,但能显著提升成品质感。
基础块的HTML结构大概长这样——我用JavaScript动态生成,避免手写54个div:
<li class="cube">.scene { perspective: 800px; } .cube { position: absolute; transform-style: preserve-3d; } .face { position: absolute; width: 50px; height: 50px; backface-visibility: hidden; }2.3 颜色分配与视觉增强
颜色分配逻辑上,一个块属于哪个面取决于它的初始坐标。比如position.y === 1的块,它的顶面就是黄色;position.x === 1的块,右面就是红色。这个规则在初始化每个块的六个面时用一次,后续就不再改动DOM的面颜色,而是通过变换整个块来改变“哪个颜色朝外”。
为了让魔方看起来更像实物,我在每个面片上加了一层很淡的黑色内阴影,并且在外露的棱边描了1px深色边线。不要小看这些细节,不然六个颜色块贴在一起,边缘会糊成一团,根本看不清层次。
3. 点击与拖动:交互系统的完整实现
3.1 魔方整体视角旋转
交互分两个层面:一是整体视角旋转,让用户能从各个角度观察魔方;二是层转动,也就是点击中间那一层来拧魔方。
整体视角旋转我用的是拖拽事件:mousedown时记录起点,mousemove时计算偏移量,然后把这个偏移量转换成场景容器的rotateX和rotateY增量。这里有一个新手非常容易踩的坑——直接把累计角度赋给rotateX和rotateY,会导致魔方越转越“拧巴”,因为CSS变换的rotate是绕着对象的局部坐标轴旋转,并不是我们直觉里的世界坐标轴。
我的解决方案是维护一个外部的rotation矩阵,拖拽时计算新的旋转矩阵,最终把矩阵展成transform: matrix3d()写在场景容器上。matrix3d虽然看起来吓人,但它能精确表达任意旋转,不会出现累加顺序错误。
3.2 判断点击的是哪个面和哪一层
点选魔法方块的某个面,如何知道用户想转动哪一层?我的思路是:不直接用二维坐标做逆变换,而是通过点击的元素反推。每个立方体块我都挂了自定义data属性,存着它的position坐标;通过event.target.closest('.cube')能拿到被点击的块,再通过块的position和当前朝向推断出它属于哪个层。
但这里有个问题:魔方转乱之后,DOM上的data-x,y,z是初始坐标,而块的朝向已经变了,你点击“表面”时看到的位置不一定等于块的初始坐标。为了解决这个问题,我维护了一套“视觉位置”,每个块在经历旋转后,立刻更新自己的DOM data属性为新的position。这样,点击某个块时,它当前的data值就是它的真实空间位置,判断层的时候直接用这个值。
至于具体是哪个层,我根据点击的面片法向量来判断。比如点击了一个法向量朝上的面,那用户意图多半是转动这个块所在的水平层;如果点到的是朝右的面,那就是转动竖直层。这个方法不100%精确,需要在拖动过程中根据方向二次验证。
3.3 拖拽方向判定与防误触
用户按下鼠标后,可能只是轻轻点一下,也可能是想拖拽旋转。为了区分,我在mousedown时不立即触发任何旋转,而是等鼠标移动超过一个阈值(8px)后才认定是拖拽。随后记录拖拽的起始坐标,并在拖动过程中持续计算位移向量。
转动哪个层,由按下时的层位置和拖拽的主方向共同决定。比如你按住顶层的一个块,向下拖,那大概率是想做一次“顶层顺时针”转动;向右拖则是顶层的另一种转动方向。判定逻辑写成一个函数,输入起始块坐标和拖拽向量,输出一个转动指令:{axis: 'y', layer: 1, direction: 1}。
防误触还有一个细节:如果拖拽过程中鼠标离开了魔方区域,就重置状态,绝不触发转动。漏掉这个判断,用户在旋转视角时稍有不慎,魔方就会莫名其妙地转一层,体验极差。
4. 转动动画与状态同步
4.1 动画期间的状态锁定
转动动画最核心的原则:同一时间只允许一个层动画。如果用户疯狂点击,动画队列会堆积,状态会出现并发覆盖问题。我在代码里维护了一个isAnimating标志,每次开始转动时检查,为true就忽略新指令;动画结束时置回false,再处理队列里的下一条。
队列我直接用数组实现,push新的转动指令,动画结束后shift执行下一个。这个方法简单粗暴,效果却很稳定,不会出现多条动画同时操作同一个块导致的位置错乱。
4.2 requestAnimationFrame与缓动函数
动画的每一帧我都是用requestAnimationFrame驱动的,而不是setInterval。原因很简单:rAF由浏览器根据刷新率调度,动画更流畅,而且页面切到后台时自动暂停,省电又省事。
旋转角度从0到90度之间,我用缓动函数控制插值。我选了easeInOutCubic,这样转动开始和结束都比较柔和,符合真实魔方的手感。实现方式是这样的:
function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }每一帧把当前进度t代入缓动函数,再把角度算出来,更新旋转层的transform。
4.3 动画结束后的数据校准
动画看起来很完整,但CSS旋转到90度之后,如果直接留着transform状态,下次旋转时角度会继续累加,迟早会超过360度。而且CSS变换的实际效果和内存里的rotation矩阵保持同步极其困难。
所以我的做法是:动画结束后,把旋转层的transform重置为none,同时把这一层所有块的position和rotation按最终状态更新完毕。相当于动画过程只是视觉上的“过渡”,最终状态完全由数据驱动。这样无论转多少圈,数据永远干净、可控。
在做这一步时特别注意,重置transform后块会因为失去了中间态的旋转而闪回原位置,但因为块本身已经被更新到最终位置,视觉上不会有任何跳变。这个“视觉欺骗”是关键,值得反复推敲。
5. 打乱、还原与校验
5.1 打乱算法:随机步数生成与防重复
打乱不是简单随机选个层转动几十次就完事。好的打乱既要保证足够的混合程度,又不能连续出现“相反方向的重复转动”,那会让状态变成原地踏步。
我的打乱逻辑是:先随机选择轴和层,再随机选择方向,但如果这一次的指令正好是上一次的逆操作,就重新随机。同时保证同一个轴同一个层不会连续逆方向转动。这能有效减少无效操作,让魔方在有限步骤内被充分地打乱。
打乱过程我也用动画队列驱动,每一步都走正常的转动流程,这样用户能亲眼看到魔方被打乱,而不是瞬间变乱,代入感强很多。如果你需要极速打乱,可以给动画时长传0,直接跳到最终状态,两种模式我做了开关。
5.2 用柯里化构建操作序列
打乱、公式还原、自定义序列,它们的本质都是多个转动指令的有序组合。我用一个数组存储序列,对这个数组做reduce,把每一个指令依次加入动画队列。
高阶函数的好处是方便扩展。比如我想让用户输入一串公式“R U R' U'”,就把字符串按空格拆分成指令序列。每个指令的格式是:R代表右侧层顺时针,R'代表逆时针,U代表顶层顺时针,U'代表顶层逆时针。
5.3 状态校验与Hint系统
校验是块模型的一个难点。因为状态用矩阵保存,不能直接比较颜色。我的做法是:给每个块建立一个“基准映射表”,记录初始状态下每个面应该是什么颜色。每次转动后,把这54个面的颜色刷新到一个临时二维数组里,用于判断六个面是否单色。
这个判断逻辑写在checkWon()里,每次动画队列清空后自动执行。如果六个面全单色,就弹出一个胜利提示。另外加上一个hint功能,监听用户的转动序列,一旦发现某个面已经还原,就在那个面上加一圈淡淡的描边高亮,相当于一个小奖励机制,提升成就感。
6. 实测中踩过的坑和优化技巧
6.1 旋转中心偏移这个经典大坑
早期版本里,我转动一个层时,rotate的transform-origin默认是元素中心,也就是魔方的中心。但实际转动某一层时,旋转轴应该穿过这一层的中点。比如转动顶层,旋转轴是Y轴,但位置在y=1的高度;如果旋转中心在魔方中心,顶层就会绕着一个错误的位置打转。
解决办法:把要旋转的那9个块包进一个临时的“层容器”里,并且给这个层容器设置transform-origin为魔方中心对应的高度。转完后再把块交还给全局容器。这个包装和拆装的过程,代码量不大,但逻辑非常绕,稍不留神就会把块的坐标弄丢。
6.2 移动端触摸事件的恼人细节
桌面端跑通了,换到手机上一看,完全不能玩。原因很简单:touch事件没有clientX/clientY的移速概念,需要从changedTouches里取;而且touchmove过程中,浏览器默认会滚动页面,必须preventDefault。还有双击缩放问题,需要给viewport加user-scalable=no,或者手动设置touch-action: none。
另外,触摸判定阈值也要比鼠标更宽松,因为手指的接触面积大,误触概率高。我最终把点击阈值从8px提到了12px,并且加了100ms的延迟确认,效果好了很多。
6.3 性能优化:减少DOM操作与重排
魔方有26个块、156个面片,在低端手机上如果每个面都频繁改style,必然卡顿。我做了三个优化:
- 面片的颜色初始化后不再变动,只改块的transform。
- 使用transform和opacity做动画,这两个属性不触发layout,能走合成器。
- 动画期间用will-change: transform标注正在旋转的层容器,GPU提前分配图层。
还有一个容易被忽略的点:transform的更新应该在动画循环里批量处理,而不是每帧分别更新多个块。我把同层9个块的transform赋值放在同一个逻辑块里,减少浏览器样式计算次数。
6.4 JS异步与Promise在动画队列中的应用
动画队列本质是一个异步任务队列。我用Promise封装了一次转动动画:开始转动时new一个Promise,resolve放在动画结束的回调里。队列用async/await串起来,代码可读性比callback嵌套好很多。
async function playSequence(sequence) { for (const move of sequence) { await playMove(move); } }这段代码看起来很简单,但解决了动画队列、打乱序列、用户连续操作三个场景的协调问题。如果你想深入理解js异步和promise的用法,去读这一段队列代码比看任何教程都直观——它把异步状态机的核心思想浓缩到了一个魔方项目里。
7. 项目结构、后续扩展与个人体会
最后放一下项目的整体文件结构,方便你对照实现:
rubiks-cube/ ├── index.html ├── style.css └── main.js ├── Cube.js // 单个魔方块的类 ├── RubikCube.js // 整个魔方:状态、转动、校验 ├── Renderer.js // 3D场景搭建与渲染 ├── Interaction.js // 鼠标/触摸事件 └── Animation.js // 动画队列与缓动我这个项目只有一个index.html入口,没有模块化打包,照样能跑,这也是“纯JS”的一种极致体现。当然,模块化拆分是OOAD的思想,代码组织上可以根据项目复杂度自由调整。后续我想加的功能还有:计时器、打乱公式分享、用户自定义公式编辑器,甚至接入Web Speech API语音控制转动,这些都是不错的扩展方向。
在实际写这个项目的过程中,我最大的感受是:真正考验前端功力的不是会用多少框架,而是能在最简单的环境里把一个复杂的3D交互拆解成形、理清状态、控制时机。魔方这个项目恰好把数组操作、矩阵变化、事件系统、异步控制、CSS动画这些最基础的知识点全部串起来了,做完一遍,你对JavaScript的数组方法、对象引用、事件冒泡、异步队列的理解都会上一个台阶。如果你最近想找一个不依赖脚手架、又能看到实际成果的练手项目,这个纯JS魔方,值得一试。
本文还有配套的精品资源,点击获取