iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
JavaScript

一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲系列 第 2

Day 2|開工前先寫兩千行規格:不是儀式,是給 AI 一份可以違反的合約

  • 分享至 

  • xImage
  •  

模組一|立案與選型(Day 1–4)

昨天講完這 30 天要交付什麼。今天回到開工前的那份文件。

先攤開事實:這個專案動工之前,有一份 2,419 行的 PRD.md,涵蓋技術選型、素材清單、物理參數、測試計畫、部署流程。一個人做的五關小遊戲,寫這麼長的規格,正常反應是「幹嘛」。我原本也這樣想。

結論先講:這份規格值錢的地方,不在它寫得多完整,在於它有一部分是機器讀得懂的。 讀得懂的那部分,變成了 CI 裡會失敗的檢查;讀不懂的那部分,就只是願望清單。這篇要用四個可以自己去 repo 對照的證據,把這件事講死。


規格對 AI 的意義,跟對人不一樣

給人看的規格,作用是「對齊理解」。人讀完之後會自己補上沒寫的東西。

給 AI 的規格不是這樣。你說「幫我畫一隻狗」,它每次畫出來的狗都不一樣——不是它笨,是這句話本身沒有任何可以被判定的東西。你沒辦法說第三隻比第二隻「錯」。

但如果規格長這樣:

  • viewBox 必須是 0 0 256 256
  • 色碼只能出現在色票表登記的那幾個 Token 裡(開工時十五個)
  • 必須有 shadowtailbodyheadearsfacecollartagoutline 這九個 <g id="...">

那第三隻狗就有可能是錯的,而且錯在哪裡可以指出來。

差別不在規格的長度,在於規格有沒有一個可以被程式解析的目標。這句話會在 Day 8 展開、Day 29 收尾,今天先看它在第一天長什麼樣。


證據一:規格檔本身就是驗證器的輸入

這個專案的 PRD.md 裡有一段話,我當時寫下去沒特別在意,現在回頭看是整份文件最重要的一句:

Source of truth: this PRD describes the product and implementation intent. The machine-readable palette and asset inventory used by automated validation live in docs/art-style.md and docs/asset-inventory.md.

翻成白話:PRD 講意圖,docs/ 底下那兩份講合約。 規格被切成兩層,一層給人讀,一層給程式讀。

給程式讀的那層長這樣(docs/asset-inventory.md,開頭就寫明它會被誰解析):

This table is parsed by `scripts/validate-svg.js`. Keep paths relative to
`public/assets/`, group IDs comma-separated, and stage labels as `第一階段` or `第二階段`.

| Path | ViewBox | Usage | Display Size | Tokens | Groups | Stage |
| --- | --- | --- | --- | --- | --- | --- |
| `characters/dog-idle.svg` | `0 0 256 256` | Dog idle state | 112 x 112 | DOG, MUZZLE, COLLAR, OL | `shadow`, `tail`, `body`, `head`, `ears`, `face`, `collar`, `tag`, `outline` | 第一階段 |

它就是一份 Markdown 表格,你可以在 GitHub 上直接讀。而驗證器那邊,是真的拿正規表示式去把它拆開(scripts/validate-svg.js):

const inventoryPath = join(rootDir, 'docs/asset-inventory.md')
const artStylePath = join(rootDir, 'docs/art-style.md')

export function loadPalette(markdown = readFileSync(artStylePath, 'utf8')) {
  const palette = new Map()
  const tableRowPattern = /^\|\s*`?([A-Z]+)`?\s*\|\s*`?(#[0-9A-Fa-f]{3,6})`?/gm

  for (const match of markdown.matchAll(tableRowPattern)) {
    palette.set(match[1], normalizeColor(match[2]))
  }

  return palette
}

色票 Token 不是抄進程式碼裡的常數,是每次執行都重新從那份文件讀出來。改色票只改文件,檢查跟著變;文件跟檢查沒有機會不同步,因為它們是同一份東西。

這件事在我寫這篇的時候剛好被驗證了一次。開工時 art-style.md 是十五個 Token,後來把關卡地形改成洞穴(commit 24a8d1c,08-06 17:58)時多了 SOILCAVE 兩個,現在是十七個——而驗證器一行都沒改。CAVE 還有個細節值得看:它的色碼 #FFF3DAMUZZLE 一模一樣,文件裡自己註明 (same value as MUZZLE)命名的單位是用途,不是顏色,所以兩個同色不同用途的 Token 各自存在。

反過來也要誠實講:PRD.md 那張散文層的色票表還停在十五個,沒有人去同步它。這正好說明規格分兩層的代價——只有被機器讀的那一層會自動保持誠實,另一層照樣會過期。


