iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0

模組四|蜜蜂、碰撞與勝負(Day 16–20)

Day 10 結尾我留了一句話:模擬層我敢說穩,畫面層不敢,而「不同幀率會不會玩出不同勝負」是 Day 19 的題目。今天就是那一天。

先把事實攤開。這個專案的隨機來源是一個 62 行、零 import 的純函式檔(src/utils/seededRandom.js);src/ 底下 Math.random 零命中【實測:grep -rn 'Math\.random' src/,無輸出】;tests/unit/determinism.test.js 用 173 行在 Node 裡組出一整場沒有畫面的模擬,跑 600 個固定步,把整個蜂群的狀態拿去逐項比對。這三件事都成立。

不成立的是標題那件事:我沒有在兩台不同刷新率的實機上,跑同一條線比對過勝負。

結論先講:決定性不是「別用 Math.random」這一條規則,是一條從亂數源、容器迭代順序到時間軸的鏈;而且它保證的範圍比直覺小——只到「同一個 JavaScript 引擎之內」為止,再往外靠的不是位元一致,是關卡留裕度。


亂數源:62 行、零 import、兩個魔術常數

src/utils/seededRandom.js 全檔 62 行,沒有任何 import,做兩件事:把任意字串壓成一個 32 位元整數,再從那個整數長出一條可重播的數列。

function hashSeed(seed) {
  if (typeof seed === 'number' && Number.isFinite(seed)) {
    return Math.floor(seed) >>> 0
  }

  const text = String(seed ?? '')
  let hash = 2166136261

  for (let index = 0; index < text.length; index += 1) {
    hash ^= text.charCodeAt(index)
    hash = Math.imul(hash, 16777619)
  }

  return hash >>> 0
}

export function createRandom(seed) {
  let state = hashSeed(seed)

  const next = () => {
    state = (state + 0x6d2b79f5) >>> 0

    let value = Math.imul(state ^ (state >>> 15), 1 | state)
    value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value

    return ((value ^ (value >>> 14)) >>> 0) / UINT32_RANGE
  }
  // range / pick / angle / fork 都掛在同一條 state 上
}

216613626116777619 是 FNV-1a 的偏移基底與質數,0x6d2b79f5 是 mulberry32 的 Weyl 常數(:10:14:31)。沒有一個字是密碼學等級的,也不需要——條件只有兩個:同一個 seed 給同一串值、不同 seed 給不同串值。

真正值錢的設計在 :51fork(salt),理由寫在 :20-26 的 JSDoc:每條 stream 各自持有自己的 state,從一條抽值不會挪動另一條。每隻蜜蜂拿到的是 this.random.fork('bee-' + index),所以第三隻蜜蜂這一步多抽了一個亂數(例如它剛好卡住、要挑左右方向,那是 Day 18 的事),不會讓第四隻的漫遊角度整個偏掉。

共用一條 stream,會讓「誰先抽」變成遊戲規則的一部分。 這種相依不會報錯,只會在你改了某個不相干的分支之後讓重播對不上。


但亂數不是唯一的亂數源

上面兩道箍我敲緊了,最下面那道還鬆著,水就從那一圈滲出來

專案裡有一條規則是機器在守的。eslint.config.js:56 起的 no-restricted-syntax,第二條選擇器長這樣:

{
  // Catches both `Math.random()` and aliasing such as `const r = Math.random`.
  selector:
    "MemberExpression[object.name='Math'][property.name='random']",
  message:
    'Gameplay randomness must come from createRandom() in src/utils/seededRandom.js so simulations stay reproducible.'
}

它擋的是 AST 節點不是字串,所以 const r = Math.random 這種先取別名再呼叫的寫法一樣被擋(註解寫在 :65)。lint 在 CI 的 verify job 裡,這條規則有真的執行者——Day 29 會把它放進覆蓋率表一起算。

但擋掉 Math.random 只解決了一半,另一半藏在容器裡。BeeSystem.js:191-194 有一段註解與一個刻意寫成索引的迴圈:

Fixed index order: never Set/Map iteration, so replays stay identical.

for...of 走一個 Set,在同一個引擎裡順序也是穩定的——但只要有人在中間刪除再插入,順序就跟著改,而這種改動在 code review 裡看起來像無害的重構。專案把它寫成測試:determinism.test.js:147-172 跑 400 步之後抓 beeSystem.bees.map(bee => bee.index),斷言它等於自己排序後的樣子,並斷言那個容器是 Array

第三個來源是牆上時鐘。BeeSystem.js:42-48 的 JSDoc 最後一句是 There is deliberately no Date.now() or performance.now().;倒數系統更狠,tests/unit/CountdownSystem.test.js:105-107CountdownSystem.js 的原始碼讀進來,用正規表示式斷言它沒有牆上時鐘。用測試掃自己的原始碼,是這個 repo 裡最直白的一種「把約定變成閘門」。

