☰
纯JS手写三维魔方:Canvas 2D渲染、透视投影与转动状态机
2026/10/31 1:11:06 网站建设 项目流程

简介:纯JavaScript实现的魔方模拟程序,面向希望借助可运行项目理解魔方旋转逻辑、交互式3D渲染与前端状态管理的学习者,也可作为网页小游戏和算法演示的练手素材。压缩包共21个文件,以15个JavaScript脚本和4个CSS样式文件为主,另有1个HTML入口页与1张图片资源,整体约255KB,体积轻量,便于本地打开阅读和二次改造。已有783人学习下载,具备一定参考热度。代码通过魔方状态建模、面转动函数、按钮与拖拽事件监听、动画过渡和移动端感应交互实现操作,并借助Three.js渲染3D场景;样式层负责立方体布局、响应式适配与界面控件呈现。读者可从中梳理DOM操作、对象与数组组织、requestAnimationFrame动画调度、递归求解思路和调试方法,也能观察多脚本协作与压缩版脚本并存的工程组织方式,适合作为前端交互练习与魔方算法入门的对照案例。

1. 想练三维图形,先别急着上引擎

想练三维图形又不想一上来就背引擎 API,我一般建议先拿一份纯 JS 魔方代码折腾两周。这份魔方代码不依赖任何三方库,用原生 Canvas 2D 自己算矩阵、做透视投影、按深度排序画四边形,把三阶魔方的转动、打乱、还原公式全部跑在一个 HTML 页面里。它适合两类人:一类是想从零摸清 3D 渲染管线的前端,另一类是要往教学演示、答题小游戏里塞一个可交互魔方模块的开发者。代码不厚,但每一层都能拆开改,改坏了也看得见后果,比啃三天理论管用。

2. 目录结构与运行:没有构建工具的纯 JS 版怎么落地

拿到一个没有 package.json、没有 webpack、连 npm install 都不用跑的纯 JS 版项目,第一反应往往是“这玩意儿怎么启动”。这类项目的目录切分方式直接决定了后续改代码时的痛苦程度:全部塞一个 app.js 当然能跑,但改转动逻辑的时候会被渲染代码淹掉。我一般按“数学层、状态层、渲染层、输入层”四块切,每块单独一个目录,文件之间靠加载顺序和全局命名空间通信。

2.1 文件构成与职责边界

下面这套结构是按三阶魔方的实际拆解需求整理的,纯 JS 版通常落在这样的骨架里:

路径职责依赖
index.html页面骨架、canvas 容器、脚本加载顺序无
css/style.css画布尺寸、居中、触摸手势禁用无
js/math/mat4.js4x4 矩阵:单位阵、乘法、旋转、平移、点变换无
js/math/vec3.js向量点乘、叉乘、归一化无
js/core/cubie.js单个小方块的数据结构mat4
js/core/moves.js转动定义、公式解析、逆操作cubie
js/core/cube.js27 个小方块的集合、层选取、状态提交cubie、moves
js/render/camera.js相机绕轴旋转、距离、视场角mat4
js/render/renderer.js面收集、背面剔除、深度排序、绘制mat4、camera
js/input/drag.js鼠标/触摸拖拽、拾取、方向判定cube、renderer
js/main.js引导、请求动画帧主循环全部

这种切法的好处是渲染层只读 cube 的状态,不写;输入层只发指令,不碰矩阵。改配色只动 renderer,改判定只动 drag,互不牵连。血泪经验是别让 renderer 直接去改 cubie.pos,一旦渲染路径带上副作用,后面调转动的 bug 会变成玄学。

2.2 脚本加载顺序:为什么不用 ES module

很多纯 JS 版会写成<script type="module">,本地双击 index.html 直接报 CORS 错误,白屏。丢掉构建工具之后,模块化的收益本来就不大,用普通 script 按依赖顺序加载更省事,每个文件在顶层往一个全局命名空间上挂东西:

<!-- index.html:顺序就是依赖顺序,不能乱 --> <canvas id="cube-canvas" width="640" height="640"></canvas> <script src="js/math/mat4.js"></script> <script src="js/math/vec3.js"></script> <script src="js/core/cubie.js"></script> <script src="js/core/moves.js"></script> <script src="js/core/cube.js"></script> <script src="js/render/camera.js"></script> <script src="js/render/renderer.js"></script> <script src="js/input/drag.js"></script> <script src="js/main.js"></script>

每个文件的写法统一成 IIFE 挂命名空间,避免变量污染:

