模組二|工程底座與可驗收的 AI(Day 5–9)
《一條線救一隻狗》是我用 PixiJS 和 Matter.js 做的網頁小遊戲,畫面上三十幾張 SVG 素材全部交給 AI 生成。守著這些素材的是一支驗證器,20 條規則,每一條都是結構性的:XML 解不解得開、色碼在不在白名單、有沒有那幾個 <g id>、有沒有超出 viewBox。
它們有一個共同的天花板:全部都只看一個檔案。 而素材出問題的地方,往往不在單一檔案裡。
結論先講:自動化的目的不是消滅人工,是把人工壓縮到機器判斷不了的那幾件事上。而這個專案的現況是——視覺這一層完全靠人,一張基準圖都沒有。這是債,不是設計。

PRD.md 有一節叫「素材驗收檢查清單」,14 列。我把它跟現在實際存在的檢查器逐列對照:
| 檢查類型 | 現在有沒有機器在守 |
|---|---|
| XML 可解析 | ✅ valid-xml |
根節點 xmlns / viewBox / preserveAspectRatio |
✅ root-* 三條 |
安全性(內嵌 script 標籤、外部 URL、Base64) |
✅ 安全性六條 |
| 色票 | ✅ allowed-color |
| 節點(group ID 一致) | ✅ required-groups + semantic-groups |
| 邊界與安全邊距 | ✅ within-viewbox + safe-margin |
| 路徑(Base Path 下正確) | ✅ ESLint 擋 src/ 內硬寫 /assets/ |
| Lint、測試、Build | ✅ CI 的 verify job |
| 視覺中心(切換狀態不跳動) | ❌ 無 |
| 輪廓(同系列一致 stroke width) | ❌ 無 |
| 小尺寸辨識度 | ❌ 無 |
| 效能(節點數、冗餘路徑) | ❌ 無 |
| 展示頁截圖無異常 | ❌ 無 |
| PixiJS 可載入並建立 Sprite | △ e2e 有測,但 e2e 不在 CI |
十四列裡有五列到今天為止一個自動檢查都沒有,而那五列全是視覺判斷。
先聲明一件事,免得跟昨天混在一起:這張表的分母是 PRD.md 的驗收清單,跟昨天那組合約覆蓋率(17/18、2/2、2/7)不是同一件事,不要相加。這裡只是想指出那五列的共同形狀。
那五列的共同形狀是:單看每一張都合格,問題只在並排時才出現。 兩張圖的輪廓一個 8 單位一個 11 單位,分開看都在規範裡;並在一起就是兩個人畫的。一個圖示放大到螢幕一半很清楚,縮到 44px 是不是一團糊,只有縮下去才知道。
解法是一個專門的場景 src/scenes/AssetGalleryScene.js(202 行),用 ?scene=gallery 進入。它把 19 個素材按分類排成卡片牆,一張一張並在同一畫面上。
它是資料驅動的。分類定義在 src/config/assets.js:
export const ASSET_CATEGORIES = [
{ category: 'characters', label: 'Characters', aliases: ['dogIdle', 'dogScared', 'dogHappy', 'dogHit'] },
{ category: 'enemies', label: 'Enemies', aliases: ['bee', 'beeAngry'] },
{ category: 'environment', label: 'Environment', aliases: ['hive', 'hiveActive', 'platformGrass', 'cloud'] },
{ category: 'ui', label: 'UI', aliases: ['buttonPrimary', 'iconPlay', 'iconRetry', 'iconPause', 'starEmpty', 'starFilled', 'timerFrame', 'drawMeterFrame'] },
{ category: 'effects', label: 'Effects', aliases: ['drawPoint'] }
].map((category) => ({ ...category, assets: category.aliases.map((alias) => ASSET_ALIASES[alias]) }))
四加二加四加八加一,正好 19。而場景那邊只是照著它排版:
draw() {
let y = START_Y
for (const category of this.categories) {
const heading = new Text({ text: category.label, style: { /* ... */ } })
heading.position.set(START_X, y)
this.content.addChild(heading)
y += 52
let x = START_X
for (const asset of category.assets) {
this.addAssetCard(asset, x, y)
x += CARD_WIDTH + GAP
}
y += CARD_HEIGHT + 38
}
this.maxScrollY = Math.max(0, y - WORLD_HEIGHT + START_Y)
this.applyScroll()
}
CLAUDE.md 把這個性質寫成一句話:「新增素材=放檔案 + 在 assets.js 補一行;AssetGalleryScene 會自動列出,不需另外註冊。」人工閘門要能一直被用,前提是它的維護成本接近零。 一個要手動同步的素材牆,第三次就會忘記更新。
這裡有一個但書要講在同一段,不留到後面。卡片上的縮放是這樣算的【實查:AssetGalleryScene.js:107-111】:
Math.min(maxSpriteWidth / displaySize.width, 92 / displaySize.height, 1.4)
最後那個 1.4 是上限。也就是說,牆上的圖不是 1:1,小圖示會被放大到 1.4 倍。風格差異在牆上看得出來,真實的 44px 辨識度在牆上看不準。 上一節那張表裡「小尺寸辨識度」還是 ❌,素材牆沒有解決它。
CLAUDE.md 另外明寫「AssetGalleryScene 是素材驗收工具⋯⋯不要把 gallery 當成最終首頁」。它是額外工作,玩家永遠不會看到它。
素材牆能看出差異,但「以什麼為基準」得先有答案。這個專案的答案是母版制,而它不是口號——驗證器裡有兩個常數【實查:scripts/validate-svg.js:9-37】:
MASTER_ASSET_FILES:6 個母版(dog-idle、bee、hive、platform-grass、button-primary、star-filled)FIRST_STAGE_ASSET_FILES:19 個第一階段素材母版被單獨列出來的意義,寫在 isRequiredForPhase() 裡:ASSET_PHASE=masters 的時候,驗證器只要求那 6 個存在,其他一律放行。也就是說,「先把母版做完並核准,再開始做變體」這件事不是口頭約定,是一個可以在指令列切換的階段。
同一組清單存在兩個地方——src/config/assets.js 也有一份 MASTER_ASSET_ALIASES 與 FIRST_STAGE_ASSET_ALIASES。兩份會不會漂掉?昨天提過的那兩個測試把它們釘住了:tests/unit/validate-svg.test.js 有兩個 it() 把 6 個與 19 個檔名逐字寫死在斷言裡。
母版核准是這條產線上唯一不可略過的人工閘門。 理由很直接:母版錯了,後面十幾張全部要重做。這裡省下的十分鐘,會在後面變成三小時。
「風格一致」整件事沒辦法自動檢查,但它有一角可以。看素材清單裡狗的四個狀態【實查:docs/asset-inventory.md:7-10】:
| 檔案 | 語意分組 |
|---|---|
dog-idle.svg |
shadow, tail, body, head, ears, face, collar, tag, outline |
dog-scared.svg |
上述九個 + sweat |
dog-happy.svg |
上述九個 + joy-marks, tongue |
dog-hit.svg |
上述九個 + dizzy-stars |
共同的九個分組,四個狀態一個都沒少,而且只增不減。 這件事是 required-groups 在守的——每個檔案缺哪個 group,驗證器會逐個點名。
這是我認為 Day 7 那三層約束裡最反直覺的一層:要求 AI 保留語意分組,表面理由是「好編輯」,真正的理由是它讓「同一角色的不同狀態長得像不像」這件事,有一部分變成了字串比對。剩下的部分(那九個分組畫得像不像)還是得靠眼睛,但至少「少畫了一塊」不會混過去。