證據二:閘門比第一張素材早了 54 分鐘

順序可以查。以下時間都取自 git log

時間 commit 發生什麼
03:07 b4c2c62 專案第一個 commit
03:36 1dd9971 validate-svg.js(當時 481 行)、art-style.mdasset-inventory.md 進 repo。public/assets/ 底下只有六個空目錄的 .gitkeep
04:30 dc5bed3 第一批六張 SVG 進來
04:48 78726e1 第一階段其餘素材進來

檢查器存在的時候,一張素材都還沒有。 這不是我事後才想到要補檢查,是先把不合格的定義寫出來,再開始生成。

順序反過來會怎樣,我沒有對照組,不編。但可以說一件確定的事:如果素材先進來,檢查器後寫,那我一定會為了讓現有素材通過而放寬規則——這是人性,不是紀律問題。先寫檢查器就沒有這個空間,因為那時候還沒有任何東西需要被通融。


證據三:規格表格的最後一欄,是 CI 的開關

asset-inventory.md 那張表有一欄叫 Stage,值是「第一階段」或「第二階段」。這一欄不是給人看的分類,它直接決定 CI 要不要擋(scripts/validate-svg.js):

  • 第一階段的檔案缺席 → violation,npm run assets:validate 失敗,CI 紅
  • 第二階段的檔案缺席 → warning,future-phase-missing,CI 照過
  • 出現沒登記在表裡的 SVG → violation,規則名稱 registered-asset

也就是說,這張表是雙向白名單:該有的沒有會被擋,不該有的多出來也會被擋。

而結果是這樣的——

  • 素材清單表總共列了 38 個檔案
  • 標記為第一階段的有 19 個
  • public/assets/ 底下實際存在的 SVG 也是 19 個

我把兩邊的檔名各自排序後逐行比對,完全相同,一個不多一個不少:

# 從 PRD 素材清單抽出第一階段的檔名,與實際存在的 SVG 逐行比對
diff <(第一階段檔名清單) <(cd public/assets && find . -name '*.svg' | sed 's|^\./||' | sort)
# 無輸出 = 完全一致

範圍沒有蔓延,也沒有偷工,因為**「做到哪裡」這件事被寫成了資料,而不是靠記性**。第二階段那 19 個檔案不是被遺忘,是被登記為「現在不做,缺席不算錯」。

這是我認為 Day 4 要談的「範圍怎麼畫」最具體的一個版本:範圍不是寫在 Notion 裡的一段話,是 CI 判定紅綠的那一欄。


證據四:規格關掉了一條我沒走過的路

前面三個講的是規格擋下了什麼。這一個講規格讓什麼沒有發生

畫線系統的驗收條件裡有這麼一句(openspec/changes/archive/2026-08-05-add-drawing-system/specs/drawing-system/spec.md):

Valid finalized strokes MUST create one rigid Matter compound body composed of chamfered rectangle segments. The line MUST be added to PhysicsManager; it MUST NOT be modeled as a flexible constraint chain in the MVP.

「防線不可以做成柔性約束鏈。」

這條路我一行程式碼都沒寫過。不是寫了發現不行才改,是驗收條件在實作之前就把它排除了。PRD 的風險表裡也有對應的一列:「防護線約束震盪或爆開,機率高、影響高,對策:MVP 採單一複合剛體,柔性鏈條延後」。

這裡我要誠實標一件事:「如果做成柔性鏈條會爆開」目前是推論,不是實測。 我沒有做過那個版本,所以我不能說「我試過,它會抖」。我能說的是:Matter.js 的 Constraint 本來就是用 stiffness 維持距離的彈性連結,把一條線拆成幾十個互相拉扯的約束,數值穩定度風險是可預期的——所以我選擇不去驗證它,而是把它寫成禁止項。這件事 Day 15 會完整講,包括「躲開一種不穩定之後,換到了哪一種不穩定」。

規格真正的價值不是它一開始就對,是它讓某些路在被走進去之前就關掉。 走進去再退出來,成本是幾天;寫一句 MUST NOT,成本是一行。


那個很誘人、但我不打算拿來說故事的數字

PRD 的 MVP 交付範圍表裡有估時,六個工作群組加起來 240 小時,規劃六週開工完成。

實際的 commit 落在:

項目
第一個 commit 2026-08-06 03:07:27 (+0800)
五關可玩的最後一個當日 commit 2026-08-06 18:40:18
這中間的跨度 15 小時 33 分
我寫這篇時的最新 commit 2026-08-07 12:35:40,累計 34 個 commit(first-parent),首末跨度 33 小時 28 分

