iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0

模組二|工程底座與可驗收的 AI(Day 5–9)

《一條線救一隻狗》是我用 PixiJS 和 Matter.js 做的網頁小遊戲,畫面上三十幾張 SVG 素材全部交給 AI 生成。守著這些素材的是一支驗證器,20 條規則,每一條都是結構性的:XML 解不解得開、色碼在不在白名單、有沒有那幾個 <g id>、有沒有超出 viewBox。

它們有一個共同的天花板:全部都只看一個檔案。 而素材出問題的地方,往往不在單一檔案裡。

結論先講:自動化的目的不是消滅人工,是把人工壓縮到機器判斷不了的那幾件事上。而這個專案的現況是——視覺這一層完全靠人,一張基準圖都沒有。這是債,不是設計。


機器擋不掉的那幾列,PRD 事前就列出來了

一張一張看都沒問題,全部夾上同一條繩子才看得出哪一件不一樣

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-idlebeehiveplatform-grassbutton-primarystar-filled
  • FIRST_STAGE_ASSET_FILES:19 個第一階段素材

母版被單獨列出來的意義,寫在 isRequiredForPhase() 裡:ASSET_PHASE=masters 的時候,驗證器只要求那 6 個存在,其他一律放行。也就是說,「先把母版做完並核准,再開始做變體」這件事不是口頭約定,是一個可以在指令列切換的階段。

同一組清單存在兩個地方——src/config/assets.js 也有一份 MASTER_ASSET_ALIASESFIRST_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 全檔沒有 toHaveScreenshotplaywright.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】。那是一份事前的預測清單,不是事後的紀錄。 它預測得準不準,我一樣沒有證據可以回答。


帶走什麼

一句話:

人工閘門要少、要準、要不可略過。而你必須誠實面對一件事——沒有基準圖的視覺審查,人一旦不在,這道閘門就不存在了。

具體到可以今天就做的事,三件:

  1. 做一個「並排看」的視圖,並且讓它零維護。 加一個素材=補一行設定,畫面自動長出來。需要手動同步的檢視工具,第三次就會忘記更新。
  2. 把「不可略過的人工關卡」壓到只剩一個。 這個專案是母版核准,理由是它的錯誤會被複製到所有變體上。其他階段的人工都可以是抽查。
  3. 檢視工具的失真要寫在工具旁邊。 素材牆有 1.4 倍上限,所以它看得出風格、看不準小尺寸——不寫下來,下次就會拿它當作沒問題的證明。

明天 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

參考資料

如果你卡在語法

深入原理


上一篇
Day 8|驗證器才是真正的閘門:一支會讓 CI 失敗的腳本,比一百句提示詞有效
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言