news 2026/9/8 5:33:21

纯JS实现3D魔方:从数据结构到旋转动画的完整实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯JS实现3D魔方:从数据结构到旋转动画的完整实战

简介:一份基于纯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魔方,值得一试。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 5:32:18

20元捡漏CMPOWER智能排插,低成本接入Home Assistant全攻略

手头这个 20 多块包邮的中移 CMPOWER 智能插排&#xff0c;最近在折腾 HA 的圈子里确实有点火。原因不难理解&#xff1a;运营商集采退下来的库存货&#xff0c;硬件底子不差&#xff0c;一个排插就带计量、带独立分控&#xff0c;价格却只有市面上同类 WiFi 计量排插的零头。买…

作者头像 李华
网站建设 2026/9/8 5:31:17

智能体架构的隔离、集成与治理:从Demo到生产的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 5:31:12

用Claude Code与Remotion写代码做视频,中配电脑也能轻松渲染

最近刷到不少用代码做视频的玩法&#xff1a;几条命令下去&#xff0c;画面、字幕、转场自动生成&#xff0c;最后渲染出一段看起来像是用 Pr 剪过的短片。整个过程不需要打开传统剪辑软件&#xff0c;中低配电脑也能跑得动。这里面的核心工具&#xff0c;就是 Claude Code 加 …

作者头像 李华
网站建设 2026/9/8 5:30:26

量级思维:从4万QPS事故看工程师的容量规划与性能排查

半夜两点&#xff0c;手机在床头柜上疯狂震动。我眯着眼瞟了一眼屏幕&#xff0c;告警群里已经炸了锅&#xff1a;支付回调服务超时率飙到40%&#xff0c;数据库连接数打满&#xff0c;Redis内存暴涨。第一反应是被人刷了&#xff0c;登录服务器一看&#xff0c;根本没攻击——…

作者头像 李华
网站建设 2026/9/8 5:29:31

Codex本地部署实战:智能代码生成工具的环境配置与API调用指南

Codex 这个项目最近在开发者圈子里讨论度很高&#xff0c;它本质上是一个智能代码生成与补全工具&#xff0c;能够根据自然语言描述或代码上下文&#xff0c;自动生成高质量的代码片段。这次我们来重点看看它的本地部署能力、硬件资源占用、API 接口调用以及批量任务处理效果。…

作者头像 李华
网站建设 2026/9/8 5:28:25

LangChain应用全链路可观测:OpenTelemetry接入实践与踩坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华