iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0

模組五|關卡、UI 與資料(Day 21–25)

昨天把關卡變成了純資料。資料化最直接的一個副作用是:五個關卡可以並排放在一起 diff,難度曲線會自己顯形。今天就是把這五份資料橫著攤開來看一次。

先說我原本的預期。做難度曲線最順手的做法是把數值往上加——第二關蜜蜂快一點、第三關再快一點。我原本以為這個專案至少會用到一點點。

結論先講:五關的蜜蜂共用同一個 Object.freeze 的調校物件,十個參數一個都沒動,而且有一條測試的名字就叫 does not make bees faster to raise difficulty 難度全部由地形與畫線預算提供,這件事不是我事後歸納的,是寫在原始碼註解裡、並且被斷言鎖住的。


五關的全部差異,一張表講完

那罐數值我用封泥封死了,這一關變難是因為我把腳邊那道口子夾窄了

以下全部【實查】自 src/levels/level01.jslevel05.js

Lv 名稱 / slug 頸口(x, 寬) 開口總寬 蜂巢 蜂數 畫線上限 3★ 2★
1 第一道防線 first-shield (375, 210) 210 1,正上方 8 500 340 420
2 兩端要站穩 steady-footing (375, 300) 300 1,正上方 8 460 400 440
3 側面的蜂群 flanking-swarm (270, 220) 220 1,左上 8 430 350 400
4 防線也是重物 heavy-shield (375, 240) 240 1,正上方 8 400 340 380
5 左右夾擊 pincer-attack (225,140)+(525,140) 280 2 12 550 490 540

這張表只有五個欄位在動:開口的寬度、位置、數量,畫線預算,蜂巢數createCavern.js:210 那個函式的註解替這件事下了註腳,它叫 cavernOpeningWidth()

Total width of every opening — the single number the difficulty curve turns.

沒動的東西比動的多得多:

