ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

纯HTML+CSS+JS手搓3D魔方:从坐标映射到状态同步的完整实现

纯HTML+CSS+JS手搓3D魔方:从坐标映射到状态同步的完整实现 简介这是一份面向前端初学者与网页游戏爱好者的3D魔方小游戏完整源码基于HTML、CSS与JavaScript三大核心技术实现适合用来练习CSS3的3D变换、透视与旋转属性以及JavaScript事件监听、鼠标交互与状态管理逻辑。压缩包共4个文件包含1个html页面、1个css样式表与2个js脚本其中three.min.js提供3D渲染支持index.js负责游戏核心逻辑整体约150KB结构精简便于阅读与二次修改。资源围绕魔方打乱、旋转控制与复位计时等玩法展开读者可从中学习矩阵运算在3D空间旋转中的应用以及用二维数组维护魔方各面颜色状态的数据结构设计思路。目前已有1915人学习下载适合希望提升前端交互开发能力、积累3D网页项目经验的开发者参考借鉴。1. 从零手搓一个 3D 魔方为什么纯 htmlcssjs 反而更难写很多人第一次搜「htmlcssjs实现的3D魔方小游戏源代码」心里想的是一份能直接双击打开、转起来不卡、还能打乱的完整代码。真动手才发现难点根本不在「画一个立方体」而在「让 27 个小方块像真魔方一样绕轴旋转、层与层之间不穿模、鼠标拖拽和键盘操作不打架」。纯前端做 3D 魔方本质是用 CSS 的transform和perspective模拟三维空间再用 JS 维护一套「逻辑坐标 → 屏幕坐标」的映射关系。它适合两类人想练 CSS 3D 变换和矩阵思维的前端新手以及想拿一个完整小游戏练手、又不想引入 three.js 这类重库的开发者。下面这套方案不依赖任何构建工具一个 HTML 文件加内联样式和脚本就能跑但每一步的坑我都会标出来。2. 拆解 3D 魔方的坐标系27 个小方块怎么摆才不穿模2.1 用 CSS 3D 还是 Canvas/WebGL先算一笔账选型这件事我一般先看「你要的是真 3D 还是看起来像 3D」。CSS 3D 变换的底层是浏览器把每个元素当成一个平面通过transform: rotateX/rotateY/translateZ组合出空间感它没有真正的深度缓冲所以两个面重叠时谁在前谁在后靠的是transform-style: preserve-3d和元素在 DOM 里的绘制顺序。这意味着如果你把 27 个小方块按顺序塞进 DOM旋转到某些角度时后面的方块可能盖住前面的出现「穿模」的玄学现象。Canvas 2D 手写投影矩阵能完全控制绘制顺序但代码量翻倍WebGL 性能最好可对新手来说着色器和矩阵库的学习成本太高。纯 htmlcssjs 方案的优势是代码可读、调试直观直接改 CSS 看效果、不需要处理光照和纹理。代价就是必须手动解决 z 轴排序问题。我的做法是每个小方块用 6 个面div拼成父容器设transform-style: preserve-3d然后给每个面单独设backface-visibility: hidden这样背面自动隐藏减少一半穿模概率。剩下的靠旋转时动态调整translateZ的层级来兜底。2.2 逻辑坐标与屏幕坐标的映射表魔方的核心数据模型不是 27 个 div而是一个三维数组。每个小方块在逻辑空间里的坐标是(x, y, z)取值-1、0、1分别代表左中右、下中上、后中前。屏幕坐标则是translateX(x*size) translateY(y*size) translateZ(z*size)其中size是小方块的边长比如 60px。这样写的好处是旋转某一层时你只需要改逻辑坐标再统一重算屏幕坐标不用去碰 CSS 的旋转角度。// 逻辑坐标 - 屏幕坐标 const SIZE 60; // 小方块边长 px function toScreen(pos) { // pos: {x, y, z} 取值 -1/0/1 return { tx: pos.x * SIZE, ty: pos.y * SIZE, tz: pos.z * SIZE }; } // 初始化 27 个小方块 const cubes []; for (let x -1; x 1; x) { for (let y -1; y 1; y) { for (let z -1; z 1; z) { if (x 0 y 0 z 0) continue; // 中心块不渲染 const el document.createElement(div); el.className cube; const s toScreen({ x, y, z }); el.style.transform translate3d(${s.tx}px, ${s.ty}px, ${s.tz}px); cubes.push({ el, pos: { x, y, z } }); scene.appendChild(el); } } }这段代码里translate3d比分开写translateX/Y/Z更稳浏览器会强制走 GPU 合成层。SIZE建议设成偶数否则旋转 90 度后像素对齐会出现半像素模糊。中心块(0,0,0)不渲染因为它在内部永远看不见省一个 div 就少一份重绘开销。2.3 六个面的贴纸与颜色映射每个小方块需要 6 个面但只有暴露在外部的面才需要贴纸颜色。判断规则很简单如果pos.x 1那么朝右的面是外表面给红色pos.x -1给橙色pos.y 1给白色pos.y -1给黄色pos.z 1给绿色pos.z -1给蓝色。内部面统一给黑色或透明。.cube { position: absolute; width: 60px; height: 60px; transform-style: preserve-3d; } .face { position: absolute; width: 60px; height: 60px; border: 1px solid #222; backface-visibility: hidden; } /* 六个面的基础变换 */ .face.front { transform: rotateY(0deg) translateZ(30px); } .face.back { transform: rotateY(180deg) translateZ(30px); } .face.right { transform: rotateY(90deg) translateZ(30px); } .face.left { transform: rotateY(-90deg) translateZ(30px); } .face.top { transform: rotateX(90deg) translateZ(30px); } .face.bottom { transform: rotateX(-90deg) translateZ(30px); }translateZ(30px)里的 30 是边长的一半这样六个面才能拼成一个闭合立方体。backface-visibility: hidden必须加否则旋转到背面时你会看到反面的贴纸透过来像鬼影一样。贴纸颜色用内联样式动态设置不要写死在 CSS 类里因为同一个面在不同旋转状态下颜色会变。3. 旋转逻辑把「转一层」翻译成矩阵运算3.1 绕轴旋转 90 度的坐标变换公式魔方有 6 个面、3 个轴每次转一层就是绕某个轴旋转 90 度。以绕 Y 轴竖轴旋转为例逻辑坐标的变换是x z, z -x顺时针或x -z, z x逆时针。这个公式不用背你拿一张纸画个俯视图就能推出来。关键是旋转只影响该层的小方块比如pos.y 1的顶层其他层不动。// 绕 Y 轴旋转某一层dir1 顺时针dir-1 逆时针 function rotateY(layerY, dir) { cubes.forEach(cube { if (cube.pos.y ! layerY) return; const { x, z } cube.pos; if (dir 1) { cube.pos.x z; cube.pos.z -x; } else { cube.pos.x -z; cube.pos.z x; } // 重算屏幕坐标 const s toScreen(cube.pos); cube.el.style.transform translate3d(${s.tx}px, ${s.ty}px, ${s.tz}px); }); }这段代码只改了逻辑坐标和translate3d没有改每个面的旋转角度。问题来了小方块本身没有转只是位置变了那贴纸方向不就错了吗对所以还需要给每个小方块加一个「自身旋转」的累积角度。常见做法是在cube对象里存一个rot矩阵或欧拉角每次层旋转时同步更新。偷懒一点的做法是直接给cube.el追加rotateY(90deg)但多次旋转后角度会累积成 360 的倍数浏览器处理起来没问题只是你调试时看到的transform字符串会很长。3.2 用 requestAnimationFrame 做平滑动画直接改transform是瞬间跳变体验很差。要平滑就得在 300ms 内插值角度。我的做法是旋转前先记录每个受影响方块的起始translate3d和起始旋转角然后用requestAnimationFrame每帧更新进度t0 到 1用缓动函数easeOutCubic让动画自然减速。function animateRotation(axis, layer, dir, duration 300) { const start performance.now(); const affected cubes.filter(c c.pos[axis] layer); // 记录起始状态 const from affected.map(c ({ ...c.pos, el: c.el })); function frame(now) { const t Math.min((now - start) / duration, 1); const ease 1 - Math.pow(1 - t, 3); // easeOutCubic const angle ease * 90 * dir; affected.forEach((c, i) { // 这里用临时包裹层或直接改 transform 组合 // 简化写法先平移回原点旋转再平移回去 const s toScreen(from[i]); c.el.style.transform translate3d(${s.tx}px, ${s.ty}px, ${s.tz}px) rotate${axis.toUpperCase()}(${angle}deg); }); if (t 1) { requestAnimationFrame(frame); } else { // 动画结束提交逻辑坐标 rotateY(layer, dir); // 以 Y 轴为例 } } requestAnimationFrame(frame); }注意动画过程中改的是视觉transform逻辑坐标要等动画结束再提交否则会出现「转了一半坐标就变了」的错乱。duration设 300ms 比较跟手低于 200ms 看不清高于 500ms 会觉得拖沓。easeOutCubic比线性自然但别用easeInOut魔方旋转是「启动快、停止慢」更符合直觉。3.3 鼠标拖拽与键盘事件的冲突处理鼠标拖拽旋转视角和键盘转层是两套输入很容易打架。我的经验是用pointerdown记录起始坐标pointermove计算位移如果位移超过 10px 就判定为「拖拽视角」此时禁用键盘转层如果pointerup时位移小于 10px才判定为「点击」不触发任何旋转。键盘用keydown监听但要用event.repeat过滤掉长按重复触发否则按住 U 键会疯狂转层。let isDragging false; let startX 0, startY 0; let rotX -30, rotY -45; // 初始视角 scene.addEventListener(pointerdown, e { isDragging true; startX e.clientX; startY e.clientY; }); window.addEventListener(pointermove, e { if (!isDragging) return; const dx e.clientX - startX; const dy e.clientY - startY; if (Math.abs(dx) 10 || Math.abs(dy) 10) { rotY dx * 0.5; rotX - dy * 0.5; scene.style.transform rotateX(${rotX}deg) rotateY(${rotY}deg); startX e.clientX; startY e.clientY; } }); window.addEventListener(pointerup, () { isDragging false; }); window.addEventListener(keydown, e { if (e.repeat || isDragging) return; const key e.key.toUpperCase(); if (key U) animateRotation(y, 1, -1); if (key D) animateRotation(y, -1, 1); // 其他键位同理 });rotX初始给 -30 度、rotY给 -45 度是为了让魔方一打开就呈现一个能同时看到三个面的角度比正对着好看得多。拖拽灵敏度0.5是调出来的太高会晕太低转不动。pointer事件比mouse事件好因为它同时兼容触屏和鼠标不用写两套。4. 避坑与排查那些让我熬夜的翻车现场4.1 旋转后贴纸颜色错乱现象转了几层之后红色面跑到绿色面旁边颜色全乱了。原因只更新了逻辑坐标没有同步更新每个小方块自身的旋转角度导致贴纸朝向和位置不匹配。解决在cube对象里维护一个rotation状态可以用欧拉角或四元数每次层旋转时对受影响方块的rotation也做同样的 90 度变换渲染时把rotation拼到transform里。偷懒方案是给每个小方块套一个内层 div外层管位置内层管自身旋转互不干扰。4.2 动画过程中快速连按导致状态错乱现象连续快速按 U 键魔方转着转着就卡住或者坐标错位。原因上一次动画还没结束逻辑坐标还没提交下一次动画又开始了两次动画的起始状态重叠。解决加一个isAnimating标志位动画进行中直接return忽略新的旋转请求。或者用队列把请求存起来等当前动画结束再执行下一个。我一般用标志位简单粗暴用户体验上就是「按快了没反应」比错乱好。4.3 移动端触摸拖拽触发页面滚动现象手机上拖拽魔方页面跟着上下滚动魔方没转。原因pointermove默认会触发浏览器的滚动手势。解决给scene容器加touch-action: none;告诉浏览器这个区域的手势由 JS 全权处理。同时pointerdown里调e.preventDefault()但注意别把点击事件也拦掉了所以只在pointermove里判断位移超过阈值后再preventDefault。4.4 小方块数量多导致低端机掉帧现象在旧手机上旋转视角时明显卡顿帧率掉到 20 以下。原因27 个小方块 × 6 个面 162 个 div每个 div 都有独立的transform和border重绘开销大。解决把内部面永远看不见的面直接不渲染只渲染外表面div 数量降到 54 个左右。另外给scene加will-change: transform;提升为合成层但别给每个小方块都加否则内存爆炸。border改成box-shadow: inset或outline减少重绘面积。4.5 旋转 90 度后像素模糊现象魔方转到某些角度贴纸边缘发虚像蒙了一层雾。原因translateZ的值是 30px但小方块边长 60px旋转后元素在 3D 空间中的投影不是整数像素浏览器做了抗锯齿。解决把SIZE改成偶数translateZ用SIZE/2并且给scene设perspective: 1200px;而不是默认值。perspective太小比如 500px会导致透视畸变严重太大比如 3000px又失去立体感1200px 是试出来的甜点值。5. 进阶技巧用一套状态机管住打乱、还原与计时5.1 打乱算法别用纯随机会出「无效步」打乱魔方不是随机转 20 次就完事。纯随机很容易出现「转了 U 又转 U」这种抵消步等于没打乱。我的做法是维护一个「上一步转的轴和层」下一步如果抽到同一个轴同一层就重新抽保证连续两步不抵消。打乱步数设 20 到 25 步比较合适太少不够乱太多浪费时间。function generateScramble(steps 20) { const axes [x, y, z]; const layers [1, -1]; const dirs [1, -1]; const moves []; let lastAxis null, lastLayer null; while (moves.length steps) { const axis axes[Math.floor(Math.random() * 3)]; const layer layers[Math.floor(Math.random() * 2)]; const dir dirs[Math.floor(Math.random() * 2)]; // 避免同轴同层连续转 if (axis lastAxis layer lastLayer) continue; moves.push({ axis, layer, dir }); lastAxis axis; lastLayer layer; } return moves; }这个算法不保证「不出现同轴不同层」的情况比如先转顶层再转底层这是合法的不用过滤。steps设 20 是 WCA 比赛的标准打乱长度日常玩 15 步也够。5.2 计时器与步数统计的绑定计时器不能从打乱第一步就开始那样对玩家不公平。正确做法是打乱动画全部播放完毕后启动计时器玩家第一次按键转层时计时器开始走魔方还原所有面颜色一致时计时器停止。步数统计则是在每次有效旋转时1动画被忽略的按键不计入。let timer null; let startTime 0; let moveCount 0; let isScrambling false; function startTimer() { if (timer) return; startTime performance.now(); timer setInterval(() { const elapsed (performance.now() - startTime) / 1000; timerEl.textContent elapsed.toFixed(2) s; }, 50); } function stopTimer() { clearInterval(timer); timer null; } // 在有效旋转的动画结束后调用 function onMoveComplete() { moveCount; moveEl.textContent moveCount; if (isSolved()) { stopTimer(); alert(还原成功用时 timerEl.textContent); } }setInterval用 50ms 刷新一次显示比requestAnimationFrame省性能因为计时器不需要 60fps。isSolved()的判断逻辑是检查每个面的 9 个贴纸颜色是否一致用逻辑坐标遍历比读 DOM 快得多。5.3 用 CSS 变量统一调色换肤不用改 JS魔方配色如果写死在 JS 里想换一套配色就得翻代码。我的习惯是把六种颜色定义成 CSS 变量JS 只负责给面加类名颜色由 CSS 决定。这样换肤只需要改:root里的变量值。:root { --color-front: #e74c3c; --color-back: #e67e22; --color-right: #2ecc71; --color-left: #3498db; --color-top: #ecf0f1; --color-bottom: #f1c40f; } .face.front { background: var(--color-front); } .face.back { background: var(--color-back); } .face.right { background: var(--color-right); } .face.left { background: var(--color-left); } .face.top { background: var(--color-top); } .face.bottom { background: var(--color-bottom); }这套配色是我调了十几版才定下来的红色和橙色区分度够高白色和黄色在浅色背景下也不糊。如果你要做暗色主题把--color-top改成深灰--color-bottom改成亮黄对比度依然够。5.4 一个我踩过的坑别在动画里读 offsetWidth早期版本我在requestAnimationFrame里调了el.offsetWidth来获取实时位置结果帧率直接腰斩。原因是offsetWidth会强制浏览器重排reflow每帧重排 162 次不卡才怪。后来改成全部用逻辑坐标计算只在最后提交时写一次transform帧率立刻回到 60。血泪经验动画循环里只碰transform和opacity其他属性一律提前算好。这套代码我前后重构了四版从最初的「能转就行」到现在的「打乱、计时、还原检测一条龙」最大的体会是纯前端 3D 魔方的难点从来不是数学而是「状态同步」——逻辑坐标、视觉变换、动画进度、用户输入四者必须严格对齐错一个就翻车。如果你打算动手建议先把逻辑坐标的旋转公式在纸上推三遍再写代码能省下大量调试时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表