Three.js 纯 JS 魔方实战:3D 状态建模、动画调度与拖拽旋转

发布时间:2026/10/31 23:52:45
Three.js 纯 JS 魔方实战:3D 状态建模、动画调度与拖拽旋转

简介:这是一份面向JavaScript初学者与前端进阶者的纯JS魔方模拟项目源码,围绕魔方旋转逻辑与网页交互展开,适合用来练习DOM操作、事件监听、动画调度及状态数据结构,也可作为算法可视化与在线魔方演示的参考实例。压缩包共21个文件,包含15个JavaScript脚本、4个CSS样式、1个HTML入口及1张图片素材,整体约255KB,体量轻巧,代码与界面资源划分清楚,便于按模块阅读和二次改造。已有783人学习下载,说明其在同类练手项目中具备一定参考度。读者可从中了解如何用原生脚本组织立方体状态、响应点击或拖动操作、实现平滑转动效果,并体会精简冗余代码后的可读性与可维护性,适合拿来拆解交互流程、补充前端实战经验或作为课程示例。

1. 一个 index.html 就能玩的魔方:纯 JS 版到底做了什么

上周有个朋友丢来一个压缩包,说"你帮我看看这个魔方代码为什么转着转着就散架了"。我解压完发现连构建工具都没有,双击index.html,屏幕上 27 个小方块以一个斜 45° 的视角悬在那里,鼠标摁住某一面往右一拖,整层就平滑地转了 90°。这套纯 JS 版魔方没有后端、没有游戏引擎、没有打包步骤,靠的是一个 3D 渲染库加几百行状态逻辑,把"拧魔方"这件事完整搬进了浏览器。

它适合两类人:一类是想在网页里塞一个可交互 3D 小玩具的前端,直接改吧改吧就能嵌进自己的页面;另一类是想搞明白"3D 物体的状态和动画怎么分离"的开发者,魔方是个特别好的练手题材——状态空间小、规则明确、出错立刻看得见。下面我按自己拆这份代码的顺序,从数据结构一路讲到怎么加求解器。

2. 三层架构拆解:26 个块、一个渲染器与一套动画调度

这份代码最值得先看的是它的分层,而不是某一段算法。整个项目被切成了三块互不越界的东西:状态层只管"哪个块在哪个位置、朝哪个方向",渲染层只管"把状态画出来",动画层夹在中间,负责把一次瞬时状态变更拉成一段 200ms 的过渡。分层没做好的魔方项目,最常见的症状是转两次之后块的位置开始漂移,或者动画播到一半状态已经改了,画面和逻辑对不上。

2.1 26 个块的状态表示:位置、朝向与贴纸颜色

魔方的状态建模有两条路。一条是"54 张贴纸"模型,把六个面各 9 个格子当数组存颜色,转动就是数组下标置换;另一条是"26 个块"模型,每个块有位置坐标、朝向、以及它自带的那几张贴纸。这份纯 JS 版走的是后者,原因是渲染层要按块去画几何体,块模型和 Three.js 的 Object3D 能一一对应,省掉一层映射。

位置用-1 / 0 / 1三个整数表示,绕原点建坐标系,这样 27 个格点里挖掉中心那个就是 26 个块。整数坐标的好处是旋转 90° 之后结果仍是整数,不会积累浮点误差;坏处是每次动画结束必须做一次量化,否则0.9999999会让"这个块在哪一层"的过滤条件失效。

朝向用一个四元数存。8 个角块每个有 3 种朝向,12 个棱块每个有 2 种,中心块只有 1 种,但这些差异不需要在数据结构上体现——统一给每个块一个四元数,转的时候跟着乘,判定还原的时候再按贴纸法线去分组,代码会短很多。贴纸信息挂在块的userData上,记录本地法线和颜色,这样"这块的红色面现在朝哪"随时能算出来。

2.2 渲染层的两种选型:Three.js 与 CSS 3D

纯 JS 不等于不用库,这份代码用的是 Three.js,通过 importmap 从 CDN 直接加载,没有 node_modules。选它的理由是attach()这个 API 太适合做层旋转了:把一个块从场景图的一个父节点挂到另一个父节点时,世界变换保持不变,动画播完再挂回去,位置和朝向自动就是旋转后的结果,不用自己算矩阵乘法。

