一个可以拖拽转面、随机打乱、并且用层先法(入门法)自动还原并分步讲解的 3D 魔方。项目完全跑在浏览器里:React 19 + react-three-fiber 负责渲染与交互,纯 TypeScript 逻辑层负责状态与求解,zustand 串联状态与动画队列。本文记录状态模型、交互几何、求解器设计与测试闭环四个部分。
项目概览
| 功能 | 说明 |
|---|---|
| 拖拽转面 | 按住某个面的贴纸沿切线方向拖拽,超过阈值触发该层 ±90° 转动 |
| 视角控制 | 在空白背景按住拖拽旋转视角(OrbitControls,仅旋转、无缩放平移) |
| 按钮转面 | U D L R F B 及反向 U′ D′ L′ R′ F′ B′(Singmaster 记法) |
| 随机打乱 / 重置 | 一键生成 20 步合法打乱序列,随时回到初始状态 |
| 自动还原 | 层先法 7 阶段求解器,输出分步中文讲解并逐条动画演示,支持暂停 / 继续 / 取消 |
技术栈:Vite 6 + React 19 + TypeScript,react-three-fiber + drei 渲染,zustand 状态管理,vitest 单元测试。纯前端、无后端,核心求解逻辑与渲染完全解耦(src/cube/ 可以在 Node 环境直接测试)。
在线演示
下面嵌入的是构建后的真实产物,可以直接玩:先点「打乱」再点「自动还原」,讲解面板会逐条高亮当前步骤;也可以自己拖拽某一层转面,或者按住空白背景旋转视角。
TIP演示是 iframe 嵌入的独立应用,也支持在新窗口打开。整个还原过程由求解器实时计算,不是预录动画——每次打乱得到的还原步骤都可能不同。
一、状态模型:27 个 cubie 与一次 applyMove
魔方状态最朴素也最可靠的表示,就是逐个记录 27 个小方块(含不可见的内部块):每个 cubie 有整数坐标 position 和 6 个贴纸槽位 colors。
export function makeCubie(p: Vec3): Cubie { const [x, y, z] = p; const colors: (Color | null)[] = [null, null, null, null, null, null]; if (x === 1) colors[0] = 'R'; // +X → 红 if (x === -1) colors[1] = 'O'; // -X → 橙 if (y === 1) colors[2] = 'W'; // +Y → 白 if (y === -1) colors[3] = 'Y'; // -Y → 黄 if (z === 1) colors[4] = 'G'; // +Z → 绿 if (z === -1) colors[5] = 'B'; // -Z → 蓝 return { position: [...p], colors };}标准配色:白上、黄下、红右、橙左、绿前、蓝后。colors 的下标固定对应世界坐标轴方向,null 表示该 cubie 在这个方向没有贴纸(角块 3 张贴纸、棱块 2 张、中心块 1 张、内部块 0 张)。
一次转动做了什么
每个面在 FACE_INFO 里登记了三个事实:转动轴、所在层(轴坐标 ±1)、顺时针对应的转角方向:
export const FACE_INFO: Record<Face, { axis: Axis; layer: 1 | -1; theta: 1 | -1 }> = { R: { axis: 'x', layer: 1, theta: -1 }, L: { axis: 'x', layer: -1, theta: 1 }, U: { axis: 'y', layer: 1, theta: -1 }, D: { axis: 'y', layer: -1, theta: 1 }, F: { axis: 'z', layer: 1, theta: -1 }, B: { axis: 'z', layer: -1, theta: 1 },};applyMove 只筛选出位于该层的 cubie,对每个做「位置旋转 + 贴纸置换」两步:
applyMove(m: Move): void { // ...整体旋转(x/y/z)分支省略 const { axis, layer, theta } = FACE_INFO[m.face]; const t = (theta * m.dir) as 1 | -1; const ai = axis === 'x' ? 0 : axis === 'y' ? 1 : 2; for (const cb of this.cubies) { if (cb.position[ai] !== layer) continue; cb.position = rotatePos(cb.position, axis, t); cb.colors = rotateColors(cb.colors, axis, t); }}rotatePos 是整数坐标旋转((x,y,z) 绕 x 轴转 90° 变成 (x,-z,y)),全程没有浮点误差——状态模型天然精确,这也是求解器可以放心在它之上做 BFS 的原因。rotateColors 则是把 6 个贴纸槽位按法向量旋转做置换,保证「贴纸始终与世界坐标轴对齐」这个不变量。
IMPORTANT状态(cubie 的位置 + 贴纸)是唯一事实来源。渲染层只是把这个状态「投影」到屏幕上,动画期间状态不变、动画结束才提交——逻辑与表现彻底分离,这是后面测试能跑得那么快的前提。
判定已解:一张展开图
isSolved() 不比较 cubie 数组,而是用 FACE_GRID 把每个面的 3×3 网格映射回世界坐标,逐格检查贴纸颜色是否符合预期。展开图(U 在上、F 居中、D 在 F 下、B 在 D 下、L/R 分居两侧)保证 12 条共享边的世界坐标两两一致、无镜像——这个不变量有专门的单元测试守护。
二、渲染与交互:把「转层」变成「拖拽」
场景搭建
27 个 RoundedBox(共享同一份几何体),间距 1.08、边长 0.94,留出约 0.14 的缝隙露出深色内部;每个 cubie 按 colors 组合贴 6 面独立材质,材质按颜色组合缓存复用。视角用 OrbitControls,只允许旋转、禁用缩放和平移。
拖拽转面的几何
转面的核心问题是:如何把一个 2D 指针拖拽翻译成「哪一层、朝哪个方向转一次」。实现分三步:
- 命中定面:
pointerdown命中某个 mesh 时,从e.face.normal(对象空间法线)直接映射到面。空闲时 mesh 恒为零旋转,所以对象空间法线就是世界法线,可以放心映射。 - 平面投影:以命中点和该法线构造一个无限平面,把指针位置沿视线投射到平面上,得到
hit点。 - 切线累积:转动方向就是角速度方向
cross(轴, 点)(ω×r),把指针位移在切线方向上投影并累加,超过阈值(0.2)触发一次转动:
// 切线 = 正转方向(ω×r);归一化后累加,消除面中心处幅值为 0 的死区const tangent = new THREE.Vector3().crossVectors(AXIS_VEC[FACE_INFO[drag.face].axis], drag.point);if (tangent.lengthSq() > 1e-8) tangent.normalize();drag.accum += hit.clone().sub(drag.prev).dot(tangent);drag.prev.copy(hit);if (Math.abs(drag.accum) > 0.2) { const dir = ((drag.accum > 0 ? 1 : -1) * FACE_INFO[drag.face].theta) as 1 | -1; useCubeStore.getState().enqueueMove({ kind: 'face', face: drag.face, dir }); dragRef.current = null; // 一次拖拽只触发一次}几个细节值得一提:
move/up用window原生监听,指针拖出 mesh、甚至拖出画布后拖拽仍然持续;配合setPointerCapture保证指针移出浏览器窗口也能收到pointerup。- 归一化切线是为了消除面中心处的死区:面中心到轴的距离为 0,
ω×r幅值为 0,直接用它当方向会在中心附近出现「拖不动」的体验。 - 拖拽期间禁用 OrbitControls,避免转层时视角被误旋转;多指按下时忽略新指针,防止两个手势打架。
- 一次拖拽只触发一次转动(触发后清空
dragRef),方向由累积位移的符号决定。
动画:pivot 分组旋转
每次 90° 转动是 250ms 的 ease-in-out。实现上把该层的 mesh 临时挂到一个 THREE.Group(pivot)下,只旋转 pivot;动画结束时把 mesh 挂回根节点、并把动画后的状态同步给渲染(syncMeshes),与 store 提交的状态保持一致。动画由 useFrame 逐帧驱动,不经过 React 渲染循环——动画期间没有 React 重渲染,性能开销极小。
三、层先法求解器:7 阶段、公式与 BFS
求解器 src/cube/solver.ts 实现标准入门法(层先法)的 7 个阶段,每一步输出 { move, stage, stageTitle, description },description 就是讲解面板里的中文说明:
| 阶段 | 目标 |
|---|---|
| 1 底层十字 | 白十字(白棱就位且侧面颜色对齐中心) |
| 2 底层角块 | 四个白角归位 |
| 3 第二层棱块 | 四个中层棱归位(至此下两层完成) |
| 4 顶层十字 | 标准公式 F R U R' U' F',dot→L→line→cross |
| 5 顶层角块定位 | 角块位置归位(朝向暂不管) |
| 6 顶层角块朝向 | 角块黄贴纸全部朝上 |
| 7 顶层棱块归位 | 棱块全部就位 |
关键技巧一:观测帧 vs 物理状态
经典入门法默认「白面在底」做 F2L。实现的做法是:白十字完成后整体 x2 翻转(白面转下),用经典公式做底层角块和第二层;顶层阶段结束后再 x2 翻回、补一个 y 旋转做帧规范化。x2/y 只改变观测帧,不扰动任何块——物理上魔方已经还原,只是我们看它的角度变了。
翻转带来的坑:x2 之后侧面中心的顺序是镜像序(红在右、蓝在前、绿在后、橙在左),所以顶层阶段的谓词全部「帧相关」——按中心色现算 home 位置,而不是查固定坐标表:
function yellowCornerHome(cube: Cube, colors: Color[]): Vec3 { for (const h of TOP_CORNER_POSITIONS) { const [x, , z] = h; const xc = centerColorAt(cube, [x, 0, 0]); const zc = centerColorAt(cube, [0, 0, z]); if ((colors[1] === xc && colors[2] === zc) || (colors[1] === zc && colors[2] === xc)) return h; } ...}关键技巧二:阶段 4-6 用「保层」公式 + BFS
阶段 4 是确定性公式:F R U R' U' F',配合 U-setup 把当前形态转到规范形(dot / L / line),循环直到黄十字完成。
阶段 5/6 是本项目最有意思的部分。它们的移动集是 13 个经实测验证「对 F2L 与黄十字零影响」的经典公式(含 Sune 系、角三循环等,不含 R'D'RD——实测会破坏 F2L)。在这个受限移动集上做 BFS,搜索把顶层角块带到目标态的最短公式组合:
- 状态编码:每个角块 = 位置(0..7)× 贴纸排列(0..5),共 48 态;4 个角块打包成一个
48⁴的数值 key; - 每个公式的作用被预计算成 48 态置换表(
CORNER_MOVE_TABLES),模块级惰性缓存——BFS 里查表代替逐 move 模拟; - 深度上限 16、节点数上限 30 万,保证最坏情况可终止。
用 BFS 而不是背公式的好处:目标态可以精确表达。阶段 5 的目标是「位置归位、朝向任意」,阶段 6 是「完全正确」,两者在同一个搜索框架下只是目标态集合不同。
NOTE设计文档原稿的阶段 6 用的是
R'D'RD反复转角块的经典教法,实测发现它会把角块停在「黄朝上但侧面贴纸对调」的扭转类,最终魔方无法还原;改用 BFS 直接搜索到完全正确终态后,100 个随机打乱全部通过。这是「教学直觉」被「可验证正确性」纠正的一个例子。
关键技巧三:阶段 7 用整体旋转代替 U-setup
阶段 7 用经典三循环公式 F2 U L R' F2 L' R U F2(作用在 (UL,UR,UF) 上)。标准教法是先 U-setup 把正确棱转到合适位置——但实测 U 会把已归位的角块整体挪走,导致最后魔方不还原。实现改为整体 y 旋转把正确棱转到后面(y 旋转不扰动任何块),循环结束后再转回来,最后统一做 x2 翻回 + y 帧规范化收尾。
求解入口
export function solveCube(start: Cube): SolveStep[] { if (start.isSolved()) return []; const cube = start.clone(); const steps: SolveStep[] = []; const record = (alg: Move[] | string, stage: number, description: string) => { const moves = typeof alg === 'string' ? parseAlg(alg) : alg; for (const m of moves) { steps.push({ move: m, stage, stageTitle: STAGES[stage], description }); } }; solveFirstTwoLayers(cube, record); solveYellowCross(cube, record); solveYellowCornerPosition(cube, record); solveYellowCornerOrientation(cube, record); solveYellowEdgePosition(cube, record); return steps;}已解魔方直接返回空数组(避免白十字阶段记录的 x2 翻面噪音);record 是纯记录回调,各阶段函数在传入的克隆上自行 applyMove,收集和改动互不干扰。
四、状态管理与动画队列
zustand store 维护了一份「已提交」的魔方状态和一条动画请求队列:
animQueue是 FIFO;finishMove在每次动画完成时恰好调用一次,消费队头、把它应用到模型状态、再决定是否启动下一步。所以求解演示就是「把steps的 move 逐个塞进队列」——队列天然驱动了动画的推进。- 动画期间 store 不变:渲染层拿到的
cube快照不随动画更新,避免 React 每帧重渲染。
两个工程细节值得记录:
代际(epoch)防竞态。求解计算是异步的(setTimeout 30ms 后执行),而这段时间用户可能点了「打乱 / 重置 / 取消演示」——scramble/reset/cancelSolve/startSolve 都会把 animEpoch +1,异步回调开始前校验 epoch,过期结果直接丢弃:
const epoch = s.animEpoch;const run = () => { const cur = useCubeStore.getState(); if (cur.animEpoch !== epoch) { set({ computing: false }); return; } ...};if (immediate) { run(); return; }set({ computing: true });setTimeout(run, 30);冷缓存延迟计算。solveCube 首次调用要构建 BFS 移动表(1-7s),如果同步算会把首帧卡死。实现先置 computing=true 渲染出「求解中…」按钮态,30ms 后再真正计算,让 React 先提交一帧反馈;任何异常都会清掉 computing,避免 UI 永久锁死在「求解中…」。
五、测试与正确性
项目有 92 个测试用例(36 个测试文件),全部跑在纯逻辑层,不依赖浏览器:
- 状态还原性:对随机序列,按逆序、逆方向执行一遍后状态不变——这是
applyMove正确性的黄金测试; - 求解收敛:
solveCube对 ≥100 个固定种子(LCG 伪随机,保证确定性可复现)的 25 步打乱全部还原;已解输入返回空; - 阶段分片测试:白十字每步只放置一个棱块(步数 ≤ 4)、F2L 逐阶段收敛、顶层 4-7 阶段端到端验证;
- 共享边一致性:展开图 12 条共享边无镜像。
工程上有个反直觉的坑:F2L 长测试(BFS 冷缓存未命中时较重)并行执行会把单文件时长推到 vitest worker 的 RPC 超时(60s)之上,报出 [vitest-worker] Timeout calling onTaskUpdate。解决方式是按小任务量拆分测试文件 + 限制 maxWorkers: 2,e2e 也从 4 个文件 × 25 种子拆成 8 个文件 × 12-13 种子。
describe('solveCube e2e (seeds 2000-2012)', () => { it('solves 13 seeded random scrambles end-to-end', () => { for (let seed = 2000; seed <= 2012; seed++) { const start = new Cube(); start.applyMoves(scrambleMovesSeeded(25, seed)); const steps = solveCube(start); const check = start.clone(); for (const s of steps) check.applyMove(s.move); expect(check.isSolved()).toBe(true); } }, 120000);});六、总结与思考
这个项目最有价值的地方在于分层带来的可测试性:
- 状态即事实:27 个 cubie 的整数坐标 + 贴纸槽位是唯一事实来源,渲染只是它的投影。没有浮点误差、没有同步问题,求解器可以放心地在纯逻辑层做 BFS。
- 表现与逻辑解耦:动画队列、epoch 代际、异步计算——这些工程机制全部服务于「状态在动画完成后才提交」这一条纪律,换来的是 React 零额外重渲染。
- 公式与搜索的结合:前四层用确定性公式,顶层用「保层移动集 + BFS」——既保证了讲解步骤的经典性,又用搜索兜住了公式表的死角。
在浏览器里拖一拖、打乱、再看着它一步步讲着中文还原,是这套设计最好的验收方式。动手玩过之后,再回头看状态模型和 BFS 编码,会更有感觉。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时





