模組五|關卡、UI 與資料(Day 21–25)
前面二十天講的是一條線怎麼從手指變成剛體、蜜蜂怎麼追人、同一幀裡贏跟輸並存時算誰的。從今天開始換一層問題:這些機制要怎麼被五個不同的關卡重複使用,而不是複製貼上五份。
先攤開事實。src/levels/ 目前有 10 個檔、1,058 行(wc -l src/levels/*.js),其中五個是關卡檔,分別是 68、58、59、75、72 行。這五個檔從第一行到最後一行只做一件事:匯出一個物件實字。沒有 if、沒有 class、沒有任何一行會執行的邏輯。
結論先講:把關卡寫成資料很容易,難的是把「資料應該帶來的好處」真的接上線。 這個專案有一個約一百行、22 個斷言(一條斷言查一項規則)的關卡驗證器,連幾何一致性都查——然後它在玩家的瀏覽器裡一次都不會跑。這不是 bug,但如果沒人講清楚,下一個接手的人會以為載入時有防護。
這是第一關的前半段(src/levels/level01.js:22-52):
export const level01 = {
schemaVersion: 1,
id: 1,
slug: 'first-shield',
name: '第一道防線',
seed: 'first-shield-2026',
logicalSize: { width: 750, height: 1334 },
background: { color: '#8ED8F8' },
// Leaving these bounds is a level outcome, not a wall: bodies are never blocked.
worldBounds: { ...SHARED_WORLD_BOUNDS },
rules: createRules(),
cavern: CAVERN,
draw: {
...SHARED_DRAW_TUNING,
maximumLength: 500,
forbiddenZones: [
{ type: 'circle', x: DOG.x, y: DOG.y, radius: 68 },
{ type: 'circle', x: 375, y: 300, radius: 70 },
UI_FORBIDDEN_BAND
]
},
dog: createDog(DOG),
hives: [createHive({ id: 'hive-main', x: 375, y: 300, count: 8 })],
scoring: {
twoStarMaximumLength: 420,
threeStarMaximumLength: 340
},
tutorial: {
instruction: '畫出一道防線,保護狗狗撐過 10 秒。',
hint: '把洞口封起來就好。'
},
必填欄位有 11 項,清單就寫在驗證器裡(src/levels/levelRepository.js:4-18):id、slug、seed、rules、draw、dog、hives、cavern、worldBounds、scoring、referenceSolution。
有一個欄位刻意不在清單裡:platforms。原始碼旁邊有註解說明理由:洞穴才是現在的場地,只有第四關在洞室裡放了一塊架子,所以它是選用的。這件事值得注意的地方在於:「哪些欄位可以缺席」本身也是被寫下來的資訊,不是靠記憶。
另一個容易被誤會的是禁畫區。它是一個形狀陣列,同時混著 circle 與 rect,而且從第一關就是這樣。理由不是「後面的關卡形狀會變複雜」,是頂部的 UI 帶天生就是一個 750×116 的矩形(sharedLevelParts.js:57-63),角色周圍天生就是圓形。兩種形狀第一天就並存,所以第一天就不能寫死成「狗狗周圍」。
五個關卡檔加起來 332 行,旁邊還有兩個檔在撐著它們:sharedLevelParts.js(157 行)與 createCavern.js(212 行)。
sharedLevelParts.js 放的是五關完全一致的東西:蜜蜂的十項調校參數(一個 Object.freeze 的常數,凍結後不可再改)、洞穴的基本剖面、畫線的七項調校、那條 UI 禁畫帶、以及 createHive() / createDog() / createRules() 這幾個小工廠。關卡檔要的是差異,共用件要的是「這些東西不准逐關長歪」。
createCavern.js 更純粹,它的檔頭第 12 行寫著這模組的定位:
This module is pure data in, plain rectangles out: no Matter, no PixiJS.
進去的是 { neck: { x: 375, width: 210 } } 這種描述,出來的是一組軸對齊矩形。再往下一層的 createLevelBodies.js:15-20 也寫了同一件事的另一半:「Kept free of PixiJS so the physics of a level can be stepped in unit tests without a renderer.」
這條分層是有回報的,而且回報很具體:tests/unit/helpers/playLevel.js(106 行)可以在沒有渲染器的情況下,用跟 GameScene 完全相同的系統與固定步長順序把一關跑完。它自稱「Headless twin of GameScene」(headless:不開畫面)。省下幾行程式碼只是順帶;關卡資料化買到的核心,是「這一關可以在沒有畫面的地方被跑一遍」。 這件事怎麼被用來驗證五關的難度曲線,明天講。

validateLevel() 在 levelRepository.js:119-218,回傳 { valid, issues },是一個純函式(同輸入必得同輸出、不動外部狀態)。裡面沒有 JSON Schema(宣告式的欄位規格)也沒有 Zod,就是手寫的 22 個斷言(grep -c 'pushIssue(',扣掉定義那一行)。
值得看的是它查什麼。這一段是評分門檻(:154-179):
if (scoring) {
if (
typeof scoring.twoStarMaximumLength !== 'number' ||
typeof scoring.threeStarMaximumLength !== 'number'
) {
pushIssue(issues, level, 'scoring thresholds must be numbers')
} else {
if (scoring.threeStarMaximumLength >= scoring.twoStarMaximumLength) {
pushIssue(
issues,
level,
'scoring.threeStarMaximumLength must be smaller than twoStarMaximumLength'
)
}
if (level.draw && scoring.twoStarMaximumLength > level.draw.maximumLength) {
pushIssue(
issues,
level,
'scoring.twoStarMaximumLength must not exceed draw.maximumLength'
)
}
}
}
第一個 if 查型別,那是任何 schema 工具都會做的事。後面兩個查的是欄位之間的關係:三星門檻必須嚴格小於二星門檻,二星門檻不得超過畫線長度上限。第二條的意思是「不能存在一個玩家再怎麼畫都拿不到二星的關卡」——那是資料合法、但關卡不能玩。
同一類的檢查還有一整組跑在洞穴上(validateCavern(),:43-110):
| 檢查 | 擋掉的錯 |
|---|---|
surfaceY < shoulderY < floorY < outer.bottom |
剖面上下顛倒 |
chamber 必須落在 outer 之內 |
洞室比洞穴還寬 |
| 至少要有一個開口 | 一個密封到蜜蜂進不來的洞 |
| 開口必須嚴格落在洞室之內 | 開口貼到洞室的側壁,封閉性悄悄失效 |
| 狗必須站在洞室裡 | 狗在洞外,關卡從第一幀就是輸的 |
還有兩條跑在參考解上:它的長度必須落在 draw.minimumLength 與 draw.maximumLength 之間,而且它的起點不得落在禁畫區裡。這幾條的共同點是:它們擋的不是「型別錯」,是「這關做不出來」。
反例也有測試守著。tests/unit/levelRepository.test.js:81-172 的 describe('validateLevel') 有 8 個案例,每一個都刻意弄壞一項再斷言它被抓到:狗跑到設計尺寸外、蜂巢跑到設計尺寸外、星等門檻順序顛倒、二星門檻超過畫線預算、參考解太長、參考解太短、參考解起點在禁畫區、傳進一個不是物件的東西。

以上是好消息。接下來換壞消息,也是本篇的主題。
validateAll() 定義在 levelRepository.js:244。全 repo 的呼叫點只有一個【實測:grep -rn "validateAll\|validateLevel" src/ tests/ scripts/】:
tests/unit/levelRepository.test.js:19。
執行期的路徑長這樣:src/levels/index.js:10 建立 repository,沒有驗證任何東西;src/main.js:84 直接 getLevelById(levelId) 把關卡拿去建場景。從瀏覽器載入到畫面出現,validateLevel() 一次都沒被呼叫。
這代表這個驗證器不是死程式碼,它保護的是另一道門。npm run test:unit 掛在 .github/workflows/deploy.yml 的 verify job 裡,跟 assets:validate、lint、build 排在一起。所以:一個壞掉的關卡進不了 main。 那是真的保護,而且是有效的保護:這次實跑 36 個測試檔、313 條測試全過【實測:npm run test:unit -- --run】。
但它保護的是 commit 這個動作,不是玩家載入遊戲這個動作。差別在什麼時候會咬人:
| 情境 | 建置期閘門管不管用 |
|---|---|
| 我改壞關卡、跑了 CI | ✅ 擋下 |
我改壞關卡、跳過測試直接 npm run build 部署 |
❌ 過 |
| 未來做關卡編輯器 / 遠端關卡 / 玩家自製關卡 | ❌ 完全幫不上忙 |
現在沒事,是因為關卡是靜態模組、跟程式碼一起編譯進去,不是因為有防護。
我要誠實標一件事:把驗證器接在載入期不是免費的,執行期多跑 22 個斷言、每次進關卡都跑一遍,換到的保護在「關卡是編譯進 bundle 的常數」這個前提下等於零。選建置期是合理的。但合理的選擇跟「有防護」是兩件事,中間那句話得有人寫出來。
schemaVersion:宣告了,沒人讀五個關卡檔都有 schemaVersion: 1(標記資料格式的版本)。全 repo 的讀取點是 零個【實測:grep -rn "schemaVersion" src/ tests/ scripts/ docs/,只命中那五行宣告】。它不在 REQUIRED_FIELDS 裡,validateLevel() 自己也不看它。
「版本號讓未來的遷移有依據」是願望,不是現況。照實講:我寫了一個成本一行、目前價值為零的欄位,它的價值要等到第一次改結構才會兌現。 留這個欄位是對的,但別把「留了欄位」當成「有版本控制能力」。
同一個 repo 裡有一個對照組,而且它誠實得多:src/core/ProgressStore.js:5-10 的註解自己寫明,版本 1 的存檔會被判定不符而整份重置,玩家進度直接消失,「只有在遊戲還沒上線、沒有真實玩家的前提下才可以接受」。一個版本號有人讀但只會重置,另一個連讀都沒人讀。兩個都是「先埋著」,成熟度差一級。 ProgressStore 那半邊留給 Day 25。
「有 schema」不等於「schema 蓋滿」。這個驗證器沒有檢查的欄位包括:schemaVersion、name、tutorial、logicalSize、background,以及蜂巢的數量與調校參數本身。forbiddenZones 也只有在「參考解的起點在不在裡面」這一件事上被用到,它的結構本身沒被驗過。
換句話說,我可以寫出一個 tutorial: { instruction: 123 } 的關卡,CI 全綠,遊戲跑起來才炸。
這條界線值得知道的原因是:驗證器的覆蓋範圍不是均勻擴散的,它會沿著「我當時在修哪個 bug」長。洞穴那一整組幾何檢查是在地形改版時一起長出來的(24a8d1c,2026-08-06 17:58,24 檔 +1,167 / -222),因為那次改動一次改寫了五個關卡,作者需要一個能當場判定「這個洞穴形狀合不合法」的東西。驗證器蓋到哪裡,反映的是曾經痛過哪裡。
這一段我沒有可以拿來當證據的 AI 協作紀錄——沒有留下「哪一版關卡資料被驗證器擋下、擋在第幾條」的原檔,所以我不會寫成「AI 產關卡、驗證器擋了幾次」。這是這個系列一路承認的同一個缺口。
能寫的是設計面的一個觀察。pushIssue()(levelRepository.js:20-22)把每一則錯誤都格式化成這樣:
level 4 (heavy-shield): scoring.twoStarMaximumLength must not exceed draw.maximumLength
前面是關卡 id 與 slug,後面是欄位名與被違反的關係。這個格式不是給人讀順的,是給下一輪提示詞用的:它把「哪一關、哪個欄位、違反什麼」壓成一行可以直接貼回去的字串,不需要人再翻譯一次。Day 2 講素材驗證器時是同一個結論——能讓 AI 自己修的錯誤訊息,長得像一筆結構化資料,不像一句抱怨。
至於「這關好不好玩」,那不在驗證器的能力範圍內,也不在我交給誰的範圍內。原因明天講。
一句話:
「可驗證」是一個能力,不是一個狀態。寫完驗證器只是有了能力;它保護哪一道門,取決於你把它接在哪裡。
三件今天就能做的事:
REQUIRED_FIELDS 之外,並在旁邊留一行註解說明為什麼。缺席的規則寫在腦袋裡,下一個人只能猜。明天 Day 22,把今天做出來的資料橫著攤開:五關的頸口寬度、畫線預算、蜂巢數與星級門檻並排比一次。要回答的具體問題是:難度到底是靠什麼爬上去的?我原本以為答案裡至少會有「蜜蜂變快一點」,實際上五關共用同一個凍結的調校物件,而且有一條測試的名字就叫 does not make bees faster to raise difficulty。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理