另一条路是纯 CSS 3D,每个小方块用 6 个div拼立方体,父容器加transform-style: preserve-3d,转层就是给一组元素加rotateX/Y/Z。这条路零依赖、体积小,缺点是层旋转动画结束后得手动把每个块的 transform 矩阵重新分解成位置和朝向,浏览器返回的是matrix3d(...)字符串,解析起来很脏。我一般只在"必须打进一个不能引外部脚本的单文件页面"时才走 CSS 3D。

维度Three.js 方案CSS 3D 方案
依赖体积约 600KB(可摇树裁剪)零
层旋转实现父节点旋转 + attach逐个元素改 transform
拾取(点选某个面)Raycaster 直接给面和法线靠elementFromPoint反查,较绕
光照与材质内置,观感好调只能用渐变模拟
适合场景独立页面、需要拾取交互嵌入现有页面、极致轻量

2.3 动画调度:把一次 90° 转动拆成帧

状态变更必须是瞬时的、原子的,动画只是把它"演"出来。这条原则落实到一个animating标志位加一个队列:动画进行中再点按钮,新的转动进队列,等当前这帧收尾了再执行。没有这个队列,玩家快速连点两下,两个 pivot 组会同时存在,块的父子关系错乱,画面直接开花。

时间基准用performance.now()而不是每帧固定加一个角度。固定步长在 60Hz 屏上看着正常,换到 120Hz 屏动画速度就翻倍,这是很典型的一个翻车点。缓动曲线用 easeOutCubic,起始快收尾慢,视觉上更接近真实手指搓动的手感;线性缓动看起来会很"机械"。

动画播完的收尾动作有三件:把块从 pivot 挂回主组、位置向量round()到整数、四元数对齐到最近的 90° 倍数。三件事少做一件,几十步之后就会看到某些面出现一条细缝——那是两个块的位置差了 0.0001 导致的 z-fighting。

3. 核心链路实现:从指针拖拽到 90° 层旋转的完整代码

拆完架构,落到能跑的具体代码。这一章按数据流顺序走:先建块,再讲转层,再讲鼠标怎么把一次拖拽翻译成"绕哪个轴、转哪一层、正转还是反转",最后是打乱、计时和还原判定。

3.1 块与贴纸的数据结构

建块这一步很直白,三重循环遍历-1..1,跳过原点,给每个块挂上它应该有的贴纸。判断"该不该有贴纸"的条件是:块在某个轴上的坐标等于 ±1,就在那个方向上贴一张。

import * as THREE from 'three'; const COLORS = { R: 0xd93b3b, L: 0xf08a24, U: 0xf5f0e6, D: 0xf2d13c, F: 0x2fa84f, B: 0x2f6fd9 }; const FACE_DEF = [ { key: 'R', normal: [ 1, 0, 0] }, { key: 'L', normal: [-1, 0, 0] }, { key: 'U', normal: [ 0, 1, 0] }, { key: 'D', normal: [ 0,-1, 0] }, { key: 'F', normal: [ 0, 0, 1] }, { key: 'B', normal: [ 0, 0,-1] }, ]; const cubies = []; 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; // 中心不可见,跳过 // 方块本体:稍微缩到 0.95,块与块之间留缝,视觉上更清晰 const mesh = new THREE.Mesh( new THREE.BoxGeometry(0.95, 0.95, 0.95), new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.85 }) ); mesh.position.set(x, y, z); // 贴纸:每个露出的面上贴一个薄片,法线朝外 mesh.userData.stickers = []; for (const f of FACE_DEF) { const [nx, ny, nz] = f.normal; // 只有块在该方向上的坐标等于 ±1 时,这个面才露在外面 if (nx * x + ny * y + nz * z !== 1) continue; const localNormal = new THREE.Vector3(nx, ny, nz); mesh.userData.stickers.push({ localNormal, color: f.key }); } cubies.push(mesh); } } }

nx * x + ny * y + nz * z !== 1这一行是判断"面是否朝外"的紧凑写法。以 (1,1,1) 这个角块为例,法线 (1,0,0) 点乘坐标得 1,说明 +X 面朝外,贴纸;法线 (0,-1,0) 点乘得 -1,说明 -Y 面被包在里面,不贴。这样 26 个块自动得到 6 个中心各 1 张、12 个棱各 2 张、8 个角各 3 张,共 54 张贴纸。

贴纸本身在渲染时是独立的小平面(PlaneGeometry),比用 6 材质 Box 更好控制——想给贴纸加圆角或者描边,换一个材质就行,不用动块的几何体。userData.stickers里存的localNormal是关键:它跟着块的四元数一起转,任何时候都能算出"这张贴纸现在朝哪个世界方向",还原判定全靠它。