// js/math/mat4.js window.RK = window.RK || {}; // 全局命名空间,只有这一个 (function (ns) { ns.Mat4 = { identity() { /* 返回单位阵 Float32Array(16) */ }, multiply(a, b) { /* 列主序矩阵相乘 */ }, rotationY(rad) { /* 绕 Y 轴旋转 */ } }; })(window.RK);

window.RK是唯一的全局出口,其余全部收在闭包里。参数说明:identity()返回长度 16 的 Float32Array,列主序;multiply(a, b)返回 a×b,顺序反了整台魔方会翻转;rotationY(rad)的rad是弧度不是角度,从交互层传进来之前务必换算。

2.3 启动流程与主循环

引导代码很薄,就三件事:拿 canvas、造对象、起循环。

// js/main.js (function (ns) { const canvas = document.getElementById('cube-canvas'); const ctx = canvas.getContext('2d'); const cube = new ns.Cube(3); // 3 阶,边长参数 const camera = new ns.Camera({ distance: 8, fov: 520 }); const renderer = new ns.Renderer(ctx, canvas.width, canvas.height); const drag = new ns.DragController(canvas, cube, camera, renderer); let last = 0; function loop(ts) { const dt = last ? ts - last : 16; // 毫秒,首帧兜底 16 last = ts; cube.update(ts); // 推进转动动画、出队下一步 renderer.draw(cube, camera); // 全量重绘,不做脏矩形 requestAnimationFrame(loop); } requestAnimationFrame(loop); })(window.RK);

cube.update(ts)吃的是时间戳而不是 delta,这样动画进度可以按绝对时间算,掉帧也不会让转动变慢,这是我在别的项目里翻过车之后改的习惯。camera的distance是相机到原点距离,fov在这里不是角度而是像素级缩放系数,值越大魔方越大。renderer.draw每帧全量重绘,27 个小方块乘 6 个面也就 162 个多边形,Canvas 2D 完全扛得住,别提前优化。

2.4 本地怎么跑起来

纯 JS 版最大的好处是没有构建。双击 index.html 就能开,前提是脚本全是普通 script、没引外部 JSON。等你要加公式库、贴图或者把打乱序列存成文件,就得起个静态服务:

# 在项目根目录执行任意一种 python3 -m http.server 8000 # 或者 npx serve .

起完访问http://localhost:8000。不带服务直接开 file:// 的话,canvas 能画,但只要一 fetch 就跨域失败,报的还是 “blocked by CORS policy”,新手很容易以为是代码写错了。

3. 渲染核心:27 个方块怎么在 Canvas 2D 上立起来

三维渲染在纯 JS 里最容易写崩的部分,是把“逻辑状态”和“屏幕像素”这两件事混在一起。判断标准很简单:如果 cubie 对象里存了屏幕坐标,这套代码基本改不动了。正确姿势是逻辑层只存位置和朝向,渲染层每次从零推导出屏幕多边形,一帧一换,谁也别记谁。

3.1 小方块的数据结构:位置加朝向矩阵

三阶魔方一共 26 个可见小方块(中心那个被完全包住,画不画都行)。每个方块要记两样东西:它在哪、它朝哪。

// js/core/cubie.js (function (ns) { class Cubie { constructor(x, y, z) { // 逻辑坐标,取值只能是 -1 / 0 / 1,不用浮点 this.pos = [x, y, z]; // 累积朝向矩阵,初始为单位阵 this.rot = ns.Mat4.identity(); // 六个面的颜色,绑定在“方块自身坐标系”上 this.faces = ['U', 'D', 'L', 'R', 'F', 'B']; } // 逻辑坐标绕轴转 90 度,dir 取 1 或 -1 rotatePos(axis, dir) { const [x, y, z] = this.pos; if (axis === 'x') this.pos = dir > 0 ? [x, -z, y] : [x, z, -y]; if (axis === 'y') this.pos = dir > 0 ? [z, y, -x] : [-z, y, x]; if (axis === 'z') this.pos = dir > 0 ? [-y, x, z] : [y, -x, z]; } } ns.Cubie = Cubie; })(window.RK);

pos只用整数,是因为后面要按pos[idx] === layer精确筛选层,浮点一进来就是玄学 bug 的源头。rot是累积朝向,动画过程中不动它,等一次 90 度转完再乘上去。faces存的是贴面标识,配合颜色表在使用时查颜色,别把颜色值直接写在方块上——换配色的时候就知道疼了。

3.2 透视投影:把三维坐标压到画布上