沒動的 內容
蜜蜂調校 SHARED_BEE_TUNING 十項全部相同,含 maximumSpeed: 185maximumAcceleration: 560、生成間隔 230 ms(sharedLevelParts.js:13-24
洞穴殼 洞室 left 96 / right 654surfaceY 604shoulderY 792floorY 1188,五關完全一致(:105-111
畫線調校 SHARED_DRAW_TUNING 七項相同,只有 maximumLength 逐關覆寫
規則 10 秒倒數、8 秒後蜂巢自己放蜂、只能畫一筆createRules():146-155
世界邊界、背景色、邏輯尺寸 五關共用

sharedLevelParts.js:6-9 的註解把設計意圖直接寫在共用件的頭上:

The curve is taught through attack angle, anchor points, terrain and the draw budget — never by making bees faster. Keeping these numbers identical also means level geometry is the only variable when a level's solvability is measured.

後半句才是重點。把蜜蜂凍結不只是設計偏好,它是一個測量前提:五關唯一的變數是幾何,所以某一關量出來不能玩的時候,只有一組數字要看。


難度真正的數字:預算減掉示範解

上面那張表還藏著一個更好讀的指標。每一關的資料裡都有一條 referenceSolution——作者示範的解法,五關都是一條橫跨開口、兩端坐在柱頂上的直線。把畫線上限減掉它的長度,得到的就是玩家的容錯空間:

Lv 示範解長度 畫線上限 容錯空間 示範解星等
1 286 500 214 3★
2 376 460 84 3★
3 292 430 138 3★
4 320 400 80 3★
5 520 550 30 2★

(示範解長度是兩點之間的水平距離,可以自己從 referenceSolution 的座標算;星等是 calculateStars() 套用該關 scoring 的結果。)

214 → 84 → 138 → 80 → 30。這條曲線不是單調的,第三關反而比第二關寬鬆。理由在關卡註解裡:第三關要教的是攻擊角度,不是節約——把預算同時收緊會讓玩家分不清自己是敗在角度還是敗在長度。

第五關更違反直覺:它是全遊戲預算最大的一關,550。level05.js:29-31 寫明了理由:

...which is why this level gets the largest budget in the game rather than the smallest: the difficulty is in seeing the one-stroke shape, not in drawing it economically.

而它的容錯空間只有 30 px,示範解只拿得到 2 星。最大的預算配上最小的餘裕——這一關要考的是「你有沒有看出那條線該長什麼樣」,不是手穩不穩。

單元測試把這個不單調也一起鎖住了。tests/unit/levelRepository.test.js:35 那條斷言的名字很誠實地只寫到第四關:tightens the draw budget from level 1 through 4。第五關被刻意排除在遞減之外。


每一關教什麼,原始碼自己說了

五個關卡檔的檔頭都有一段 JSDoc 寫明教學目標。這裡逐關摘一句:

Lv 檔案:行 原文
1 level01.js:15-21 teaches drawing, the countdown and win/lose … There is nothing to work out
2 level02.js:16-22 teaches that the opening costs line … a wider hole needs a longer plank
3 level03.js:16-23 teaches attack angle … The left seat is deliberately the narrow one
4 level04.js:31-38 teaches that a line without support is not a line
5 level05.js:24-32 the exam … the difficulty is in seeing the one-stroke shape

第二關值得單獨看一眼,因為它把「難度」拆成了玩家看得見的算術:洞口從 210 拉寬到 300,畫線預算反而從 500 收到 460。洞變大、能用的線變短,玩家不需要理解任何物理就知道自己被逼緊了。

教學文字的紀律也可以直接查:五關的 tutorial 都恰好只有 instructionhint 兩句,沒有第三句【實查:level01.js:49-52 等五處】。看三行字才能開始玩的教學關,玩家會直接跳過。


星等用線長算,不用時間

星等門檻是關卡資料的一部分,五關各不相同。用線長而不是用剩餘時間來評分,這個選擇的理由寫在 src/systems/scoring.js:3-10

Length is the yardstick rather than remaining time: the countdown is fixed, so a win always ends with zero seconds left and time cannot separate a careful solution from a sloppy one.

倒數固定 10 秒,贏的那一刻剩餘時間永遠是零——時間這個維度在這個遊戲裡沒有解析度。線長有:畫得住跟畫得精簡之間是連續的,而且由玩家自己控制。

這是「用資料表達難度」最乾淨的一個例子:評分規則不是寫死在計分程式裡的常數,是每一關自己帶的兩個數字,所以第五關可以合法地說「我這關 490 才算三星」,而第四關說「340」。計分函式本身只有 29 行,裡面沒有任何一個關卡專屬的判斷。

不過這些門檻的來歷要講清楚:它們是回填的,不是設計的。 地形從開闊平地換成洞穴之後(commit 24a8d1c,2026-08-06 17:58),五關的示範解全部重畫,門檻跟著全部重調——而 maximumLength 五關一個都沒動:

Lv 舊示範解 新示範解 舊三星 新三星 畫線上限
1 342 286 360 340 500(未動)
2 350 376 360 400 460(未動)
3 332 292 370 350 430(未動)
4 330 320 260 340 400(未動)
5 461 520 450 490 550(未動)

(舊值取自 openspec/changes/archive/2026-08-06-add-tutorial-levels/tasks.md 的實作結果表,新值取自現行五個關卡檔。)

第四關的三星門檻從 260 跳到 340,幅度 31%。門檻是跟著解法走的,不是先訂一個「三星應該多難」再去湊。 這件事本身沒有錯,但它意味著星等門檻不能當成難度設計的證據——它是解法的影子。


第四關被改過兩次,兩次都是機器發現的

第四關是這五關裡唯一被重寫過兩次的,而且兩次的原因是同一句話:我想教的東西,遊戲實際上教不出來。

第一次(開闊地形時期)。它原本的教學目標是「別把狗推下去」——狗旁邊有坑,畫壞的線會把狗推落。實測結果寫在 add-tutorial-levels/tasks.md 的實作結果裡:

原目標經實測無法產生 —— 八種畫法 × 六個 seed 皆未觸發 DOG_OUT_OF_BOUNDS;落下的防線對圓形狗狗的側向衝量不足以推離平台,調整任一方密度亦然。

八種畫法 × 六個 seed = 48 次,一次都沒推下去。目標於是改成「防線也是重物、需要支撐」。

第二次(洞穴時期)。改成洞穴之後,狗一開始站在洞室正中央的窄台上,結果關卡變得沒有失敗解。理由寫在 level04.js:20-22

The dog was on a central ledge in the first draft and the level was trivial — every dropped line landed on the dog and roofed it, so "unsupported" and "supported" both won. Moving the shelf aside is what makes the lesson real.

掉下去的線正好落在狗身上,變成一個屋頂——「有支撐」和「沒支撐」都贏,反例站不住。把架子移到右側牆邊(ledge (560, 1040, 190×36))之後,沒坐上柱頂的線會直接掉到洞室地板,離狗老遠。

改完之後的反例現在有測試守著。tests/unit/levelSolvability.test.js:58-79 對第四關列了三種畫法,全部斷言 LOSE:太短搆不到任何一根柱子、只有一端踩在土上、畫在開口旁邊的實土上。「線有質量」這件事,最後是用「它會掉到哪裡」教的,不是用「它會壓到誰」。 後者聽起來比較戲劇化,但規則裁決不了它。


可解性有機器驗證,好不好玩沒有

這條路我來回推了十二趟證明走得通,那把椅子到現在還是空的

關卡設計是我在這個專案裡最沒把握的部分。但「沒把握」這件事要拆成兩層,因為其中一層是有數字的。

有機器驗證的那層:這關解得開。 tests/unit/helpers/playLevel.js 是 GameScene 的 headless 雙胞胎(同樣的系統、同樣的固定步長順序、沒有渲染器),levelSolvability.test.js 拿它跑五關 × 12 個 seed,全部斷言 WIN。地形改版時的第一次量測就是 12/12:

Lv 開闊地形 洞穴地形
01–05 12/12 12/12

而過程的差別才是重點(add-cavern-terrain/tasks.md:62-65):

上一個變更為了讓圓頂穩住,花了三輪實測 —— 降蜜蜂密度、加寬 L4 平台、把圓頂抬離狗狗頭頂。換成「兩端坐在柱頂的平板」之後,五關第一次量測就全部 12/12,一個參數都沒調。

同一組測試,換一種地形,調參的次數從三輪變成零。

沒有數字的那層:這關值不值得解。 全 repo 沒有任何真人試玩紀錄,add-tutorial-levels/tasks.md 的未完成項還明列著「中階手機實機 FPS 觀測」。我不會寫「我測過玩家會怎樣」,因為使用者資料是零。

還有一條邊界:12/12 是同一條參考解跨 12 個 seed 全勝,不是「玩家怎麼畫都會贏」。它證明的是「這關有解、而且解很穩」,不是「難度剛好」。把可解性驗證講成難度驗證,是這類數字最常見的誤用。


交給 AI

這一段我沒有交給 AI 決定,理由很具體:難度曲線的兩層裡,只有一層有可判定的目標。

「這關解得開嗎」可以寫成斷言——playLevel 跑 12 個 seed,全 WIN 就是過。這一層我確實整個丟給機器跑,而且它推翻過我兩次(第四關的兩次改寫)。但那是測試在推翻我,不是 AI 在幫我想關卡。

「這關教得會嗎」沒有這種目標。我沒辦法把「玩家在第三關應該要恍然大悟」寫成一個會失敗的檢查,所以也沒辦法把它交出去——交出去的前提是我能驗收,我驗收不了的東西,交出去只是把判斷推給一個更沒有資料的人。


帶走什麼

一句話:

難度曲線要沿著「概念」爬,不是沿著「數值」爬。而要證明自己真的沒有偷偷靠數值爬,最省事的辦法是把數值凍起來,再寫一條測試守住它。

三件今天就能做的事:

  1. 把「不准動的參數」抽成一個共用常數並凍結。 這個專案是 Object.freeze(SHARED_BEE_TUNING),十個參數五關共用。凍結之後,「難度來自地形」不再是一句宣言,是一個 diff 看得出來的事實。
  2. 為設計意圖寫一條測試,即使它看起來很傻。 does not make bees faster to raise difficulty 這條測試只做一件事:把五關的 maximumSpeed 塞進 Set 再斷言 size 是 1。它擋不住聰明的繞路,但它會在有人「只是暫時調一下」的那一刻變紅。
  3. 把難度曲線寫成一張可以並排比對的表再開始調。 動了什麼、沒動什麼分成兩欄。你會很快發現自己動的欄位比想像中多。

明天 Day 23 換到另一邊:五個畫面之間切來切去,怎麼確保上一個畫面真的消失了。要處理的具體問題是——這個專案的每一次重試都是整個場景銷毀重建,那麼「銷毀」到底要拆掉幾樣東西才算乾淨?以及一個更尷尬的問題:我怎麼知道自己沒漏? 這篇會有一組 50 次循環的實測數字,也會有一條我不打算跨過的界線。


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

參考資料

如果你卡在語法

深入原理


上一篇
Day 21|關卡是資料——但資料化的好處不會自己發生
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言