模組二|工程底座與可驗收的 AI(Day 5–9)
《一條線救一隻狗》是我用 PixiJS 和 Matter.js 做的一款網頁小遊戲:玩家畫一條線,讓狗安全落地。畫面上的三十幾張 SVG 素材,全部分成好幾批交給 AI 生成。本篇談的就是這件事——怎麼讓分批生出來的圖,看起來像同一個人畫的。
先講清楚我不會寫什麼。這個專案沒有留下風格漂移的中間版本,所以我不會給你「你看,第一版歪成這樣,加了約束之後就對了」的前後對照圖——那些檔案已經流失,我不編。本篇講的是約束本身怎麼設計,證據是規格檔、驗證器與素材檔的現況,不是失敗紀錄。
結論先講:風格一致性不是用形容詞達成的,是靠三層具名約束達成的——顏色有名字、結構有數字、圖層有語意 ID。凡是無法被機器指名的風格要求,都會漂移。
PRD.md 有一節叫〈常見生成錯誤與修正〉,十二列,第一列是:
| 錯誤 | 原因 | 修正 Prompt |
|---|---|---|
| 每張狗狗長得不同 | 模型只看到文字描述,沒有母版約束 | 「以 dog-idle.svg 為唯一造型母版,只允許變更列出的節點」 |
這裡要標一件事:這張表是在生成任何一張素材之前寫的。 它在專案第一個 commit(b4c2c62,08-06 03:07,當時檔名還是 deep-research-report.md,03:12 才更名為 PRD.md)就存在,而第一批 SVG 進 repo 是 04:30 的 dc5bed3。所以它不是我的踩坑記錄,是我開工前抄下來的預期清單。真假由讀者自己判斷——我能保證的只有「這張表存在的時間早於素材」這件可以用 git log 查的事。
至於為什麼「幫我畫一隻狗」注定不穩定,原因 Day 2 說過一次:那句話裡沒有任何可以被判定的東西,所以你沒辦法說第三隻比第二隻「錯」。今天要處理的是它的反面——要怎麼寫,才能讓「錯」變成一個有位址的字。

docs/art-style.md 的色票表現在有 17 個 Token:
| Token | Color | Usage |
| --- | --- | --- |
| OL | #3E3440 | Main outlines, bee dark details, UI strokes |
| DOG | #F6B84A | Dog fur |
| MUZZLE | #FFF3DA | Dog muzzle and chest |
| COLLAR | #3A8DDE | Dog collar |
| BEE | #FFD84D | Bee body and stars |
| HIVE | #C98035 | Hive and wood elements |
| SKY | #8ED8F8 | Sky and light wings |
| GRASS | #7AC943 | Grass and bushes |
| SUCCESS | #45C486 | Success UI |
| DANGER | #F45B69 | Failure and hazard UI |
| UI | #30334A | Dark interface elements |
| WHITE | #FFFDF6 | Highlights and light UI |
| SOIL | #7B4A2F | Cavern walls, pillars and floor |
| CAVE | #FFF3DA | Lit cavern interior (same value as MUZZLE) |
| STONE | #8B91A1 | Stone and spikes |
(省略最後兩列 WATER、LAVA。這張表會被 scripts/validate-svg.js 直接解析,機制 Day 2 講過,今天只用它的內容。)
CAVE 那一列是我認為整張表最值得講的一列:它的色碼跟 MUZZLE 一模一樣,都是 #FFF3DA,而它們是兩個不同的 Token。
如果命名的單位是顏色,這兩個應該合併——同一個色碼寫兩次是重複。但命名的單位是用途:狗狗的嘴鼻與洞穴的內壁現在剛好一樣亮,不代表以後也要一樣。有兩個名字,你才有機會只改其中一個;只有一個名字,任何調整都會波及到不相干的地方。
這張表也不是一次寫完的。開工時是 15 個 Token;08-06 17:58 的 24a8d1c 把地形從開闊平地改成洞穴,同一個 commit 加了 SOIL 與 CAVE 兩個【實測:git log -S'SOIL' -- docs/art-style.md】。新增顏色的動作被強制發生在色票檔裡,而不是發生在某一張 SVG 裡——這就是把顏色收攏成 Token 表換到的東西。
第二層管的是「畫布長怎樣」,特徵是每一條都能寫成斷言:
| 項目 | 規定 |
|---|---|
| 根節點 | 必須有 xmlns、指定的 viewBox、preserveAspectRatio="xMidYMid meet" |
| viewBox 家族 | 角色/道具 0 0 256 256、蜜蜂/圖示 0 0 128 128、平台/危險物 0 0 512 128、按鈕 0 0 320 96 |
| 主輪廓寬度 | 128 單位 5–7、256 單位 8–12、320×96 按鈕 6–10、512×128 平台 8–14 |
| 安全邊距 | 所有可見圖形距 viewBox 邊界至少 6% |
| 文字 | 按鈕 SVG 只放背景與陰影,文字由 PixiJS Text 疊上去 |
CLAUDE.md:130 還多寫了一條「同角色不同狀態必須同尺寸同構圖」。這條特別重要,因為它防的是一個很難用眼睛抓到的 bug:角色切換狀態時會跳一下。docs/asset-inventory.md 裡 dog-idle 的顯示尺寸是 112 × 112、dog-happy 是 120 × 120——尺寸可以不同,但 viewBox 都是 0 0 256 256,而 PRD 給的修正提示詞寫得更死:「保持頭部中心、腳底與陰影座標完全一致」。
最後一列是我後來覺得最划算的一條。按鈕 SVG 裡不放文字,代表按鈕素材可以重複用在不同語言、不同文案上,也代表 AI 不用去處理字型——而字型正是它最容易偷懶引用外部 URL 的地方(昨天那條 Never 列的十項之一)。