渲染前先算相机矩阵。相机绕原点转,等价于整个世界反向转,所以相机矩阵就是相机欧拉角的逆。

// js/render/camera.js class Camera { constructor(opt) { this.distance = opt.distance; // 相机到原点距离,8 左右比较舒服 this.fov = opt.fov; // 像素级缩放,500~600 之间 this.yaw = -0.6; // 水平角,弧度 this.pitch = -0.5; // 俯仰角,弧度 this.matrix = RK.Mat4.identity(); } update() { // 先俯仰再偏航,顺序反了魔方会歪着转 this.matrix = RK.Mat4.multiply( RK.Mat4.rotationX(-this.pitch), RK.Mat4.rotationY(-this.yaw) ); } }

投影函数是整条管线的咽喉,写错一位,魔方就会在某个角度突然撕裂:

// 把世界坐标点压成屏幕坐标 function project(p, camMatrix, camera, w, h) { const m = camMatrix; // 列主序变换:v' = M * v const x = m[0] * p[0] + m[4] * p[1] + m[8] * p[2]; const y = m[1] * p[0] + m[5] * p[1] + m[9] * p[2]; const z = m[2] * p[0] + m[6] * p[1] + m[10] * p[2]; const depth = z + camera.distance; // 推到相机前方,必须 > 0 const k = camera.fov / depth; // 透视缩放因子 return { x: w / 2 + x * k, y: h / 2 - y * k, // 屏幕 y 向下,这里取反 z: depth // 保留深度给后面排序 }; }

参数逐个说:depth是相机空间下的 z,加了distance之后保证为正,否则透视除法会翻转;k是缩放因子,跟深度成反比,这是“近大远小”的全部来源;y取反是因为 canvas 坐标系 y 轴朝下,而数学习惯 y 轴朝上,这一处漏了魔方会上下颠倒。z返回的是深度而不是原始 z,排序时直接比大小即可。

3.3 面收集、背面剔除与画家算法

每个小方块有 6 个面,但同一时刻最多只能看到 3 个方向。先按法线和视线夹角剔除背面,剩下的按深度从远到近画,这就是最朴素的画家算法。

// js/render/renderer.js const FACES = [ { n: [0, 1, 0], c: [[-1,1,-1],[1,1,-1],[1,1,1],[-1,1,1]] }, // U { n: [0,-1, 0], c: [[-1,-1,1],[1,-1,1],[1,-1,-1],[-1,-1,-1]] },// D // ... 其余四面同理 ]; draw(cube, camera) { const faces = []; for (const cubie of cube.cubies) { // 方块中心的世界坐标:逻辑坐标 × 间距 const center = cubie.pos.map(v => v * cube.spacing); for (const f of FACES) { // 法线跟着方块自身朝向转 const n = RK.Mat4.transformDir(cubie.rot, f.n); // 背面剔除:法线朝向屏幕外侧就跳过 if (RK.Vec3.dot(n, camera.viewDir) > 0) continue; let sum = 0; const pts = f.c.map(corner => { // 角点先按方块自身转,再平移到世界位置 const local = RK.Mat4.transformDir(cubie.rot, corner.map(v => v * cube.half)); const world = [local[0] + center[0], local[1] + center[1], local[2] + center[2]]; const s = project(world, camera.matrix, camera, this.w, this.h); sum += s.z; return s; }); faces.push({ pts, z: sum / pts.length, color: cube.colorOf(cubie, f.n) }); } } faces.sort((a, b) => b.z - a.z); // 远的先画 for (const f of faces) this.fillPolygon(f); }

cube.spacing是小方块中心之间的距离,取 1.0 时方块会贴得死紧,取 1.06 左右留出缝隙,视觉上更像实体魔方。cube.half是单个小方块的半边长,一般取 0.42 到 0.46,跟 spacing 配合决定缝隙宽度。z用四个角点深度的平均值,比取中心点更稳一点。camera.viewDir是相机空间下 z 轴正方向,法线点乘它大于 0 说明背对屏幕。

排序这里有个细节:按面排序时,同一个方块的两个面 z 值可能几乎相等,浏览器偶尔会把它们画反,表现为方块边缘出现一条闪烁的细缝。常见做法是把每个面沿自身法线向内缩一点点,或者在比较函数里加一个极小的偏置,我在第 5 章会展开讲。

4. 转动引擎与交互状态机:一次拖拽到一次 90 度转动的全链路