隱性亂數源 這個專案的處置 有沒有執行者
Math.random() 與其別名 eslint.config.js:56-71 的 AST 選擇器 lint 在 CI verify job
SetMap 迭代順序 固定索引迴圈(BeeSystem.js:191-194 determinism.test.js:147
Date.now()performance.now() 只吃 deltaMs,不讀時鐘 CountdownSystem.test.js:105 掃原始碼

時間軸:固定步長把幀率從變因裡拿掉

這一段的機制 Day 10 已經拆過,這裡只講它對「決定性」的貢獻。

GameLoop.js:79 先把這一幀夾在 MAX_FRAME_MS = 100 以內;:84-91 的 while 迴圈每次固定扣掉一個 fixedStepMs,最多跑 MAX_CATCH_UP_STEPS = 3 次;:88 傳給場景的是 this.fixedStepMs 這個常數,不是這一幀實際過了多久

最後那一句是關鍵。物理與蜜蜂看到的時間永遠是 1000/60 毫秒,一個 30fps 的裝置只是把兩個步長塞進同一幀跑完,不是把步長拉長成兩倍。tests/unit/GameLoop.test.js:23-38 把它釘成斷言:餵一個 250ms 的長幀,updateFixed 恰好被呼叫 3 次、累加器停在 10。

還有一個小到會被跳過、形狀卻很典型的東西:src/config/game.js:11-16ACCUMULATION_EPSILON_MS = 1e-6,註解說明累加 FIXED_STEP_MS 會落在整毫秒目標前面幾飛秒,不補這個容差,deadline 會被推遲一整個步長。浮點誤差在固定步長系統裡不會表現成「數字不準」,會表現成「差一步」——而差一步足以改變勝負。


測試測的不是那個函式,是整場模擬

tests/unit/seededRandom.test.js 有 7 個測試在守純函式層(同 seed 同序列 :9、stream 互不干擾 :24-32fork 可重現 :62-68)。但那一層過了,不代表遊戲可重播。

determinism.test.js:26-71simulate() 做的是另一件事:在 Node 裡真的組出 PhysicsManager、關卡剛體、一條防線、CollisionSystemBeeSystem,手動跑 600 個固定步,回傳蜂群快照、生成總數與狗的座標。

it('replays bit-for-bit with the same seed and the same line', () => {
  const first = simulate({ seed: level01.seed })
  const second = simulate({ seed: level01.seed })

  expect(first.bees.length).toBeGreaterThan(0)
  expect(second).toEqual(first)
})

:78 那行 toBeGreaterThan(0) 是這個檔案最值得抄走的一行。沒有它,一個「蜜蜂根本沒生出來」的 bug 會讓 expect(second).toEqual(first) 變成兩個空陣列相等——測試會綠,而且綠得毫無破綻。可重現性測試天生有這個弱點:什麼都沒發生,也是一種完美的重現。

六個測試分工如下:

測試(行號) 守什麼
:74 同 seed 逐位重播 主張本身
:82 換 seed 就不同 防止「決定性」退化成「常數」
:89 120/300/900 步都一樣 不是只有 600 步剛好對上
:97 鄰近 stream 先被抽乾 stream 隔離真的成立
:108 body 數 < 100 與 Day 27 的效能預算掛鉤
:147 不依賴 Set/Map 迭代順序 隱性亂數源

保證的邊界:跨瀏覽器那一刀是實測砍出來的

我沒有想辦法把路鋪平,我把碗裡的水少裝了三分

這是全篇唯一一段有實測、而且結論是「我原本想的太滿」的證據。

openspec/changes/archive/2026-08-06-add-bee-and-countdown/design.md 的 D10 記著:原本的決策 D3 主張「給定 seed 與防線就能位元級重現」,單元測試在 Node 裡證實了;但 E2E 在 mobile-iphone(WebKit)profile 上出現了 Chromium 判 WIN、WebKit 判 LOSE 的分歧。

原因寫在同一條裡:ECMAScript 不規定 Math.sinMath.cosMath.atan2Math.hypot 的最後一個 ulp。蜜蜂每一步轉向都要呼叫這幾個函式,600 步之後那點誤差足以決定某一隻蜜蜂鑽不鑽得過防線。

而修法不是去改決定性——是改關卡。原本的高空拱形防線在 12 個 seed 裡只贏 11 個,它一直是邊緣解,跨引擎分歧只是把這件事顯影出來;換成兩端落在平台上的圓頂防線之後是 12/12。結論落地成一條護欄測試(tests/unit/levelOutcome.test.js:120-133),12 個 seed 跑同一條示範防線全部要 WIN——剛好守住的線會在這裡先失敗,而不是等到瀏覽器上偶發。

所以「在你的手機上也要輸」靠的不是位元一致,是留裕度。 決定性負責讓同一個引擎裡的重播可信,跨引擎的一致性是關卡設計的責任——這兩件事被明確分開,是我在這個專案裡最不後悔的一次範圍界定。


我沒做的那項實測,以及畫面層的誠實話

大綱原本替這篇設計的開場是「同一條線,在 60Hz 螢幕上贏,在 120Hz 螢幕上輸」。我不能這樣寫,因為那件事沒有量過:撰稿用的 tasks.md 4.2(跨幀率一致性:兩台裝置各跑一次相同操作,不一致要錄影)到今天仍未勾選,專案 dev-log.md 證據索引第 4 項「不同幀率跑出不同勝負的實測」狀態是 ⬜ 可補【皆實查】。

我能說的只有機制論證:固定步長讓模擬層看不見幀率,GameLoop.test.js:23-38 證明長幀會被切成整數個固定步而不是拉長步長,所以跨幀率的勝負一致在理論上有保證——這是推論,不是量測結果。

畫面層要說得更難聽一點。RenderSyncSystem.sync():32-42)逐項把 body 的 positionangle 寫進顯示物件,沒有 alpha 參數,沒有插值。design.md 把代價寫下來了:加插值要改 GameLoop 的簽章,而那份契約已經 archive,在還沒量到問題之前不划算;代價是 120Hz 裝置上蜜蜂仍以 60Hz 的節奏更新位置。