差了一個數量級。這種數字很好賣,所以我要把限制寫在同一段裡,不留到 Day 30:

  1. 跨度不是工時。 那 15 小時 33 分是首末提交的時間差,中間離開電腦的時間全部算在裡面。實際投入人時沒有任何紀錄,我不會去推估一個聽起來合理的數字。
  2. 做出來的是五關 MVP,不是產品。 PRD 的 240 小時包含跨瀏覽器測試、效能分析、視覺回歸、兩台行動實機驗收——這些到現在都還沒做完。拿「做完一半」的時間去比「全部做完」的估時,本來就不公平。
  3. 我全程在場,每一個決定都是我下的。 沒有一段是我丟給 AI 之後去做別的事。
  4. 沒有對照組。 我沒有再做一次不用 AI 的版本。

所以這篇不會出現「AI 讓開發快 20 倍」這種句子。那是拿一個沒有對照組的數字說故事。

反過來,這個落差有一個真正的用法:估時差一個數量級這件事,是因為有 PRD 才看得出來。 沒有那張估時表,我今天只會有一個模糊的「好像挺快的」印象,連「我估錯了」都無從指認。規格的第二個價值在這裡——它讓錯誤變得可以被定位。


不做清單:最被低估的一節

PRD 有一節叫「明確排除項目」,內容很短:

MVP 不應投入後端帳號、排行榜、廣告 SDK、付費功能、每日任務、角色換裝、社群登入與關卡編輯器。這些功能不會改善最核心的「畫線後是否可靠地擋住蜜蜂」問題,反而會延後最重要的物理驗證。

寫「不做什麼」比寫「要做什麼」更能保護一個小專案,理由是:要做的事會被進度推著走,不做的事只會被靈感推著走,而靈感沒有截止日。

這一節有沒有守住,可以驗:我在 src/ 全庫掃過 leaderboardloginpurchase/每日任務這類字眼,零命中

還有一個更細的例子。PRD 把音效寫成「Howler.js 可在後半段加入」,不是不做,是排序。實際上:音訊層的第一個 commit 在 15:45,音檔進來是 18:32——在那 34 個 commit 裡是倒數幾個。排序也是規格的一部分,而且它比「不做」更難守。


交給 AI:這份規格實際上是誰在用

這個專案的素材幾乎都是我指揮 AI 生成的。過程不是「幫我畫一隻狗」,是這樣的循環:

  1. asset-inventory.md 抓一列出來當工單(檔名、viewBox、允許色票、必須有的 group ID)
  2. AI 產出 SVG
  3. npm run assets:validate 跑一次
  4. 有 violation 就把驗證器的輸出原封不動貼回去,讓它自己修

第 4 步是關鍵:驗證器的錯誤訊息,就是給 AI 的下一輪提示詞。 它不需要我用自然語言解釋「你的色碼不在色票裡」,它拿到的是 expected: listed in docs/asset-inventory.md, actual: unregistered file 這種可以直接對應到動作的東西。

這也是為什麼我說規格要寫成表格而不是散文——散文只能餵給模型當背景,表格可以餵給程式當判準。

現在的驗證器是 516 行、20 條規則加一條警告。它擋下過哪些具體的 AI 產出,我沒有留下紀錄(昨天講過,這是這個系列最尷尬的部分)。所以 Day 8 那篇會寫成「這道閘門在防什麼」,不會偽造「它擋下過什麼」。


帶走什麼

一句話:

規格的品質,等於它能自動擋下多少爛結果。擋不下任何東西的規格,就只是願望清單。

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

  1. 把規格切兩層。 意圖寫在散文裡,判準寫成表格,並在表格開頭寫明「這張表會被誰解析」。
  2. 檢查器先於產出。 素材、程式碼都一樣。等東西進來再補檢查,你會為了讓現有的東西通過而放寬規則。
  3. 把「現在不做」寫成資料而不是共識。 這個專案是把它寫進表格的 Stage 欄,讓 CI 知道缺哪些檔案該紅、缺哪些該放行。

明天 Day 3,講技術選型裡最容易誤會的一件事:PixiJS 不是遊戲引擎,是渲染器。搞錯這件事的人會去 Google「PixiJS 碰撞偵測怎麼寫」然後懷疑人生——順便講為什麼這個專案還需要 Matter.js,以及中間那層負責對齊兩套座標的東西是怎麼冒出來的。


本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit 5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑自行對照。
可玩網址https://save-the-dog-web.vercel.app/原始碼https://github.com/HarryFan/save-the-dog-web

參考資料

如果你卡在語法

深入原理


上一篇
Day 1|一條線、一隻狗、十秒鐘:我要用 30 天講什麼
下一篇
Day 3|PixiJS 是渲染器,不是遊戲引擎:它不做的每一件事都會變成你的檔案
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言