渲染是“看起来像魔方”,转动才是“真的是魔方”。这一步写不好,会出现转完一圈颜色错位、连转两次回到原地颜色却变了、拖拽方向和实际转动方向镜像对称等等问题。核心原则只有一条:动画期间只改“临时角度”,动画结束的那一刻才把结果提交进逻辑数据。

4.1 先给轴和层号,挑出该转的 9 个小方块

三阶魔方一共 6 个面、9 种转动(不含中层),对应 3 根轴 × 3 个层号 × 2 个方向。选层逻辑不用单独存表,直接从位置筛。

// js/core/cube.js selectLayer(axis, layer) { const idx = { x: 0, y: 1, z: 2 }[axis]; // layer 取 -1 / 0 / 1,与 cubie.pos 的量纲一致 return this.cubies.filter(c => Math.abs(c.pos[idx] - layer) < 1e-6); }

axis是字符串 'x' / 'y' / 'z',layer是整数层号。Math.abs(...) < 1e-6而不是===,是给那些被浮点污染过的实现兜底;如果你从第 3 章的数据结构一路用整数,这里直接===就行,更快也不会误判。返回的数组长度必然是 9(含中心方块时是 9,不含是 8,渲染和转动都按 9 处理更省心)。

4.2 动画与提交:临时角度和逻辑状态分家

一次转动对象要活过整个动画周期,所以把它做成一个小状态机。

// js/core/moves.js class Move { constructor(axis, layer, dir, duration) { this.axis = axis; // 'x' | 'y' | 'z' this.layer = layer; // -1 | 0 | 1 this.dir = dir; // 1 = 顺时针(沿轴正向看), -1 = 逆时针 this.duration = duration; // 毫秒,150 左右手感较好 this.startTime = null; this.angle = 0; // 当前动画角度,渲染时叠加 this.done = false; } update(now, cube) { if (this.startTime === null) this.startTime = now; const t = Math.min(1, (now - this.startTime) / this.duration); const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; this.angle = (Math.PI / 2) * this.dir * eased; // 缓入缓出 if (t >= 1) { this.commit(cube); // 提交:改逻辑数据 this.angle = 0; // 动画角度归零 this.done = true; } } commit(cube) { const list = cube.selectLayer(this.axis, this.layer); const R = RK.Mat4[`rotation${this.axis.toUpperCase()}`](Math.PI / 2 * this.dir); for (const c of list) { c.rotatePos(this.axis, this.dir); // 位置用整数规则转 c.rot = RK.Mat4.multiply(R, c.rot); // 朝向用矩阵乘 } } }

duration决定手感,太短看不清转动方向,太长拖沓,150 到 220 毫秒是多数人觉得顺手的区间。eased是缓入缓出曲线,线性运动看着像机器。关键是最后那个commit:位置用rotatePos的整数规则算,朝向用标准旋转矩阵乘,两条路径必须对同一个方向的旋转给出相同结果,否则动画结束的瞬间魔方会“跳”一下——这一跳就是血泪史的开端。

渲染时把这 9 个方块的变换额外叠加一个旋转:

// renderer 里取模型矩阵时 let model = RK.Mat4.multiply(RK.Mat4.translation(...center), cubie.rot); if (cube.activeMove && cube.activeMove.layer === layerOf(cubie, cube.activeMove.axis)) { const anim = RK.Mat4[`rotation${axis.toUpperCase()}`](cube.activeMove.angle); model = RK.Mat4.multiply(anim, model); // 动画旋转施在世界空间 }

注意anim要乘在左边,它作用在世界空间;乘在右边就变成绕方块自身坐标系转,看起来会整块乱飞。

4.3 拖拽判定:把屏幕位移翻译成轴和方向

比想象中难的是“用户往右拖,到底该转哪一层”。做法分两步:先在按下时拾取到具体哪个面,再根据拖动方向选轴。

// js/input/drag.js onPointerDown(e) { const p = this.toCanvasCoord(e); // 渲染器每帧保留的屏幕多边形,从近到远比,第一个命中的就是点击面 const hit = this.renderer.pickFace(p.x, p.y); if (!hit) return; this.start = { p, cubie: hit.cubie, normal: hit.normal }; } onPointerUp(e) { if (!this.start) return; const p = this.toCanvasCoord(e); const dx = p.x - this.start.p.x; const dy = p.y - this.start.p.y; if (Math.hypot(dx, dy) < 8) return; // 位移太小视为点击,不转 // 用命中面的两个屏幕切向量做投影,谁的投影大就用谁 const [tu, tv] = this.screenTangents(this.start.cubie, this.start.normal); const alongU = dx * tu.x + dy * tu.y; const alongV = dx * tv.x + dy * tv.y; const { axis, layer, dir } = this.resolve(alongU, alongV, this.start); this.cube.enqueue(new RK.Move(axis, layer, dir, 180)); }

