模組五|關卡、UI 與資料(Day 21–25)
昨天把關卡變成了純資料。資料化最直接的一個副作用是:五個關卡可以並排放在一起 diff,難度曲線會自己顯形。今天就是把這五份資料橫著攤開來看一次。
先說我原本的預期。做難度曲線最順手的做法是把數值往上加——第二關蜜蜂快一點、第三關再快一點。我原本以為這個專案至少會用到一點點。
結論先講:五關的蜜蜂共用同一個 Object.freeze 的調校物件,十個參數一個都沒動,而且有一條測試的名字就叫 does not make bees faster to raise difficulty。 難度全部由地形與畫線預算提供,這件事不是我事後歸納的,是寫在原始碼註解裡、並且被斷言鎖住的。

以下全部【實查】自 src/levels/level01.js 到 level05.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: 185、maximumAcceleration: 560、生成間隔 230 ms(sharedLevelParts.js:13-24) |
| 洞穴殼 | 洞室 left 96 / right 654、surfaceY 604、shoulderY 792、floorY 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 都恰好只有 instruction 與 hint 兩句,沒有第三句【實查: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 決定,理由很具體:難度曲線的兩層裡,只有一層有可判定的目標。
「這關解得開嗎」可以寫成斷言——playLevel 跑 12 個 seed,全 WIN 就是過。這一層我確實整個丟給機器跑,而且它推翻過我兩次(第四關的兩次改寫)。但那是測試在推翻我,不是 AI 在幫我想關卡。
「這關教得會嗎」沒有這種目標。我沒辦法把「玩家在第三關應該要恍然大悟」寫成一個會失敗的檢查,所以也沒辦法把它交出去——交出去的前提是我能驗收,我驗收不了的東西,交出去只是把判斷推給一個更沒有資料的人。
一句話:
難度曲線要沿著「概念」爬,不是沿著「數值」爬。而要證明自己真的沒有偷偷靠數值爬,最省事的辦法是把數值凍起來,再寫一條測試守住它。
三件今天就能做的事:
Object.freeze(SHARED_BEE_TUNING),十個參數五關共用。凍結之後,「難度來自地形」不再是一句宣言,是一個 diff 看得出來的事實。does not make bees faster to raise difficulty 這條測試只做一件事:把五關的 maximumSpeed 塞進 Set 再斷言 size 是 1。它擋不住聰明的繞路,但它會在有人「只是暫時調一下」的那一刻變紅。明天 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
如果你卡在語法
深入原理