3.2 单层旋转的数学:绕轴旋转与坐标映射

转层的实现分两段:动画期间用一个临时的 pivot 组带着整层转,动画结束时把块挂回主组并量化。这个 attach/detach 的套路能同时搞定视觉和状态,比手写矩阵乘再赋值省事得多。

const AXIS_VEC = { x: new THREE.Vector3(1,0,0), y: new THREE.Vector3(0,1,0), z: new THREE.Vector3(0,0,1) }; let animating = false; const queue = []; let onMoveDone = () => {}; export function rotateLayer(axis, layer, turns, duration = 200) { if (animating) { queue.push([axis, layer, turns, duration]); return; } const moving = cubies.filter(c => Math.round(c.position[axis]) === layer); if (!moving.length) return; animating = true; const pivot = new THREE.Group(); scene.add(pivot); moving.forEach(c => pivot.attach(c)); // attach 保持世界变换,块跟着 pivot 转 const targetAngle = -turns * Math.PI / 2; // 负号:从轴正方向看向原点时,正转是逆时针 const t0 = performance.now(); function tick(now) { const p = Math.min((now - t0) / duration, 1); const e = 1 - Math.pow(1 - p, 3); // easeOutCubic pivot.setRotationFromAxisAngle(AXIS_VEC[axis], targetAngle * e); if (p < 1) { requestAnimationFrame(tick); return; } // 收尾:挂回主组 + 量化,防浮点漂移 moving.forEach(c => { scene.attach(c); c.position.round(); snapQuaternion(c.quaternion); }); scene.remove(pivot); animating = false; onMoveDone({ axis, layer, turns }); if (queue.length) rotateLayer(...queue.shift()); } requestAnimationFrame(tick); } // 把四元数对齐到最近的 90° 倍数,避免长跑之后出现倾斜 function snapQuaternion(q) { const m = new THREE.Matrix4().makeRotationFromQuaternion(q); for (let i = 0; i < 16; i++) m.elements[i] = Math.round(m.elements[i]); q.setFromRotationMatrix(m); }

参数上,axis取'x' | 'y' | 'z',layer取-1 | 0 | 1(0 是中层,做 M/E/S 这类公式时要用),turns取±1表示顺逆时针,duration默认 200ms——低于 150ms 看着像瞬移,高于 300ms 连拧时会有粘滞感,200 是我试下来最舒服的值。

targetAngle前面那个负号容易搞反。约定是"从轴正方向朝原点看,逆时针为正",Three.js 的setRotationFromAxisAngle用的是右手定则,两者对+Y轴是一致的,对+X和+Z就需要靠符号调整。我第一次写的时候三个轴的符号全配错,转出来像是魔方在跳机械舞,血泪经验是:先固定只调一个轴,拿一个已知的公式(比如 R U R' U')对比真实魔方验证方向,对了再推广。

3.3 输入层:鼠标拖拽选面与方向判定

拖拽操作的难点不在拾取,而在"把屏幕上的二维位移翻译成三维的旋转决策"。用户按住某个面往右拖,这个"右"在屏幕上是一个方向,在世界空间里可能对应绕 Y 轴、也可能对应绕 Z 轴,取决于当前视角朝向。

const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); let drag = null; renderer.domElement.addEventListener('pointerdown', e => { pointer.set( (e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1 ); raycaster.setFromCamera(pointer, camera); const hit = raycaster.intersectObjects(cubies, true)[0]; if (!hit) return; // hit.face.normal 是几何体本地空间,转到世界空间才是真正的朝向 const n = hit.face.normal.clone() .applyQuaternion(hit.object.getWorldQuaternion(new THREE.Quaternion())) .round(); drag = { normal: n, startX: e.clientX, startY: e.clientY, cubie: hit.object }; }); renderer.domElement.addEventListener('pointerup', e => { if (!drag) return; const dx = e.clientX - drag.startX; const dy = e.clientY - drag.startY; if (Math.hypot(dx, dy) < 8) { drag = null; return; } // 位移太小,当点击处理 // 与法线垂直的两个轴,就是这次拖拽可能的旋转轴 const candidates = ['x', 'y', 'z'].filter(a => Math.round(drag.normal[a]) === 0); // 把候选轴投影到屏幕空间,取与拖拽方向最接近的那个 let best = null, bestScore = -Infinity; for (const axis of candidates) { for (const sign of [1, -1]) { const screenVec = projectToScreen(AXIS_VEC[axis].clone().multiplyScalar(sign)); const score = screenVec.x * dx + screenVec.y * (-dy); // 屏幕 y 轴向下,取反 if (score > bestScore) { bestScore = score; best = { axis, sign }; } } } // 层号由被按住的那个块在旋转轴上的坐标决定 const layer = Math.round(drag.cubie.position[best.axis]); // 绕该轴转 -sign 方向,才能让被按住的面跟着手指走 rotateLayer(best.axis, layer, -best.sign); drag = null; }); // 把世界方向投影成归一化的屏幕方向向量 function projectToScreen(v) { const a = v.clone().project(camera); const o = new THREE.Vector3(0, 0, 0).project(camera); return new THREE.Vector2(a.x - o.x, -(a.y - o.y)).normalize(); }

pointerdown里那段applyQuaternion是必须的:hit.face.normal给的是几何体原始的法线,块转过之后它就不代表屏幕方向了,得乘上块当前的世界四元数才是真实的朝外方向。我见过不少实现漏了这一步,表现是"刚开局拖拽正常,拧过几次之后拖拽方向就开始乱"。

pointerup的判断链条是:法线定出两个候选轴 → 候选轴投影到屏幕 → 与拖拽向量做点积 → 分最高者胜出 → 层号取被按块在该轴上的坐标。最后那个-best.sign是方向修正,因为"轴正向"和"面跟着手指走"是反的。这段逻辑看着绕,但它对任意视角都成立,把相机转到背面再拖也不会出错,比按屏幕象限硬编码四个方向要稳。

3.4 打乱、计时与还原检测

打乱不能纯随机。同一条轴连着转两次在视觉上是冗余的(转完又转回来),而且会让打乱序列看起来"没那么乱"。代码里做了个最小约束:相邻两步不用同一条轴。

const AXES = ['x', 'y', 'z']; export function scramble(n = 20) { const seq = []; let lastAxis = null; for (let i = 0; i < n; i++) { let axis; do { axis = AXES[(Math.random() * 3) | 0]; } while (axis === lastAxis); lastAxis = axis; // 只转外层,中层的随机转动会让打乱结果难以肉眼核对 const layer = Math.random() < 0.5 ? -1 : 1; const turns = Math.random() < 0.5 ? 1 : -1; seq.push([axis, layer, turns]); } return seq; }

还原判定按贴纸法线分组:把每张贴纸的法线用块的四元数转到当前空间,四舍五入成"0,1,0"这样的字符串当 key,同一个 key 下的颜色集合大小必须是 1。

export function isSolved() { const buckets = new Map(); for (const c of cubies) { for (const s of c.userData.stickers) { const n = s.localNormal.clone().applyQuaternion(c.quaternion); const key = ['x', 'y', 'z'].map(a => Math.round(n[a])).join(','); if (!buckets.has(key)) buckets.set(key, new Set()); buckets.get(key).add(s.color); } } // 六个方向、每个方向只有一种颜色,就是还原态 return [...buckets.values()].every(set => set.size === 1); }

这个判定的成本是 54 次四元数乘加几十次 Set 操作,一帧调一次完全无压力,不需要做脏标记优化。计时器挂在onMoveDone上:第一转动开始时启动performance.now(),isSolved()返回 true 时停表。这里有个细节——打乱序列本身也会触发onMoveDone,得用一个scrambling标志位把这段时间排除掉,否则秒表在打乱阶段就跑起来了。

4. 跑起来:目录结构、依赖加载与本地调试入口

这份代码的部署成本几乎为零,但要改得顺手,目录和调试入口得先理清楚。下面是我整理后的结构,和原包大同小异,主要差别是我把solver.js单独抽了出来,方便后面接求解器。

4.1 目录结构与文件职责

cube-js/ ├── index.html # 入口,含 importmap 与 canvas 容器 ├── style.css # 画布铺满、按钮栏、计时器样式 └── src/ ├── main.js # 场景、相机、灯光、渲染循环 ├── cube.js # 建块、转层、打乱、还原判定(第 3 章那些代码) ├── input.js # 指针拖拽 + 键盘映射 ├── ui.js # 按钮、计时器、步数显示 └── solver.js # 预留,二次开发时接求解器

main.js只做三件事:初始化渲染器、注册渲染循环、把cube.js导出的接口挂到window上方便调试。渲染循环里除了renderer.render(),还要更新一个THREE.Clock的 delta——虽然这个项目没用到物理,但后面加惯性旋转或者拖拽甩动时会需要。

键盘映射建议在这一层做,键位沿用标准的魔方记号:U/D/L/R/F/B对应六个面顺时针,按住 Shift 表示逆时针,斜体'不方便输入就不做了。映射表放在input.js顶部,改键位只动这一处。

4.2 依赖加载与本地启动

不用构建工具时,靠 importmap 让浏览器知道import 'three'去哪儿找:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="stylesheet" href="./style.css" /> <script type="importmap"> { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } } </script> </head> <body> <div id="app"></div> <div id="hud"> <span id="timer">00:00.0</span> <button id="btn-scramble">打乱</button> <button id="btn-reset">复原</button> </div> <script type="module" src="./src/main.js"></script> </body> </html>

type="importmap"必须出现在任何type="module"脚本之前,顺序错了浏览器会直接报 "Failed to resolve module specifier"。这也是我在本地起服务时踩过的第一个坑——直接双击index.html打开是file://协议,ES module 会被 CORS 拦下来,必须起个静态服务:

# 任选一种,Python 自带的最省事 python3 -m http.server 5173 # 或者 npx serve .

three/addons/那条映射是留给OrbitControls的,装上之后相机可以用鼠标右键拖动旋转,跟左键拖拽转层不冲突——OrbitControls 默认左键也是旋转视角,得把controls.mouseButtons改成只认右键和中键,否则两种拖拽会打架。

4.3 调试面板与状态快照

改魔方这种项目,肉眼调试的效率极低,状态对不对得靠打印。我在main.js末尾挂了一个全局对象:

import * as Cube from './cube.js'; window.cube = { ...Cube, // rotateLayer / scramble / isSolved snapshot() { return Cube.cubies.map(c => ({ pos: c.position.toArray().map(Math.round), q: c.quaternion.toArray().map(v => +v.toFixed(3)), colors: c.userData.stickers.map(s => s.color), })); }, // 把一段转动序列跑完,用于复现 bug runSeq(seq) { seq.forEach(args => Cube.rotateLayer(...args)); }, };

snapshot()返回的是可序列化的纯数据,复制到控制台里 diff 两次的结果,能很快看出是哪一层哪个块没跟上。runSeq()更关键——把出问题的那串操作固化成数组,一键重放,比反复手拧快十倍。

提示:调试时把duration改成 0 会让动画瞬间完成,但量化逻辑仍会执行,适合验证纯状态问题;验证手感时再改回 200。

渲染性能上,27 个块加 54 张贴纸不到 100 个 draw call,任何设备都跑得动。真要优化,把贴纸合并成InstancedMesh或者用合批材质,但那是几百个魔方同屏才需要考虑的事。

5. 排查记录:五个让魔方转歪、闪面、卡帧的坑

这一章是实打实踩出来的。每条按"现象 → 原因 → 解决"写,遇到相似症状可以直接对号入座。

坑一:拧十几步之后块之间出现细缝,面看起来在抖

现象是画面出现一条极细的黑线,快速转动时特别明显,像贴纸没对齐。

原因是动画结束后的量化不彻底。c.position.round()只处理了位置,四元数没对齐,累积几十步之后块的旋转角变成89.9997°,贴纸和块本体的相对位置就偏了,z-fighting 随之出现。

解决办法是把四元数也 snap 到最近的 90° 倍数(就是 3.2 里那个snapQuaternion)。注意Math.round作用在矩阵元素上是安全的,因为纯 90° 旋转的矩阵元素只有-1/0/1三种值,浮点误差不会越过 0.5 的边界。

坑二:快速连点按钮,某一层转到一半停住不动

现象是动画卡在中间,块悬在 45° 位置,再点也没反应。

原因是两个rotateLayer并发执行,各自建了一个 pivot 组,同一个块被attach到两个父节点下——Three.js 的attach是从旧父节点移除再挂到新父节点,两个动画互相抢块,最后就卡住了。

解决办法是加animating标志位和队列(3.2 里的写法)。队列长度建议设个上限,比如 8,防止用户长按连续触发把内存堆起来。

坑三:拖拽方向时对时错,转视角之后就全乱了

现象是默认视角下拖拽正常,把相机转到背面再拖,转动的层和方向完全不符合预期。

原因是用了屏幕坐标硬编码判断方向,比如"dx > 0 就绕 Y 轴转",这在正面视角下碰巧成立,换个角度就失效。

解决办法是把候选轴投影到屏幕空间再做点积,也就是 3.3 里的projectToScreen。这套算法对任意视角都成立,代价是每次抬手指要算两次project,可以忽略不计。

坑四:打乱之后判定不出还原,明明看着已经拼好了

现象是肉眼看上去六面同色,但isSolved()一直返回 false。

原因是分组 key 用了toFixed(2)之类的字符串。四元数转出来的法线可能是-0.0000001,toFixed得到"-0.00",和"0.00"是两个不同的 key,贴纸被分到了错误的组里。

解决办法是Math.round之后转整数再拼 key,负零会被 round 成0,问题消失。这类"看着一样但不相等"的坑,在浮点参与的判定里几乎必然出现,凡是拿浮点当 Map 的 key,都要先量化。

坑五:手机上拖拽会连带触发页面滚动,转层失灵

现象是桌面浏览器一切正常,手机上按住魔方一拖,整个页面跟着上下滑,魔方纹丝不动。

原因是pointerdown之后浏览器把这次触摸判定成了滚动手势,后续的pointermove被浏览器接管。

解决办法是给 canvas 容器加touch-action: none,同时在pointerdown里调e.preventDefault()。前者告诉浏览器"这块区域的手势我自己处理",后者是兜底。两件事都做,iOS 和 Android 上才都稳。

6. 二次开发:加公式回放、接通求解器与手感调校

想把这份魔方代码用起来,最划算的三个改造方向是公式回放、求解器接入和手感调校,投入产出比依次递减。

公式回放最容易做,也是最有用的调试工具。加一层记号解析,把"R U R' U'"这样的标准记法翻译成[axis, layer, turns]数组,再喂给队列就行:

const NOTATION = { R: ['x', 1, 1], L: ['x', -1, -1], U: ['y', 1, 1], D: ['y', -1, -1], F: ['z', 1, 1], B: ['z', -1, -1], M: ['x', 0, -1], E: ['y', 0, -1], S: ['z', 0, 1], // 中层,方向依 R/U/F 定义 }; export function playFormula(str, gap = 120) { const tokens = str.trim().split(/\s+/); tokens.forEach((tk, i) => { const face = tk[0]; const prime = tk.includes("'"); const base = NOTATION[face]; if (!base) return; const [axis, layer, turns] = base; // 加引号表示逆时针,层号不变、方向取反 setTimeout(() => rotateLayer(axis, layer, prime ? -turns : turns), i * (200 + gap)); }); }

NOTATION里中层的方向定义是最容易错的地方:M跟随L、E跟随D、S跟随F,跟错了整条公式的结果会完全反掉。验证办法是跑一条已知的恒等式,比如R U R' U'连做六次应该回到原状,能回去就说明记号和方向都对了。

求解器不要自己写。Kociemba 那套两阶段算法涉及坐标压缩和剪枝表,实现成本远超这个项目本身。社区有现成的 JS 移植版,输入 54 个面的颜色串,输出一串解法记号,正好能被上面的playFormula直接消费。接的时候注意颜色顺序约定:多数库用U R F D L B的排列,跟这份代码的FACE_DEF顺序不一致,中间要加一层映射表,别直接把贴纸数组传进去。

手感调校是被最多人忽略的一块。默认 200ms 线性转完,连拧时会有明显的停顿感,我一般会加两条:一是把队列里相邻的转动时长压缩到 140ms,二是让相机支持惯性旋转(记录pointermove的速度,抬手后按衰减系数继续转几百毫秒)。惯性这块要小心,别跟层旋转的 pointerup 抢事件,加个"如果按在块上就不触发视角惯性"的判断。

改造项工作量需要的额外依赖风险点
公式回放半天无中层方向定义
求解器接入1~2 天一个 Kociemba 实现颜色顺序映射
手感调校1 天无与拖拽事件冲突
状态持久化半天无四元数序列化精度

状态持久化顺带提一句:把snapshot()的结果JSON.stringify存进 localStorage,刷新页面后按位置和朝向重建块,就能做"关闭页面保留进度"。重建时别存四元数的原始 4 个浮点数,存量化后的整数矩阵更稳,反序列化时用setFromRotationMatrix还原。

从那以后,我每次动这个项目的动画逻辑,都会先把一帧的dt和量化后的位置打出来跑一遍,再去看画面——凭手感改参数翻车过太多次,日志比眼睛可靠。希望帮到你。

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