時間:2023-07-08 16:03:01 | 來源:網(wǎng)站運營
時間:2023-07-08 16:03:01 來源:網(wǎng)站運營
Web 魔方模擬器的設(shè)計與實現(xiàn):魔方是個結(jié)構(gòu)簡單而變化無窮的神奇玩具。那么如何在萬能的瀏覽器里模擬出魔方的無盡變換,又如何將其還原呢?下面讓我們一步步地來一探究竟吧。3^3 = 27
塊。對這些塊,你所能使用的唯一操作(或者說變換)方式,就是在不同面上的旋轉(zhuǎn)。那么,我們該如何標識出一次旋轉(zhuǎn)操作呢?Front
,背對的一面定義為 Back
。類似地,我們有了 Left
/ Right
/ Upper
/ Down
來標識其余各面。當你旋轉(zhuǎn)某一面時,我們用這一面的簡寫(F
/ B
/ L
/ R
/ U
/ D
)來標識在這一面上的一次順時針 90 度旋轉(zhuǎn)。對于一次逆時針的旋轉(zhuǎn),我們則用 F'
/ U'
這樣帶 '
的記號來表達。如果你旋轉(zhuǎn)了 180 度,那么可以用形如 R2
/ U2
的方式表示。如下圖的 5 次操作,如果我們約定藍色一面為 Front,其旋轉(zhuǎn)序列就是 F' R' L' B' F'
:Block
基類,然后用形如 CornerBlock
和 EdgeBlock
的類來抽象棱塊和角塊,在每個角塊實例中還可以保存這個角塊到它相鄰三個棱塊的引用……這樣一個魔方的 Cube
對象只需持有對中心塊的引用,就可以基于各塊實例的鄰接屬性保存整個魔方了。O(1)
地實現(xiàn)「給定某個塊,查找其鄰接塊」的操作,但不難發(fā)現(xiàn),它需要 O(N)
的復(fù)雜度來實現(xiàn)形如「某個位置的塊在哪里」這樣的查找操作,基于它的旋轉(zhuǎn)操作也并不十分符合直覺。相對地,另一種顯得「過于暴力」的方式反而相當實用:直接開辟一個長度為 27 的數(shù)組,在其中存儲每一塊的顏色信息即可。O(1)
的時間復(fù)雜度。而如果我們在一個三維坐標系中定位魔方的每一個塊,那么每個塊的空間坐標都可以唯一地映射到數(shù)組的下標上。更進一步地,我們可以令 x, y, z
分別取 -1, 0, 1
這三個值來表達一個塊在其方向上可能的位置,這時,例如前面所定義的一次 U
旋轉(zhuǎn),剛好就是對所有 y 軸坐標值為 1 的塊的旋轉(zhuǎn)。這個良好的性質(zhì)很有利于實現(xiàn)對魔方的變換操作。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)) // 調(diào)整每個塊的自旋朝向 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}
這個實現(xiàn)的效率應(yīng)該不差:在筆者的瀏覽器里,上面的代碼可以支持每秒 30 萬次的旋轉(zhuǎn)變換。為什么在這里我們需要在意性能呢?在魔方的場景下,有一個非常不同的地方,即狀態(tài)的有效性與校驗。O(N)
的關(guān)聯(lián)。好在一個實際把玩中的魔方狀態(tài)一般只會在 100 步之內(nèi),故而上面以犧牲時間復(fù)雜度換取數(shù)據(jù)有效性的代價應(yīng)當是值得的。另外,這個方式可以非常簡單地實現(xiàn)魔方任意狀態(tài)之間的時間旅行:從初始狀態(tài)走到任意一步的歷史狀態(tài),都只需要疊加上它們之間一系列的旋轉(zhuǎn) diff 操作即可。這是一個很可靠的思維模型。drawElements
或 drawArray
渲染一幀[-1, -1, -1]
到 [1, 1, 1]
的一系列塊。在一個三重的 for
循環(huán)里,逐個將這些塊繪制到屏幕上的邏輯大概就像前面看到的這張圖:drawElements
后,即可實現(xiàn)流暢的 60 幀動畫了 :)gl_Position
位置。但對于單個面的旋轉(zhuǎn),我們選擇了先在 CPU 中計算好頂點位置,再將其傳入頂點緩沖區(qū)。這和魔方旋轉(zhuǎn)動畫的實現(xiàn)原理直接相關(guān):rotate
API 來「瞬間旋轉(zhuǎn)好」魔方的數(shù)據(jù)模型,而后再多繪制一幀。render
API??紤]到魔方在繪制時可能存在對某個面一定程度的旋轉(zhuǎn),這個無狀態(tài)的渲染 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)}
而對單個面的旋轉(zhuǎn)過程中,我們可以使用瀏覽器的 requestAnimationFrame
API 來實現(xiàn)基本的時序控制。一次調(diào)用 animate
的旋轉(zhuǎn)返回一個在單次旋轉(zhuǎn)結(jié)束時 resolve 的 Promise,其實現(xiàn)如下: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] // 繼續(xù)已有邏輯
到這里,一個可以供人體驗的魔方基本就可以在瀏覽器里跑起來了。但這還不是我們最終的目標:我們該怎么自動還原一個魔方呢?R2
旋轉(zhuǎn)即可使紅白棱塊歸位。但下面這種情況也是完全合法的: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}
這個實現(xiàn)原理并不復(fù)雜,其代價就是過小的局部最優(yōu)造成了較多的冗余步驟。如果同時觀察 2 個甚至更多的棱塊狀態(tài)并將其一并歸位,其效率顯然能得到提升(這時的實現(xiàn)難度也是水漲船高)。作為對比,一流的魔方玩家可以在 7 步內(nèi)完成十字,但這個算法實現(xiàn)卻需要 20 步左右——不過這里意思已經(jīng)到了,各位看官就先不要太苛刻啦。U L U' R' U L' U' R
的步驟來還原。類似地,在還原頂層順序時,規(guī)則的匹配方式形如這樣:R2 U' R' U' R U R U R U' R
。這樣一來,只需要實現(xiàn)對規(guī)則的匹配和執(zhí)行操作,規(guī)則的邏輯就可以完全與代碼邏輯解耦,變?yōu)榭膳渲玫?JSON 格式數(shù)據(jù)。用于還原前兩層的一條規(guī)則格式形如:{ match: { [E]: topEdge(COLOR_F, E), [SE]: SE_D_AS_F }, moves: "U (R U' R')"}
頂層同色的規(guī)則格式形如:{ match: { [NW]: L, [NE]: R, [SE]: R, [SW]: L }, moves: "R U R' U R U' R' U R U U R'"}
頂面順序的規(guī)則格式形如:{ match: { [N]: W, [W]: [E], [E]: N }, moves: "R R U' R' U' R U R U R U' R"}
這里的 NW
/ E
/ SE
是筆者的實現(xiàn)中基于九宮格東西南北方向定位的簡寫。在實現(xiàn)了對規(guī)則的自動匹配和應(yīng)用之后,CFOP 中后面三步的實現(xiàn)方式可以說大同小異,主要的工作集中在一些與旋轉(zhuǎn)相關(guān)的 mapping 處理。{ match: { [N]: W, [W]: [E], [E]: N }, moves: "R R U' R' U' R U R U R U' R"}
我們只需要將 moves
中的每一步順序顛倒地輸入初始狀態(tài)的魔方,就可以用這個狀態(tài)來驗證規(guī)則是否能夠匹配,以及魔方是否能基于該規(guī)則還原了。這個性質(zhì)使得我很容易地編寫了下面這樣簡單的代碼,自動驗證每條輸入規(guī)則的正確性: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)})
在這個支持自測試的規(guī)則匹配算法基礎(chǔ)上,求解魔方的全部步驟就這樣計算出來了 :)關(guān)鍵詞:設(shè)計,實現(xiàn),模擬
微信公眾號
版權(quán)所有? 億企邦 1997-2025 保留一切法律許可權(quán)利。