完整的誠實版本是三句:模擬層跨幀率應該安全(推論);畫面平滑度在高刷新率裝置上不會變好(實查);兩者都還沒在實機上被驗證過。


附帶效果:seed 是寫死的

五個關卡的 seed 是五個固定字串:first-shield-2026steady-footing-2026flanking-swarm-2026heavy-shield-2026pincer-attack-2026src/levels/level01.js:27level05.js:38)。不是每局隨機,不是時間戳。

這讓「可重現 = 可除錯」強得多:玩家回報第三關過不了,我手上跑的就是同一批蜜蜂。 重播不是要另外做的功能,是預設狀態;剩下的變因只有玩家畫的那條線,而那條線在 window.__SAVE_THE_DOG_DEBUG__ 裡讀得到。


交給 AI

這一段沒有「AI 寫了什麼、我怎麼改」的故事可以講——誰打了哪一行,這個 repo 沒有留下可查的紀錄,我不編。

可以講的是分工的形狀。決定性這種約束有一個特性:它不會在你違反它的當下報錯。 寫下 Math.random() 的那一刻,遊戲照跑、測試照綠,代價要到某個人回報「我明明照做卻過不了」才浮出來。這種約束交給 code review 守是守不住的,尤其當產出速度快到你來不及逐行讀。

所以這個專案把它變成三個會失敗的東西:一條 AST lint 規則、一個掃自己原始碼的測試、一個跑滿 600 步比對整場模擬的測試,三個都在 CI 裡。我要求 AI 遵守的規則有幾十條,配了執行者的只有極少數,而決定性是有配的那幾條之一——這張對照表 Day 29 會整個攤開。誠實補一句:這條規則具體擋下過哪一次產出,我沒有紀錄(跟 Day 2、Day 8 同一個問題),我能證明的只有它存在、涵蓋別名、會讓 CI 紅。


帶走什麼

一句話:

決定性是一條鏈,不是一條規則。亂數、容器順序、時鐘、時間步長,任何一環漏掉都會讓重播失效,而且失效的時候不會有人報錯。

三件今天就能做的事:

  1. 把「唯一亂數來源」寫成 lint 規則,不要寫成慣例。 用 AST 選擇器而不是字串比對,否則取別名就繞過去了。
  2. 在可重現性測試裡加一條「有東西真的發生」的斷言。 兩個空陣列永遠相等,那是最假的綠燈。
  3. 先想清楚保證範圍到哪。 引擎內可重現與跨瀏覽器結果一致是兩個問題;後者用位元一致去追很貴,用設計裕度吸收便宜得多。

明天 Day 20,把這個模組收在一個我大綱裡沒想到的問題上:同一個固定步長裡,蜜蜂碰到狗而且十秒剛好走完,算贏還是算輸?這個專案為它寫了一個具名常數與一個純函式,寫得很漂亮——然後我 grep 了一次呼叫端,發現真正在跑的路徑不是它。順便講重試為什麼是整個場景銷毀重建,以及那個在 destroy() 裡漏掉沒清的全域變數。


本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit 5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑自行對照。
可玩網址https://save-the-dog-web.vercel.app/原始碼https://github.com/HarryFan/save-the-dog-web

參考資料

如果你卡在語法

深入原理


上一篇
Day 18|卡住的蜜蜂:規劃了三級復原,實作交出兩級
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言