把整條產線攤開,四個階段,各自誰把關:
| 階段 | 誰把關 | 通過條件 | 在 CI 嗎 |
|---|---|---|---|
| 生成後 | 驗證器 | 結構、色票、安全性、登記一致 | ✅ |
| 母版核准 | 人工,不可略過 | 這是後續所有變體的唯一造型依據 | ❌(人工) |
| 批次產出後 | 素材牆人工掃視 | 一致性、狀態切換跳動 | ❌(人工) |
| 整合後 | e2e | 只釘清單完整性與「畫面不是一片單色」 | ❌ e2e 不在 CI |
最後一列要展開講,因為它是這篇最難堪的一格。
tests/e2e/smoke.spec.js 確實有測素材牆,但它斷言的是 expect(debug.gallery.aliases).toEqual(firstStageAliases)——19 個 alias 一個不少,跟長什麼樣完全無關。整份 e2e 唯一用到截圖的地方是把畫布的一小塊截下來、數不同的 RGB 值,斷言 > 3,也就是「畫面不是一片單色」【實查:smoke.spec.js:109-117】。煙霧級,不是基準比對。
PRD.md 的分批生成策略表裡,最後兩個批次各有一個人工關卡:
| 批次 | PRD 寫的人工關卡 | 現況 |
|---|---|---|
| 整合批次 | Playwright 截圖基準 | ❌ 四個 e2e spec 全檔沒有 toHaveScreenshot,playwright.config.js 也沒有 snapshot 設定【實測:grep -rn "toHaveScreenshot|snapshot" tests/e2e/ playwright.config.js】 |
| 最佳化批次 | 確認語意 ID 未被移除 | ❌ repo 內沒有任何 SVG 最佳化工具,package.json 七個 script 都不是 |
所以請不要從我這裡讀到「CI 會幫你標出視覺差異」。沒有基準圖,而且就算有,e2e 也不在 verify job 裡跑。
還有兩件事要說清楚:
第一,中間版本的截圖沒有留存。 我沒辦法給你「風格漂移被抓到,然後修正」的前後對照——只剩下 19 個通過驗收的成品。所以這篇的論點不是「漂移被擋下」,是「母版制與語意分組讓一致性變成一部分可檢查的東西」。同樣地,我不會寫「我在牆上看出第幾張不對勁」,那種事沒有紀錄就等於沒發生過。Day 8 講過 docs/rejected/ 是空的,這裡是同一筆帳的另一面。
第二,PRD.md 裡有一張「常見生成錯誤與修正」表,12 列,寫得很像事後檢討,但它不是。 那份 PRD 的最後一次修改在 2026-08-06 03:36,第一張 SVG 進 repo 是 04:30【實測:git log】。那是一份事前的預測清單,不是事後的紀錄。 它預測得準不準,我一樣沒有證據可以回答。
一句話:
人工閘門要少、要準、要不可略過。而你必須誠實面對一件事——沒有基準圖的視覺審查,人一旦不在,這道閘門就不存在了。
具體到可以今天就做的事,三件:
明天 Day 10 進入畫線那條主線,先從最底層開始:遊戲迴圈到底是誰在驅動誰。 Pixi 有 Ticker、Matter 有 Runner,讓兩套迴圈各跑各的會發生什麼;為什麼物理必須用固定時間步長,以及當一幀算太久、累加器追不上的時候,會怎麼掉進那個越算越慢、越慢越要算的死亡螺旋。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑與行號自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理