简介CSS 3D变换与原生JavaScript的结合为前端开发者提供了构建复杂三维交互的可能。理解perspective、transform-style等核心概念是搭建立体场景的基础。通过坐标模型与旋转算法可以模拟魔方等实体对象的动态行为。本文将三阶魔方作为案例拆解从27个小方块生成到单层旋转动画的完整实现过程涵盖DOM结构设计、状态管理、过渡动画与性能优化。该技术不仅适用于网页特效展示也能用于教学演示、互动游戏等场景更能帮助开发者扎实掌握前端基础与空间几何思维。1. 为什么选择用原生三件套做一个3D魔方大概每个前端开发者都会经历这样一个阶段教程看了一堆框架也用了不少但突然想找一个能同时验证自己HTML、CSS、JavaScript功底的小项目。3D魔方就是这类项目里非常典型的一个——它涉及DOM结构设计、CSS 3D变换、JavaScript状态管理、用户交互逻辑还自带极强的视觉完成度。做完之后你拿给朋友看别人会觉得“哇你这个很酷”但只有你自己清楚这背后其实是把前端基础语法完整串了一遍。这个项目最大的价值在于它不依赖任何框架和库纯原生实现。打开浏览器就能跑不需要Node环境不需要npm install不需要构建工具。你写完之后想发给谁看都可以一个HTML文件丢过去就能玩。对刚学完前端基础的人来说这是检验自己到底学没学会的试金石对已经工作的人来说这是重温基本功、找回写代码纯粹乐趣的好方式。我最初做这个项目的原因很简单想给期末网页设计大作业找个有意思的选题又不想做那种千篇一律的“个人主页”。看到很多人在找类似的源码干脆自己从零写了一个再把整个思考和实现过程整理出来。这篇文章里我会把核心原理拆开讲给出完整可运行的代码还会分享一些我在开发过程中踩过的坑和优化心得。如果你是前端初学者这篇文章可以帮你搞清楚CSS 3D变换到底是怎么一回事如果你已经有一定基础可以直接看代码和踩坑部分自己做扩展会非常顺手。2. CSS 3D变换魔方旋转的地基2.1 建立一个三维坐标系在动手写代码之前必须先搞明白CSS 3D的坐标系规则。这就像你下棋之前得先明白棋盘上的格子怎么走一样不理解坐标系后面写旋转逻辑就会各种混乱。CSS 3D的默认坐标系是这样定义的X轴水平方向向右为正Y轴垂直方向向下为正Z轴垂直于屏幕平面指向用户为正对比你初中数学课上学过的三维坐标系最大的区别是Y轴方向是反的。这也是很多新手第一次接触CSS 3D时最容易搞混的地方。你希望元素向上移动的时候用的是负的Y值想让它向屏幕外靠近用的是正的Z值。这个坐标系决定了魔方六个面的定位方式。比如前面板面向用户Z轴正方向即translateZ(r)后面板Z轴负方向即rotateY(180deg) translateZ(r)右面板X轴正方向即rotateY(90deg) translateZ(r)左面板X轴负方向即rotateY(-90deg) translateZ(r)上面板Y轴负方向即rotateX(90deg) translateZ(r)下面板Y轴正方向即rotateX(-90deg) translateZ(r)你可能会问为什么后面板不是直接translateZ(-r)而是要先旋转180度再沿Z轴正方向移动这里有一个非常关键的细节在CSS 3D变换中元素的位移方向受其自身旋转状态影响。先旋转再位移位移的“正方向”已经跟着元素一起转过去了。这有点像你面朝东走十步和面朝西走十步虽然都写了“十步”但最终到达的位置完全不同。如果你写过纸盒子的CSS实现就会觉得这一套逻辑很眼熟。魔方其实就是六个纸盒子加上27个小立方体再加上一些旋转控制逻辑。2.2 透视距离的选择为什么是800px在CSS 3D变换中perspective透视距离是让一切立体效果成立的前提。没有透视所谓3D就只是元素在平面上被缩放了一下视觉上根本没有纵深感。透视距离的值怎么选我用的是800px这个值不是随便拍脑袋定的。根据我自己的测试400px以下效果非常夸张转动时元素形变严重有点像鱼眼镜头长时间盯着看会晕800px-1000px视觉上最舒服既能清晰感受到深度又不会过度变形1500px以上3D效果变得很平深度感明显减弱魔方的“立体感”就打了折扣当然这和个人屏幕尺寸也有关系。如果是在手机上玩透视距离适当缩小到500px-600px会更好。我的代码里统一用了800px在PC端体验最佳。还有一个细节perspective属性写在父容器上而不是写在3D元素本身上。两者的区别在于写父容器上是“整个场景统一透视”所有子元素共享同一个观察点写在元素上是“每个元素各自透视”视觉效果会出现偏移甚至轻微扭曲。正确做法是把3D变换的元素包在一个容器里容器设置perspective元素设置transform-style: preserve-3d。2.3 transform-style保留3D空间的关键开关如果说perspective是“看3D的眼睛”那transform-style: preserve-3d就是“保持3D世界的开关”。CSS默认情况下即使在子元素上设置了translateZ这类3D变换父元素在被渲染时也会把这些子元素“压扁”回二维平面就像你用相机对着3D场景拍了一张照片照片本身就是二维的你想从照片里再感受到“深度”是办不到的。设置了preserve-3d之后父元素就不再“压扁”子元素了而是让它们继续在三维空间里存在。这样当你在最外层容器上做旋转动画时里面所有小方块才会作为一个整体转动。魔方里用到这个属性的一共三层.scene { perspective: 800px; } .cube { position: relative; width: 200px; height: 200px; transform-style: preserve-3d; transform: rotateX(-30deg) rotateY(45deg); }在cube这一层打开preserve-3d里面的大对面和每个小方块单元才能保持立体结构。简单记一句话想做嵌套3D就往里加preserve-3d。3. 魔方的内部结构从27个小方块到6个大对面3.1 为什么要分两层结构很多人第一次做魔方项目时脑子里直接想的就是27个小方块每个小方块6个面一共要写162个面光想到这个数据就开始头大了。我一开始也是这样想的结果代码写到一半发现逻辑复杂到不行。所有小方块散落在三维空间里然后你要给每个小方块做旋转动画、颜色管理、点击交互……很快就乱成一锅粥。后来我换了个思路先用6个大对面把魔方的外壳搭起来再在每层内部填充小方块。这个大对面不是用来直接展示的而是用来做整体旋转和交互的。当用户拖动整个魔方旋转时旋转作用在cube这一层所有小方块跟着一起动当用户点击某一行或某一列单独旋转时我们把对应的9个小方块拿出来围绕坐标轴旋转90度。外层负责整体内层负责细节各司其职代码逻辑就清晰多了。这个设计思路其实和你在实际项目中做组件拆分是一样的先定好边界再填充内容。3.2 大对面的尺寸与颜色分布我用的是一个200px × 200px × 200px的魔方所以每个大对面都是200px见方。标准三阶魔方的配色是这样的前红色#C41E3A后橙色#FF5800左蓝色#0051BA右绿色#009E60上白色#FFFFFF下黄色#FFD500但如果你只是仿照国际标准配色其实远远不够。真正让魔方看起来像魔方的是每一个小方块上的圆角、内边框和黑色分隔线。没有这些细节整个魔方看起来就是色块糊在一起毫无立体感。我的处理方式是大对面本身只做定位内部再用JavaScript动态生成27个小方块。每个小方块都是独立的div元素默认色是黑色或者深灰色六个面根据其在大对面上的位置确定颜色。3.3 建立魔方核心模型我参考的是三阶魔方3×3×3中心有一个隐藏的小立方体不在外部显示只作为内部定位的锚点。真正的结构其实是27个坐标点每个坐标点对应一个小方块坐标范围从(-1, -1, -1)到(1, 1, 1)。用JavaScript描述这个结构大概是这样的const size 3; const cubeSize 200 / size; // 每个小方块的边长约66.67px小方块生成时按坐标位置定位到三维空间里for (let x -1; x 1; x) { for (let y -1; y 1; y) { for (let z -1; z 1; z) { // 创建小方块设置它的平移偏移量 const face document.createElement(div); face.className cubie; const tx x * cubeSize; const ty y * cubeSize; const tz z * cubeSize; face.style.transform translate3d(${tx}px, ${ty}px, ${tz}px); cube.appendChild(face); } } }这里的cubeSize是小方块的边长translate3d把小方块从中心位置移到对应的坐标点上。因为魔方的边长是200px分成3格后每格约66.67px所以偏移量就是x * 66.67。为什么要用-1到1的范围而不是0到2这样定位方便魔方的中心正好在(0,0,0)所有旋转都围绕中心点进行不需要额外做偏移量修正。如果你从0开始你还需要在每次旋转时把坐标系原点修正到中心点凭空多出一堆麻烦。4. 小方块的生成和面颜色填充4.1 六个面的Base HTML结构每个小方块本质上是六个小div元素拼成一个立方体。每个面的大小就是小方块的边长即66.67px。这个数字有小数点直接写在CSS里非常麻烦所以我用CSS变量预先定义了一次.cubie { width: 62px; height: 62px; position: absolute; transform-style: preserve-3d; } .cubie .face { position: absolute; width: 62px; height: 62px; border: 1px solid #000; box-sizing: border-box; border-radius: 6px; }注意小方块的尺寸我设成了62px而不是66.67px给后面留了一点缝隙空间。如果两个相邻小方块之间毫无缝隙转动时因为像素取整问题会导致重叠闪烁那种观感很廉价。保留2px-3px的间隙视觉上会显得每个小方块都是独立的个体转动时也不会打架。实际上的每个小方块生成时我会在JavaScript里创建6个div.face分别设置对应的transformconst faces [ { class: front, transform: rotateY(0deg) translateZ(${halfSize}px) }, { class: back, transform: rotateY(180deg) translateZ(${halfSize}px) }, { class: right, transform: rotateY(90deg) translateZ(${halfSize}px) }, { class: left, transform: rotateY(-90deg) translateZ(${halfSize}px) }, { class: up, transform: rotateX(90deg) translateZ(${halfSize}px) }, { class: down, transform: rotateX(-90deg) translateZ(${halfSize}px) }, ];这里的halfSize是小方块边长的一半即31px。每个面先旋转到对应的朝向然后沿自身的Z轴平移半个边长正好让小方块的六个面包裹成一个封闭的立方体。4.2 颜色分配内层和外层怎么区分这里有一个非常容易踩坑的点内部的面和外面的面颜色不能一样。如果在最中心的小方块它六个面全是内部面不应该上色保持黑色就行。只有朝外的那些面才需要根据魔方配色上色。我的判断逻辑是判断坐标值是否处于边界小方块坐标为(-1, y, z)时它的左侧面朝外左侧面应为蓝色小方块坐标为(1, y, z)时它的右侧面朝外右侧面应为绿色小方块坐标为(x, -1, z)时它的上侧面朝外上侧面应为白色小方块坐标为(x, 1, z)时它的下侧面朝外下侧面应为黄色小方块坐标为(x, y, -1)时它的后面朝外后面应为橙色小方块坐标为(x, y, 1)时它的前面朝外前面应为红色在代码里我用了这样一个映射表const faceColorMap { front: { condition: (x, y, z) z 1, color: #C41E3A }, back: { condition: (x, y, z) z -1, color: #FF5800 }, right: { condition: (x, y, z) x 1, color: #009E60 }, left: { condition: (x, y, z) x -1, color: #0051BA }, up: { condition: (x, y, z) y -1, color: #FFFFFF }, down: { condition: (x, y, z) y 1, color: #FFD500 }, };循环每个坐标、每个方向判断坐标是否在边界上。如果满足条件就设置颜色否则保持默认的深灰色。这样27个小块生成了每个小块的6个面中有几个面被着色也自动确定了。棱块两个面有色、角块三个面有色、中心块一个面有色也就自然区别开了。4.3 渲染细节边框和圆角决定了质感如果只是给不同的面上色整个魔方看上去还是像一个彩色色块积木缺少那种现代魔方的精致感。我做了一个小优化给每个面加上border-radius: 6px配色时相邻块之间留出大约4px的黑色空隙。效果就是每个颜色面都是一个带圆角的小贴片贴在一个黑色小方块上。视觉上就是很标准的玩具魔方外观。这个细节很多人不看源码根本不会注意但少了它观感差距很大。5. 魔方旋转交互绕轴旋转的实现5.1 整体旋转用鼠标拖动控制视角一个不会转的魔方其实叫像素立方体没什么好玩的。真正的魔方交互必须包含两块第一整体视角的旋转让用户可以自由从任意角度观察魔方第二某一层或某一列的独立旋转对应实际玩魔方时的转动。整体视角旋转实现思路特别简单监听鼠标的mousedown、mousemove、mouseup事件计算鼠标移动的偏移量然后更新cube层的rotateX和rotateY角度。let isDragging false; let startX, startY; let currentRotateX -30; let currentRotateY 45; container.addEventListener(mousedown, (e) { isDragging true; startX e.clientX; startY e.clientY; }); document.addEventListener(mousemove, (e) { if (!isDragging) return; const deltaX e.clientX - startX; const deltaY e.clientY - startY; currentRotateY deltaX * 0.3; currentRotateX - deltaY * 0.3; startX e.clientX; startY e.clientY; updateCubeTransform(); }); document.addEventListener(mouseup, () { isDragging false; }); function updateCubeTransform() { cube.style.transform rotateX(${currentRotateX}deg) rotateY(${currentRotateY}deg); }这里deltaX * 0.3的0.3是旋转灵敏度。调大了转得飞快轻轻一碰就转大半圈调小了又觉得跟没转一样需要大范围拖动。我实测下来0.3到0.5比较合适。这个完全看个人手感代码里留了变量想改直接改系数。5.2 单层旋转核心算法解析单层旋转是魔方项目里技术含量最高的部分。当用户点击某个面时我们要确定一个旋转轴和旋转层然后把该层上的所有小方块围绕轴旋转90度。举一个具体例子如果你点击的是最前面一层z 1的所有小方块那么这一层的9个小方块就应该围绕Z轴也就是垂直于该面的轴顺时针或逆时针旋转90度。你需要计算出这9个小方块当前所处的位置把它们从原来的坐标位置重新计算为新坐标位置。这里我用了一个非常经典的旋转公式——绕Z轴旋转90度(x, y, z) - (-y, x, z)这个是数学里的三维坐标旋转公式在只绕Z轴旋转90度时的简化形式。同理绕X轴旋转90度(x, y, z) - (x, -z, y)绕Y轴旋转90度(x, y, z) - (z, y, -x)为什么这里可以直接用数学公式因为我在前面设计数据模型时每个小方块的真实三维坐标是精确的(-1, -1, -1)到(1, 1, 1)旋转就是一个单纯的矩阵运算。如果我当初用的是像素坐标直接显示那这里就麻烦大了得逐一处理平移补偿。这部分我放一个实际代码示例用于处理点击某个面后旋转某一层function rotateLayer(axis, layerIndex, direction) { const affected cubies.filter(cubie { const coord cubie.coords; if (axis x) return coord.x layerIndex; if (axis y) return coord.y layerIndex; if (axis z) return coord.z layerIndex; }); affected.forEach(cubie { const { x, y, z } cubie.coords; if (axis z) { cubie.coords { x: -y * direction, y: x * direction, z }; } else if (axis x) { cubie.coords { x, y: -z * direction, z: y * direction }; } else if (axis y) { cubie.coords { x: z * direction, y, z: -x * direction }; } }); // 动画结束后将新坐标更新到元素样式 applyNewPositions(); }这里的direction是旋转方向1代表顺时针从轴正方向看-1代表逆时针。每次旋转90度后原来的坐标直接替换成新坐标这样数据模型始终保持最新状态下一次旋转不会出错。5.3 动画过渡transition的巧用如果只是把坐标瞬间跳变过去那旋转就是“瞬移”整个魔方看起来就像闪了一下。为了让它有平滑的转动动画我用了CSS的transition属性.cubie { transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); }transition会对transform的变化做插值动画也就是说每当transform属性改变时浏览器会自动算出从旧值到新值之间的过渡帧。0.4秒的时长是我反复调过的太短显得急促太长又会觉得拖沓。cubic-bezier(0.2, 0.8, 0.2, 1)是一个先快后慢的缓动曲线符合物体转动的物理直觉。这里有一个非常隐蔽的坑当你连续旋转同一个面时如果在上一次动画还没结束时就发起下一次旋转元素的起始位置会跟当前实际位置不一致动画会闪一下或者直接跳走。这个问题的本质是动画期间的中间状态没有被记录到数据模型里。我的解决方案是在每次旋转操作发起时用一个函数检测当前是否有动画正在执行如果有就先强制结束上一次动画再把当前角度更新到数据模型然后再启动新动画。简单说就是“先把账算清再动手指”。function forceFinishCurrentAnimation() { // 强行触发一次重排让中间态立刻落到最终态 cubie.getBoundingClientRect(); cubie.style.transition none; cubie.style.transform translate3d(...); // 直接落到当前位置 void cubie.offsetWidth; // 触发重排 cubie.style.transition ; // 恢复动画 }这是个很小的细节但是不做的话项目交互体验会差很多尤其是快速连续点击的时候特别明显。我在做初版时就被这个问题卡了半个小时所以特意记下来提醒大家。6. 从代码到小游戏交互按键与操作引导6.1 键盘操作和按钮控制鼠标拖拽能控制整体视角单层旋转则要配合点击面或快捷键。为了提升可玩性我加了键盘控制Q / W绕X轴旋转上/下A / D绕Y轴旋转左/右Z / X绕Z轴旋转前后以及按钮区放了6个基础操作按钮上旋、下旋、左旋、右旋、前旋、后旋。每个按钮绑定对应轴的旋转动作这样鼠标拖拽只能调整视角按钮才是真正执行转动。document.addEventListener(keydown, (e) { switch (e.key.toLowerCase()) { case q: rotateLayer(x, -1, 1); break; case w: rotateLayer(x, -1, -1); break; case a: rotateLayer(y, -1, 1); break; case d: rotateLayer(y, -1, -1); break; case z: rotateLayer(z, -1, 1); break; case x: rotateLayer(z, -1, -1); break; } });6.2 随机打乱功能魔方游戏不能没有“打乱”功能不然你只能手动把魔方转乱那个过程太痛苦了。我在界面上加了一个“随机打乱”按钮点击后随机执行15-20步旋转操作每步之间间隔大约200毫秒看上去就像真实魔方在被快速打乱。async function scramble() { const axes [x, y, z]; const layers [-1, 0, 1]; const directions [1, -1]; for (let i 0; i 20; i) { const axis axes[Math.floor(Math.random() * 3)]; const layer layers[Math.floor(Math.random() * 3)]; const dir directions[Math.floor(Math.random() * 2)]; rotateLayer(axis, layer, dir); await sleep(200); } }这个功能让整个项目从“静态展示”真正变成“可玩的小游戏”。你点一下“打乱”然后开始尝试还原虽然自己也是半吊子还原水平但过程确实很上瘾。6.3 判定还原状态的实现判断魔方是否还原其实比想象中要简单。因为魔方还原要求每个面上的每个小贴片颜色统一。但在这个项目里我们只要判断每个小方块的坐标是否回到了初始位置颜色自然会回位。确切地说还原状态等价于原始坐标的顺序没变也就是所有小方块的坐标等于初始坐标。我维护了一份初始坐标快照每次旋转完成后检查一次function checkSolved() { return cubies.every((cubie, i) { const init initialCoords[i]; return cubie.coords.x init.x cubie.coords.y init.y cubie.coords.z init.z; }); }如果所有小方块都在初始坐标上就弹出一个“恭喜还原”的提示。这个逻辑有个前提内部小方块即使转了方向只要坐标对了也算还原。但实际玩魔方的人都知道中心块的方向偏差肉眼几乎观察不到所以这个简化对于展示和游戏目的完全够用。7. 性能优化与浏览器兼容性7.1 为什么不要用过多的DOM节点27个小方块每个6个面一共是162个div元素。看起来数量不多但加上CSS 3D变换和持续动画如果处理不好还是会卡。尤其是当你把每个面都加box-shadow、渐变、动画滤镜的时候GPU负担会明显增加。我最终的优化策略是每个小方块的6个面全部生成后才插入DOM避免多次回流不会动的元素内部六个中心面不设置transition整体旋转时不在mousemove里读写style.transform以外的东西面的背景用纯色不用box-shadow模拟阴影减少绘制面积把162个节点创建好之后再一次性挂载到页面这个操作能减少大约几百次的DOM插帧操作是现代前端里“批量操作DOM”思想的微型体现。7.2 手机的触屏适配手机端浏览这个项目时鼠标事件是失效的。我用的是pointerdown、pointermove、pointerup这组事件它比mouse和touch事件更通用桌面端和移动端一套代码都能跑container.addEventListener(pointerdown, startDrag); document.addEventListener(pointermove, onDragMove); document.addEventListener(pointerup, endDrag);在手机上测试时触屏灵敏度跟鼠标不同需要把旋转灵敏度系数稍微调高一点同时加上touch-action: none防止页面滚动被误触.scene { touch-action: none; }这个属性很容易被忽略。不加的话你在手机上拖拽魔方拖到一半页面跟着滚动了体验非常割裂。加上之后整个区域就完全属于魔方了。7.3 兼容性提示这个项目用到的CSS 3D属性已经获得了几乎所有现代浏览器的支持包括Chrome、Firefox、Safari、Edge以及移动端的WebView。如果你还在用特别老的浏览器3D效果会退化成平面色块这是正常的。建议在项目里加一个简单的特性检测这样用户能在不支持的浏览器上看到提示而不会以为你的代码出bug了。检测方式很简单就是用CSS.supports判断一下if (!CSS.supports(transform-style, preserve-3d)) { alert(当前浏览器不支持3D变换请使用现代浏览器打开); }8. 一个更完整的代码版本考虑到很多人需要的是一份可以直接运行的源码我这里给你一个完整版本复制到一个HTML文件里就能跑。这个版本包含了整体旋转、单层按键旋转、随机打乱、还原检测。代码我做了精简但核心逻辑全部保留!DOCTYPE html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title3D魔方小游戏/title style * { box-sizing: border-box; margin: 0; padding: 0; } body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; background: #1a1a2e; color: #fff; font-family: Segoe UI, sans-serif; } .scene { width: 360px; height: 360px; perspective: 800px; perspective-origin: 50% 50%; touch-action: none; cursor: grab; } .scene:active { cursor: grabbing; } .cube { position: relative; width: 200px; height: 200px; margin: 80px auto; transform-style: preserve-3d; transform: rotateX(-30deg) rotateY(45deg); } .cubie { position: absolute; width: 62px; height: 62px; transform-style: preserve-3d; transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); } .cubie .face { position: absolute; width: 62px; height: 62px; border: 1px solid #000; border-radius: 6px; background: #222; box-sizing: border-box; } .controls { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; max-width: 420px; margin-top: 10px; } .controls button { padding: 8px 16px; border: none; border-radius: 8px; background: #16213e; color: #fff; cursor: pointer; font-size: 14px; transition: background 0.2s; } .controls button:hover { background: #0f3460; } .info { margin-top: 12px; font-size: 14px; color: #aaa; text-align: center; max-width: 420px; } /style /head body div classscene idscene div classcube idcube/div /div div classcontrols button idbtnScramble随机打乱/button button idbtnUp上旋/button button idbtnDown下旋/button button idbtnLeft左旋/button button idbtnRight右旋/button button idbtnFront前旋/button button idbtnBack后旋/button /div div classinfo 拖拽旋转视角 | 上旋: W / 下旋: S / 左旋: A / 右旋: D | 随机打乱: 空格 /div script // 配置 const cubeContainer document.getElementById(cube); const scene document.getElementById(scene); const cubieSize 62; const halfSize cubieSize / 2; const gapSpace 2; // 坐标范围 const coords []; for (let x -1; x 1; x) { for (let y -1; y 1; y) { for (let z -1; z 1; z) { coords.push({ x, y, z }); } } } // 六个面的定义, 旋转 平移 const FACES [ { name: front, transform: rotateY(0deg) translateZ(${halfSize}px), test: (c) c.z 1, color: #C41E3A }, { name: back, transform: rotateY(180deg) translateZ(${halfSize}px), test: (c) c.z -1, color: #FF5800 }, { name: right, transform: rotateY(90deg) translateZ(${halfSize}px), test: (c) c.x 1, color: #009E60 }, { name: left, transform: rotateY(-90deg) translateZ(${halfSize}px), test: (c) c.x -1, color: #0051BA }, { name: up, transform: rotateX(90deg) translateZ(${halfSize}px), test: (c) c.y -1, color: #FFFFFF }, { name: down, transform: rotateX(-90deg) translateZ(${halfSize}px), test: (c) c.y 1, color: #FFD500 }, ]; // 存储所有小方块 const cubies []; coords.forEach((coord, index) { const cubie document.createElement(div); cubie.className cubie; cubie.dataset.index index; cubie.coords { ...coord }; // 计算偏移量 const tx coord.x * (cubieSize gapSpace); const ty coord.y * (cubieSize gapSpace); const tz coord.z * (cubieSize gapSpace); cubie.dataset.defaultTransform translate3d(${tx}px, ${ty}px, ${tz}px); // 6个面 FACES.forEach(face { const div document.createElement(div); div.className face; div.style.transform face.transform; if (face.test(coord)) { div.style.background face.color; } cubie.appendChild(div); }); cubie.style.transform cubie.dataset.defaultTransform; cubeContainer.appendChild(cubie); cubies.push(cubie); }); // 初始坐标快照 const initCoords cubies.map(c ({ ...c.coords })); function applyPositions() { cubies.forEach((cubie) { const { x, y, z } cubie.coords; const tx x * (cubieSize gapSpace); const ty y * (cubieSize gapSpace); const tz z * (cubieSize gapSpace); cubie.style.transform translate3d(${tx}px, ${ty}px, ${tz}px); }); } // 旋转某层 function rotateLayer(axis, layerIndex, dir) { const affected cubies.filter(cubie { const c cubie.coords; if (axis x) return c.x layerIndex; if (axis y) return c.y layerIndex; if (axis z) return c.z layerIndex; }); affected.forEach(cubie { const { x, y, z } cubie.coords; if (axis x) { cubie.coords { x, y: -z * dir, z: y * dir }; } else if (axis y) { cubie.coords { x: z * dir, y, z: -x * dir }; } else if (axis z) { cubie.coords { x: -y * dir, y: x * dir, z }; } }); applyPositions(); checkSolved(); } // 整体拖拽旋转 let isDragging false; let startX, startY; let rotateXAngle -30; let rotateYAngle 45; function updateCubeTransform() { cubeContainer.style.transform rotateX(${rotateXAngle}deg) rotateY(${rotateYAngle}deg); } scene.addEventListener(pointerdown, (e) { isDragging true; startX e.clientX; startY e.clientY; scene.setPointerCapture(e.pointerId); }); scene.addEventListener(pointermove, (e) { if (!isDragging) return; const dx e.clientX - startX; const dy e.clientY - startY; rotateYAngle dx * 0.3; rotateXAngle - dy * 0.3; startX e.clientX; startY e.clientY; updateCubeTransform(); }); scene.addEventListener(pointerup, () { isDragging false; }); // 键盘操作 document.addEventListener(keydown, (e) { const key e.key.toLowerCase(); if (key w) rotateLayer(y, -1, 1); if (key s) rotateLayer(y, -1, -1); if (key a) rotateLayer(x, -1, 1); if (key d) rotateLayer(x, -1, -1); if (key q) rotateLayer(z, -1, 1); if (key e) rotateLayer(z, -1, -1); if (key ) { e.preventDefault(); scramble(); } }); // 按钮事件 document.getElementById(btnUp).addEventListener(click, () rotateLayer(y, -1, 1)); document.getElementById(btnDown).addEventListener(click, () rotateLayer(y, -1, -1)); document.getElementById(btnLeft).addEventListener(click, () rotateLayer(x, -1, 1)); document.getElementById(btnRight).addEventListener(click, () rotateLayer(x, -1, -1)); document.getElementById(btnFront).addEventListener(click, () rotateLayer(z, -1, 1)); document.getElementById(btnBack).addEventListener(click, () rotateLayer(z, -1, -1)); document.getElementById(btnScramble).addEventListener(click, scramble); // 随机打乱 async function scramble() { const axes [x, y, z]; const layers [-1, 0, 1]; const dirs [1, -1]; for (let i 0; i 20; i) { const axis axes[Math.floor(Math.random() * 3)]; const layer layers[Math.floor(Math.random() * 3)]; const dir dirs[Math.floor(Math.random() * 2)]; rotateLayer(axis, layer, dir); await new Promise(resolve setTimeout(resolve, 200)); } } // 还原检测 function checkSolved() { const solved cubies.every((cubie, i) { const init initCoords[i]; return cubie.coords.x init.x cubie.coords.y init.y cubie.coords.z init.z; }); if (solved) { setTimeout(() alert(恭喜还原), 150); } } /script /body /html这份代码里rotateLayer函数是整个旋转逻辑的核心。你在点按钮时操作的层固定为-1最外层但随机打乱时会从-1、0、1三个层里随机选这样打乱得更彻底。旋转完成后applyPositions把所有坐标更新到transform上transition过渡自然就产生动画效果。如果你直接复制跑起来会发现几个特点整体旋转非常顺畅没有卡顿感单层旋转的动画里有明显的“转动”过程而不是瞬移快速连续点击多个旋转不会出问题因为每次操作都是基于最新坐标重新计算9. 几个值得警惕的陷阱和优化空间9.1 坐标模型和渲染位置保持同步在整个项目中最容易出错的地方是数据模型和DOM渲染不同步。我在开发时有一段时间在旋转动画中间就去读DOM位置来更新数据模型结果因为动画还没结束读取到的值是中间态坐标直接导致后面所有旋转都错乱了。教训是永远以数据模型为准不要用动画中间态去做计算。所有旋转逻辑先改变cubie.coords再统一把坐标同步到样式上。这样哪怕界面卡顿了数据也不会错。9.2 连续动画的打断问题前面提到过这个问题这里补充一个更常见的表现当你快速点两次同一个旋转按钮时第一次动画还没结束第二次动画就从第一次的初始位置开始跳变魔方会“闪”一下。原因是transition只对属性的“变化过程”插值如果你连续两次赋值又不重置浏览器会认为第二次的起始位置就是第二次设置时那个瞬间的最终值但视觉上它还停在中间。我最终的解决方案很简单每次旋转前把上一个动画强制结束。这里用了一个常见的强制重绘技巧读取一次offsetWidth让浏览器进行一次同步重绘顺带把样式刷新到最终值再开启新动画。9.3 后续的优化方向如果你想把这份代码继续完善我建议从这三个方向入手增加计时器和步数统计这是让游戏目标更明确的好办法很多人玩魔方就是追求速度和步数。增加动画队列连续点击时把操作放入队列依次执行这样能彻底避免动画打断问题而且打乱时的动画会更有节奏感。增加自动还原功能记录所有旋转步骤倒序执行逆操作就能实现一键还原。这个实现起来也不复杂本质上就是维护一个操作历史栈。我后来还做过一个版本把每个面的颜色换成了图片纹理整个魔方看起来像是一个个贴了图案的小盒子效果也很惊艳。如果你喜欢挑战可以试试让每个小方块的面数由动态数据驱动做出来更接近一个真正的3D魔方产品。做这个项目的过程中我最大的体会是把一个抽象的概念拆成一层层的代码结构比直接一步到位要容易得多。先让魔方静态显示出来再慢慢加上交互、动画、打乱、判定还原每一步都有清晰的验证目标。等你走完全程再回头看那些曾经觉得复杂的3D变换原理会发现它们在自己的代码里已经变成了非常自然的东西。希望这份源码和拆解过程对你有帮助也欢迎你在这个基础上做出更有意思的玩法。本文还有配套的精品资源点击获取