pickFace依赖fillPolygon时把{pts, cubie, normal}一起存下来,否则拾取还得反算一遍,纯属自找麻烦。8像素是点击阈值,触屏上要放大到 12 左右。screenTangents返回命中面所在的两个切向在屏幕上的投影向量,resolve拿这两个投影值和面的法线,就能唯一确定“绕哪根轴、转哪一层、往哪个方向”。

4.4 公式队列:打乱、还原与防连点

用户手速一快,两次转动会同时进来。如果每来一次就直接改逻辑状态,动画还没放完,状态已经乱了。做法是串行队列,同一时刻只允许一个 Move 处于活跃状态。

// js/core/cube.js enqueue(move) { this.queue.push(move); // 不打断正在播的动画 } update(now) { if (!this.activeMove && this.queue.length) { this.activeMove = this.queue.shift(); } if (this.activeMove) { this.activeMove.update(now, this); if (this.activeMove.done) { this.activeMove = null; if (this.onMoveDone) this.onMoveDone(); // 提交后的回调,二次开发用得上 } } }

打乱就是往队列里推 20 到 25 步随机转动:

scramble(n = 22) { const axes = ['x', 'y', 'z']; 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 = [-1, 1][(Math.random() * 2) | 0]; // 只转外层 const dir = Math.random() < 0.5 ? 1 : -1; this.enqueue(new RK.Move(axis, layer, dir, 140)); } }

lastAxis去重是为了避免连续两步绕同一根轴转,那种序列看起来像没打乱,用户会以为按钮坏了。层号只取 -1 和 1,是只动外层的常见选择,要支持中层把layer扩到 -1/0/1 即可。

要注意这里说的“还原”是回放打乱序列的逆操作,不是求解器。记录打乱时同步存档,还原时倒序遍历、方向取反、逐条入队,一个真实的还原算法(比如分层法或查表法)是另一个量级的工程,别在演示项目里硬上。

5. 避坑与排查:五个把我反复按在地上摩擦的问题

纯 JS 手写三维,出错的表现往往不是报错,而是“看着有点怪”。下面五条都是现象能被肉眼看见、原因藏在几十行之外的类型,按现象→原因→解决的顺序记,遇到时按图索骥。

5.1 方块边缘闪烁细缝,转起来像在抖

现象:静止时看不出问题,一转动,相邻小方块交界处出现一闪一闪的细线,截图放大能看到颜色错位。

原因:画家算法按面片的平均深度排序,两个共面或近乎共面的面在深度值上几乎相等,sort的比较结果不稳定,这一帧 A 在后、下一帧 B 在后,就闪了。这不是精度不够,是排序算法本身对相等键没有定义顺序。

解决:两条路,一是把小方块的面沿法线向内缩一个极小量(0.005 到 0.01 个方块单位),让共面变不共面;二是排序时按方块中心深度而不是面平均深度排,同一方块的六个面永远贴着一起画。我一般两条一起上,先按方块中心纬度排,方块内部再按面法线朝向排。

5.2 连续转几十次之后,魔方整体歪掉

现象:刚开始方方正正,转了几十步之后魔方整体有轻微倾斜,角度越转越明显,几百步之后肉眼可见。

原因:朝向矩阵是浮点累乘的结果,每次乘一个 90 度的旋转矩阵,浮点误差会累积。矩阵会缓慢失去正交性,从“旋转矩阵”退化成“带缩放的近似矩阵”,所有面都不再垂直。

解决:commit里不要用R * oldRot一路乘下去,而是每次提交时对矩阵做一次正交化,或者更省事的办法:既然所有转动都是 90 度的整数倍,提交时把矩阵的 9 个元素四舍五入到 -1、0、1 三个值,浮点误差根本没有机会累积。这一招在纯 JS 项目里特别划算,因为状态空间本来就是离散的。

5.3 双击 index.html 打开,白屏且控制台报 CORS

现象:本地直接双击 HTML 文件,页面全白,控制台一行红字说跨域策略阻止了模块加载。

原因:写成了<script type="module">,浏览器的模块加载在 file:// 协议下受同源策略限制。这不是代码逻辑问题,是协议问题。

解决:要么退回普通 script 加命名空间(第 2 章的做法),要么老老实实起一个静态服务。别去改浏览器启动参数关掉安全策略,那只是把问题藏起来,换台机器照样炸。打包成单文件 HTML 也是一种解法,把脚本内联进<script>标签即可。

5.4 疯狂连点之后,颜色彻底对不上

现象:慢慢点一切正常,手速一快,转到一半魔方颜色开始乱,最后变成一个永远还原不了的状态。

原因:每次点击都直接触发了commit改逻辑状态,而动画还没播完,玩家看到的位置和逻辑记录的位置不一致;更糟的是两次 commit 作用在同一批方块上,位置规则转了一次、朝向矩阵转了两次,直接错位。

解决:入口处做串行化,所有转动走队列,只有activeMove为空时才出队。同时在拖拽入口加一个简单的锁:动画期间忽略新的拖拽起始事件,避免用户边转边拖产生方向判定错乱。这个锁的粒度是一步动画,不是整个队列,否则快速连点会有一长串延迟。

5.5 拖拽方向反了,往右拖却往左转

现象:正对着魔方的一侧还好,把视角转到某个角度之后,拖拽方向和实际转动方向呈镜像。

原因:判定方向时用了世界坐标轴的屏幕投影,但相机是绕原点转的,屏幕上的“右”和世界坐标的“+X”根本不对应。相机转到背面时,屏幕右方向对应的是世界 -X,方向自然反了。

解决:判定必须基于“当前命中面在屏幕上的两个切向量”,而不是固定的世界轴。切向量由面的两条边经过相机矩阵和投影后得到,天然跟着视角走。这也是在第 4 章把screenTangents单独抽出来的原因,它不是一个可有可无的工具函数,是方向判定的正确性基础。

6. 二次开发:改三处就能把它变成你自己的模块

拿这份代码做二次开发,多数需求集中在三个地方:外观、公式、对外接口。外观改renderer.js里的配色表和cube.spacing / cube.half两个参数,配色表建议收敛成一个COLORS对象,六个面各一个色值,改成深色主题或低饱和风格只需要动这一处;spacing从 1.06 调到 1.0 会变成无缝的实心观感,调到 1.12 就是拼图感。

公式部分在moves.js,标准记法R U R' U'用一个小解析器拆成字符即可:

// 把 "R U R' U'" 解析成转动序列,二次开发时直接复用 function parseFormula(str) { return str.trim().split(/\s+/).map(token => { const face = token[0]; // U D L R F B const isPrime = token.includes("'"); // 带撇表示逆时针 const isDouble = token.includes('2'); // 带 2 表示转 180 度 const map = { // 面 -> 轴与层 U: ['y', 1], D: ['y', -1], R: ['x', 1], L: ['x', -1], F: ['z', 1], B: ['z', -1] }; const [axis, layer] = map[face]; const dir = isPrime ? -1 : 1; return { axis, layer, dir, times: isDouble ? 2 : 1 }; }); }

token[0]取面标识,isPrime决定方向,isDouble决定转两次还是转一次。入队时times: 2就推两个同向 Move,别去实现 180 度转动的独立动画,没有必要。

第三个改动点是暴露事件。cube.onMoveDone这个回调在每次 commit 之后触发,接上它就能做步数统计、撤销栈、还原成功判定:

cube.onMoveDone = () => { history.push(cube.lastCommitted); // 压栈,撤销用 if (isSolved(cube)) showWinPanel(); // 六面同色判定 };

isSolved的判定别去比每个方块的朝向矩阵,直接按面统计颜色:每个面 9 个小格颜色一致即还原,实现简单也不容易出错。

想改的东西动哪个文件注意
配色、缝隙、尺寸render/camera.js、core/cube.js 参数改了 spacing 记得同步拾取用的坐标
增加中层转动、斜转core/moves.js 的 map 表layer 要扩到 0
撤销 / 步数 / 计时cube.onMoveDone 回调撤销用历史栈,别反向推公式
换成 4 阶、5 阶core/cube.js 的构造参数层号范围变了,拾取和判定都要跟着改

后面还有一条经验:每次动完转动逻辑,别只看屏幕,把 26 个方块的pos和rot打印出来,跑一遍 6 个基本操作再全部逆操作回去,看状态是否严格回到初始值。这个自检我一开始懒得加,结果在一个“转 4 次回到原位”的 bug 上耗了整整一下午,从那以后每次改完转动引擎都强制走一遍这个往返校验。希望这份拆解帮到你,把纯 JS 魔方代码真正跑起来、改出自己的版本。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询