时间:2023-07-08 16:03:01 | 来源:网站运营
时间:2023-07-08 16:03:01 来源:网站运营
Web 魔方模拟器的设计与实现:魔方是个结构简单而变化无穷的神奇玩具。那么如何在万能的浏览器里模拟出魔方的无尽变换,又如何将其还原呢?下面让我们一步步地来一探究竟吧。3^3 = 27
块。对这些块,你所能使用的唯一操作(或者说变换)方式,就是在不同面上的旋转。那么,我们该如何标识出一次旋转操作呢?Front
,背对的一面定义为 Back
。类似地,我们有了 Left
/ Right
/ Upper
/ Down
来标识其余各面。当你旋转某一面时,我们用这一面的简写(F
/ B
/ L
/ R
/ U
/ D
)来标识在这一面上的一次顺时针 90 度旋转。对于一次逆时针的旋转,我们则用 F'
/ U'
这样带 '
的记号来表达。如果你旋转了 180 度,那么可以用形如 R2
/ U2
的方式表示。如下图的 5 次操作,如果我们约定蓝色一面为 Front,其旋转序列就是 F' R' L' B' F'
:Block
基类,然后用形如 CornerBlock
和 EdgeBlock
的类来抽象棱块和角块,在每个角块实例中还可以保存这个角块到它相邻三个棱块的引用……这样一个魔方的 Cube
对象只需持有对中心块的引用,就可以基于各块实例的邻接属性保存整个魔方了。O(1)
地实现「给定某个块,查找其邻接块」的操作,但不难发现,它需要 O(N)
的复杂度来实现形如「某个位置的块在哪里」这样的查找操作,基于它的旋转操作也并不十分符合直觉。相对地,另一种显得「过于暴力」的方式反而相当实用:直接开辟一个长度为 27 的数组,在其中存储每一块的颜色信息即可。O(1)
的时间复杂度。而如果我们在一个三维坐标系中定位魔方的每一个块,那么每个块的空间坐标都可以唯一地映射到数组的下标上。更进一步地,我们可以令 x, y, z
分别取 -1, 0, 1
这三个值来表达一个块在其方向上可能的位置,这时,例如前面所定义的一次 U
旋转,刚好就是对所有 y 轴坐标值为 1 的块的旋转。这个良好的性质很有利于实现对魔方的变换操作。rotate (center, clockwise = true) { const axis = center.indexOf(1) + center.indexOf(-1) + 1 // Fix y direction in right-handed coordinate system. clockwise = center[1] !== 0 ? !clockwise : clockwise // Fix directions whose faces are opposite to axis. clockwise = center[axis] === 1 ? clockwise : !clockwise let cs = [[1, 1], [1, -1], [-1, -1], [-1, 1]] // corner coords let es = [[0, 1], [1, 0], [0, -1], [-1, 0]] // edge coords const prepareCoord = coord => coord.splice(axis, 0, center[axis]) cs.forEach(prepareCoord); es.forEach(prepareCoord) if (!clockwise) { cs = cs.reverse(); es = es.reverse() } // 移动每个块到其新位置 const rotateBlocks = ([a, b, c, d]) => { const set = (a, b) => { for (let i = 0; i < 6; i++) a[i] = b[i] } const tmp = []; set(tmp, a); set(a, d); set(d, c); set(c, b); set(b, tmp) } const colorsAt = coord => this.getBlock(coord).colors rotateBlocks(cs.map(colorsAt)); rotateBlocks(es.map(colorsAt)) // 调整每个块的自旋朝向 const swap = [ [[F, U, B, D], [L, F, R, B], [L, U, R, D]], [[F, D, B, U], [F, L, B, R], [D, R, U, L]] ][clockwise ? 0 : 1][axis] const rotateFaces = coord => { const block = colorsAt(coord) ;[block[swap[1]], block[swap[2]], block[swap[3]], block[swap[0]]] = [block[swap[0]], block[swap[1]], block[swap[2]], block[swap[3]]] } cs.forEach(rotateFaces); es.forEach(rotateFaces) return this}
这个实现的效率应该不差:在笔者的浏览器里,上面的代码可以支持每秒 30 万次的旋转变换。为什么在这里我们需要在意性能呢?在魔方的场景下,有一个非常不同的地方,即状态的有效性与校验。O(N)
的关联。好在一个实际把玩中的魔方状态一般只会在 100 步之内,故而上面以牺牲时间复杂度换取数据有效性的代价应当是值得的。另外,这个方式可以非常简单地实现魔方任意状态之间的时间旅行:从初始状态走到任意一步的历史状态,都只需要叠加上它们之间一系列的旋转 diff 操作即可。这是一个很可靠的思维模型。drawElements
或 drawArray
渲染一帧[-1, -1, -1]
到 [1, 1, 1]
的一系列块。在一个三重的 for
循环里,逐个将这些块绘制到屏幕上的逻辑大概就像前面看到的这张图:drawElements
后,即可实现流畅的 60 帧动画了 :)gl_Position
位置。但对于单个面的旋转,我们选择了先在 CPU 中计算好顶点位置,再将其传入顶点缓冲区。这和魔方旋转动画的实现原理直接相关:rotate
API 来「瞬间旋转好」魔方的数据模型,而后再多绘制一帧。render
API。考虑到魔方在绘制时可能存在对某个面一定程度的旋转,这个无状态的渲染 API 接口形如:render (rX = 0, rY = 0, moveFace = null, moveAngle = 0) { if (!this.gl) throw new Error('Missing WebGL context!') this.buffer = getBuffer(this.gl, this.blocks, moveFace, moveAngle) renderFrame(this.gl, this.programInfo, this.buffer, rX, rY)}
而对单个面的旋转过程中,我们可以使用浏览器的 requestAnimationFrame
API 来实现基本的时序控制。一次调用 animate
的旋转返回一个在单次旋转结束时 resolve 的 Promise,其实现如下:animate (move = null, duration = 500) { if (move && move.length === 0) return Promise.resolve() if (!move || this.__ANIMATING) throw new Error('Unable to animate!') this.__ANIMATING = true let k = move.includes("'") ? 1 : -1 if (/B|D|L/.test(move)) k = k * -1 const beginTime = +new Date() return new Promise((resolve, reject) => { const tick = () => { const diff = +new Date() - beginTime const percentage = diff / duration const face = move.replace("'", '') if (percentage < 1) { this.render(this.rX, this.rY, face, 90 * percentage * k) window.requestAnimationFrame(tick) } else { this.move(move) this.render(this.rX, this.rY, null, 0) this.__ANIMATING = false resolve() } } window.requestAnimationFrame(tick) })}
if (Array.isArray(move) && move.length > 1) { const lastMove = move.pop() // 返回递归得到的 Promise return this.animate(move).then(() => this.animate(lastMove))} else if (move.length === 1) move = move[0] // 继续已有逻辑
到这里,一个可以供人体验的魔方基本就可以在浏览器里跑起来了。但这还不是我们最终的目标:我们该怎么自动还原一个魔方呢?R2
旋转即可使红白棱块归位。但下面这种情况也是完全合法的:solveCross () { const clonedCube = new Cube(null, this.cube.moves) const moveSteps = [] while (true) { const lostEdgeCoords = findCrossCoords(clonedCube) if (!lostEdgeCoords.length) break moveSteps.push(solveCrossEdge(clonedCube, lostEdgeCoords[0])) } return moveSteps}
这个实现原理并不复杂,其代价就是过小的局部最优造成了较多的冗余步骤。如果同时观察 2 个甚至更多的棱块状态并将其一并归位,其效率显然能得到提升(这时的实现难度也是水涨船高)。作为对比,一流的魔方玩家可以在 7 步内完成十字,但这个算法实现却需要 20 步左右——不过这里意思已经到了,各位看官就先不要太苛刻啦。U L U' R' U L' U' R
的步骤来还原。类似地,在还原顶层顺序时,规则的匹配方式形如这样:R2 U' R' U' R U R U R U' R
。这样一来,只需要实现对规则的匹配和执行操作,规则的逻辑就可以完全与代码逻辑解耦,变为可配置的 JSON 格式数据。用于还原前两层的一条规则格式形如:{ match: { [E]: topEdge(COLOR_F, E), [SE]: SE_D_AS_F }, moves: "U (R U' R')"}
顶层同色的规则格式形如:{ match: { [NW]: L, [NE]: R, [SE]: R, [SW]: L }, moves: "R U R' U R U' R' U R U U R'"}
顶面顺序的规则格式形如:{ match: { [N]: W, [W]: [E], [E]: N }, moves: "R R U' R' U' R U R U R U' R"}
这里的 NW
/ E
/ SE
是笔者的实现中基于九宫格东西南北方向定位的简写。在实现了对规则的自动匹配和应用之后,CFOP 中后面三步的实现方式可以说大同小异,主要的工作集中在一些与旋转相关的 mapping 处理。{ match: { [N]: W, [W]: [E], [E]: N }, moves: "R R U' R' U' R U R U R U' R"}
我们只需要将 moves
中的每一步顺序颠倒地输入初始状态的魔方,就可以用这个状态来验证规则是否能够匹配,以及魔方是否能基于该规则还原了。这个性质使得我很容易地编写了下面这样简单的代码,自动验证每条输入规则的正确性:const flip = moves => moves.map(x => x.length > 1 ? x[0] : x + "'").reverse()OLL.forEach(rule => { const rMoves = flip(rule.moves) const cube = new Cube(null, rMoves) if ( matchOrientationRule(cube, rule) && isOrientationSolved(cube.move(rule.moves)) ) { console.log('OLL test pass', rule.id) } else console.error('Error OLL rule match', rule.id)})
在这个支持自测试的规则匹配算法基础上,求解魔方的全部步骤就这样计算出来了 :)关键词:设计,实现,模拟