這一層最反直覺。
docs/asset-inventory.md 每一列的倒數第二欄是 Groups,列出這個檔案必須包含哪些 <g id="...">。狗狗那四列長這樣:
| 檔案 | 必須有的 group |
|---|---|
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 |
直覺的理由是「分組比較好手動改」。那是附加價值。真正的理由是:共同的九個 group 一旦被寫成清單,「這四張圖是同一隻狗的四個狀態」就從一句形容詞,變成一條可以執行的檢查。
驗證器可以逐檔問「這九個 id 在不在」,答案是布林值。如果 AI 為了畫出開心的表情,把 face 和 tongue 合成一個 face-happy,它畫得再好看都會被擋——因為它破壞的不是美感,是這批素材之間的對應關係。
反過來說,狀態專用的 group(sweat、tongue、dizzy-stars)是允許新增的。規則的形狀是「共同的不准少,專用的可以多」,這讓變體有空間,同時鎖住骨架。
scripts/validate-svg.js:9-16 有一個具名常數:
MASTER_ASSET_FILES = dog-idle.svg、bee.svg、hive.svg、platform-grass.svg、button-primary.svg、star-filled.svg——六個母版。第一階段總共交付 19 個 SVG,其中 6 個是母版。
PRD 的〈分批生成策略〉把生成拆成八個批次,母版批次那一列的「人工關卡」欄寫著四個字:必須人工審查後才繼續。
| 批次 | Codex 任務 | 人工關卡 |
|---|---|---|
| 規範批次 | 建立合約、色票、驗證器、素材清單 | 確認色票與角色設定 |
| 母版批次 | 六個母版 SVG | 必須人工審查後才繼續 |
| 狀態批次 | 狗狗與蜜蜂的狀態變體 | 比對中心點、尺寸與輪廓 |
| 場景批次 | 平台、石頭、木箱、雲、樹 | 檢查縮放與平鋪 |
| UI 批次 | 其餘按鈕、圖示、計時器 | 檢查 44px 小尺寸辨識度 |
母版制的邏輯是:把「風格」這個沒辦法用文字描述完整的東西,換成一個檔案路徑。 之後每一輪的提示詞不再說「卡通風格、線條圓潤、色彩明亮」,而是說「以 dog-idle.svg 為唯一造型母版,只允許變更列出的節點」。模型不需要理解你的審美,只需要對齊一個它讀得到的檔案。
代價是母版本身沒有母版可以對齊,所以它必須是人工把關的那一關。這是我在整條產線上刻意保留的人工閘門之一,Day 9 會講它為什麼放在這裡、而不是放在更後面。
三層約束加母版,合起來就是一張可以貼給 AI 的工單。PRD 的〈Codex Prompt 範本〉是這樣寫的(節錄「設計契約」與「執行步驟」兩段):
設計契約:
- 所有檔案使用 `viewBox="0 0 256 256"`
- 保持 dog-idle.svg 的角色比例、頭部位置、輪廓粗細與色票
- 角色視覺中心保持在同一位置
- 只改變表情、耳朵、尾巴與少量姿勢
- 每個 SVG 包含以下 group ID:
shadow、tail、body、head、ears、face、collar、tag、outline
- 可增加狀態專用 group,但不可刪除共同 group
- 不使用 script、foreignObject、image、Base64、filter、mask 或文字
- 不使用 AGENTS.md 未允許的色碼
- 圖形距離 viewBox 邊界至少 16 個 user units
- 不要修改 dog-idle.svg
執行步驟:
1. 檢查既有 SVG 與風格文件。
2. 先說明你觀察到的共同造型規則。
3. 建立三個完整 SVG。
4. 將素材加入 src/config/assets.js。
5. 將素材加入 AssetGalleryScene。
6. 執行 npm run assets:validate。
(7–10 略:lint、單元測試、build,任一失敗就修到過)
整張工單分成六段:先讀哪些檔、這次要建立什麼、設計契約、執行步驟、驗收要求、回報格式。值得指出的是第 2 步——要求它先講出它觀察到的共同造型規則,再開始畫。這一步不產出任何檔案,但它讓你在最便宜的時間點看到它理解錯了沒有。
還有一個細節:「圖形距離 viewBox 邊界至少 16 個 user units」。合約寫的是「至少 6% 安全邊距」,而 256 的 6% 是 15.36。同一條規則,合約留百分比、工單給絕對值。 合約要同時管四種 viewBox,所以只能寫比例;工單這一批只有 0 0 256 256 一種,那就把算術先做完再交出去——少一個需要模型自己換算的步驟,就少一個它算錯的機會。
最後這一節是誠實補充,講一個我設計了、但沒有真的執行的東西。
規格要求素材分兩份存放:assets-source/ 放可編輯的來源版,public/assets/ 放經過驗證與最佳化的執行版。理由寫在 PRD 的錯誤表最後一列:「最佳化後不能編輯|最佳化工具刪除 ID|原始版與執行版分離,設定保留 ID」——SVGO 這類工具預設會把它認為沒用的 id 屬性拿掉,而那些 id 正是上一節整套檢查的依據。
目錄確實有兩份,各 19 個 SVG。但我把它們逐檔做位元組比對:
for f in $(cd assets-source && find . -name '*.svg' | sed 's|^\./||'); do
cmp -s "assets-source/$f" "public/assets/$f" || echo "DIFF $f"
done
【實測】19 個檔案全部相同,零個有差異,repo 裡也沒有任何 SVGO 設定檔。PRD 的第八個批次「最佳化批次」從來沒有跑過。
所以現況是:分離的目錄結構在那裡,分離的理由還沒發生。 這不是壞事——它是一個成本很低的預留位——但如果我不說,你從目錄結構會推論出一條不存在的產線。
有一件事倒是真的在守著:tests/unit/assets-config.test.js:70 與 :80 兩個測試,逐檔比對兩邊的 viewBox 與 group ID 集合必須完全相同。這個測試在 CI 裡,所以哪一天最佳化真的跑起來、而它把 id 刪掉了,建置會紅。閘門先架好了,等的是那條產線。
風格一致性不是形容詞,是三個可以被指名的東西:顏色的名字、結構的數字、圖層的 ID。凡是這三層都沒接住的風格要求,都會在第三批素材上開始漂移。
三件今天就能做的事:
明天 Day 8 進到這個模組的核心:前面兩天講的都是「合約」,而合約有一個致命弱點——它沒有辦法讓不合格的東西進不了 repo。真正的閘門是那支 516 行、20 條規則的 scripts/validate-svg.js。明天要一條一條看它在檢查什麼、每條規則各自在防哪一種偷懶,以及昨天賣的那個關子:Never 那十項裡,為什麼有一項天生就檢查不了。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑與指令自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理