模組一|立案與選型(Day 1–4)
昨天講完這 30 天要交付什麼。今天回到開工前的那份文件。
先攤開事實:這個專案動工之前,有一份 2,419 行的 PRD.md,涵蓋技術選型、素材清單、物理參數、測試計畫、部署流程。一個人做的五關小遊戲,寫這麼長的規格,正常反應是「幹嘛」。我原本也這樣想。
結論先講:這份規格值錢的地方,不在它寫得多完整,在於它有一部分是機器讀得懂的。 讀得懂的那部分,變成了 CI 裡會失敗的檢查;讀不懂的那部分,就只是願望清單。這篇要用四個可以自己去 repo 對照的證據,把這件事講死。
給人看的規格,作用是「對齊理解」。人讀完之後會自己補上沒寫的東西。
給 AI 的規格不是這樣。你說「幫我畫一隻狗」,它每次畫出來的狗都不一樣——不是它笨,是這句話本身沒有任何可以被判定的東西。你沒辦法說第三隻比第二隻「錯」。
但如果規格長這樣:
viewBox 必須是 0 0 256 256
shadow、tail、body、head、ears、face、collar、tag、outline 這九個 <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.mdanddocs/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)時多了 SOIL 與 CAVE 兩個,現在是十七個——而驗證器一行都沒改。CAVE 還有個細節值得看:它的色碼 #FFF3DA 跟 MUZZLE 一模一樣,文件裡自己註明 (same value as MUZZLE)。命名的單位是用途,不是顏色,所以兩個同色不同用途的 Token 各自存在。
反過來也要誠實講:PRD.md 那張散文層的色票表還停在十五個,沒有人去同步它。這正好說明規格分兩層的代價——只有被機器讀的那一層會自動保持誠實,另一層照樣會過期。
順序可以查。以下時間都取自 git log:
| 時間 | commit | 發生什麼 |
|---|---|---|
| 03:07 | b4c2c62 |
專案第一個 commit |
| 03:36 | 1dd9971 |
validate-svg.js(當時 481 行)、art-style.md、asset-inventory.md 進 repo。public/assets/ 底下只有六個空目錄的 .gitkeep |
| 04:30 | dc5bed3 |
第一批六張 SVG 進來 |
| 04:48 | 78726e1 |
第一階段其餘素材進來 |
檢查器存在的時候,一張素材都還沒有。 這不是我事後才想到要補檢查,是先把不合格的定義寫出來,再開始生成。
順序反過來會怎樣,我沒有對照組,不編。但可以說一件確定的事:如果素材先進來,檢查器後寫,那我一定會為了讓現有素材通過而放寬規則——這是人性,不是紀律問題。先寫檢查器就沒有這個空間,因為那時候還沒有任何東西需要被通融。
asset-inventory.md 那張表有一欄叫 Stage,值是「第一階段」或「第二階段」。這一欄不是給人看的分類,它直接決定 CI 要不要擋(scripts/validate-svg.js):
npm run assets:validate 失敗,CI 紅future-phase-missing,CI 照過registered-asset
也就是說,這張表是雙向白名單:該有的沒有會被擋,不該有的多出來也會被擋。
而結果是這樣的——
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:
所以這篇不會出現「AI 讓開發快 20 倍」這種句子。那是拿一個沒有對照組的數字說故事。
反過來,這個落差有一個真正的用法:估時差一個數量級這件事,是因為有 PRD 才看得出來。 沒有那張估時表,我今天只會有一個模糊的「好像挺快的」印象,連「我估錯了」都無從指認。規格的第二個價值在這裡——它讓錯誤變得可以被定位。
PRD 有一節叫「明確排除項目」,內容很短:
MVP 不應投入後端帳號、排行榜、廣告 SDK、付費功能、每日任務、角色換裝、社群登入與關卡編輯器。這些功能不會改善最核心的「畫線後是否可靠地擋住蜜蜂」問題,反而會延後最重要的物理驗證。
寫「不做什麼」比寫「要做什麼」更能保護一個小專案,理由是:要做的事會被進度推著走,不做的事只會被靈感推著走,而靈感沒有截止日。
這一節有沒有守住,可以驗:我在 src/ 全庫掃過 leaderboard/login/purchase/每日任務這類字眼,零命中。
還有一個更細的例子。PRD 把音效寫成「Howler.js 可在後半段加入」,不是不做,是排序。實際上:音訊層的第一個 commit 在 15:45,音檔進來是 18:32——在那 34 個 commit 裡是倒數幾個。排序也是規格的一部分,而且它比「不做」更難守。
這個專案的素材幾乎都是我指揮 AI 生成的。過程不是「幫我畫一隻狗」,是這樣的循環:
asset-inventory.md 抓一列出來當工單(檔名、viewBox、允許色票、必須有的 group ID)npm run assets:validate 跑一次第 4 步是關鍵:驗證器的錯誤訊息,就是給 AI 的下一輪提示詞。 它不需要我用自然語言解釋「你的色碼不在色票裡」,它拿到的是 expected: listed in docs/asset-inventory.md, actual: unregistered file 這種可以直接對應到動作的東西。
這也是為什麼我說規格要寫成表格而不是散文——散文只能餵給模型當背景,表格可以餵給程式當判準。
現在的驗證器是 516 行、20 條規則加一條警告。它擋下過哪些具體的 AI 產出,我沒有留下紀錄(昨天講過,這是這個系列最尷尬的部分)。所以 Day 8 那篇會寫成「這道閘門在防什麼」,不會偽造「它擋下過什麼」。
一句話:
規格的品質,等於它能自動擋下多少爛結果。擋不下任何東西的規格,就只是願望清單。
具體到可以今天就做的事,三件:
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
如果你卡在語法
深入原理