昨天談的是企劃文件包本身,今天想往下一層,談這些文件在實際開發裡怎麼被「用」
包含專案的入口文件、跟開發直接掛鉤的管理流程,以及素材怎麼從一堆散圖變成可以被程式讀取的資源。
規劃大綱裡寫的「README / CHARACTER / WORLD / ENDING / STORY MAP」是概念上的分類
README → 根目錄 README.md,不是企劃文件,是給任何人(含 AI 代理)30 秒搞懂專案結構的入口
CHARACTER → docs/CHARACTERS.md
WORLD → 世界觀跟角色/章節/結局全部收在同一份 docs/GAME_STORY_BIBLE.md(劇情企劃書),是最上層的一份
ENDING → docs/ENDING_CONDITIONS.md
STORY MAP → 逐場景的章節流程寫在 docs/CHAPTER_FLOW.md
README.md:架構定位——目錄樹長什麼樣、每個資料夾裝什麼、指令怎麼跑,回答「東西放在哪裡」
AGENTS.md:規則本體——架構限制、TypeScript/Ink 規則、禁止事項、提交格式,回答「能不能這樣做」
MEMORY.md:專案現況——每次重大改動改了什麼、留了什麼未完成,回答「現在做到哪裡了」
三者的關係在 CLAUDE.md 裡寫得很明白:「CLAUDE.md 只做『路由』開發規則本體只在 AGENTS.md,專案現況、進行中的工作只在 MEMORY.md」。
這代表 AI 代理(或任何接手的人)開場不用重新 git log/git diff 摸現況,直接讀 MEMORY.md 就有一份持續更新的專案日誌
README 是靜態的(除非架構真的變了才需要改),MEMORY.md 幾乎每次重大改動都要更新一段,本質上是一份帶時間戳記的開發日記,而不是文件包的一部分。
專案管理流程:TASK 清單 + 驗證腳本,取代人工追蹤
Day5 提過 DEVELOPMENT_TASKS.md 把開發拆成 67 個 TASK、14 個 Phase。但光有清單還不夠,真正讓「有沒有做完」變得可驗證的,是一組 npm run 腳本:
npm run check:ink # Ink 語法能不能編譯
npm run validate:story # 劇本結構驗證(場景/選項有沒有斷鏈)
npm run validate:assets # 資產缺漏驗證(引用的圖/音檔是否存在)
npm run typecheck # TypeScript 型別
npm run test # 單元測試(story runtime / 存檔 / 分支 / 結局…)
這幾支指令背後對應 scripts/ 資料夾裡一整批工具:
每一章都有專屬的 test-chXX-branch.mjs 分支測試,還有 test-ending-resolver.mjs、test-evidence-system.mjs 這種系統級測試。
管理流程不是開會對進度,而是「這個 TASK 有沒有讓對應的驗證腳本通過」
這也是為什麼 AGENTS.md 會規定每次提交前至少要跑 typecheck 跟 test,改 Ink 再多跑 check:ink + validate:story。
| 檔案 | 內容 |
|---|---|
| test-ch00-branch.mjs | ch00分支測試 |
| test-ch01-branch.mjs | ch01分支測試 |
| test-ch02-branch.mjs | ch02分支測試 |
| test-ch03-branch.mjs | ch03分支測試 |
| test-ch04-branch.mjs | ch04分支測試 |
| test-ch05-branch.mjs | ch05分支測試 |
| test-ch06-branch.mjs | ch06分支測試 |
| test-ch07-branch.mjs | ch07分支測試 |
| test-ch08-branch.mjs | ch08分支測試 |
| 檔案 | 內容 |
|---|---|
| test-ending-resolver.mjs | 結局判定 |
| test-evidence-system.mjs | 證據系統 |
| test-endings-formal.mjs | 結局正式驗證 |
| test-character-events.mjs | 角色事件 |
| test-extra-unlock-resolver.mjs | 番外解鎖判定 |
| test-ink-tag-parser.mjs | Ink 標籤解析 |
| test-pixi-stage.mjs | Pixi 舞台 |
| test-save-manager.mjs | 存檔管理 |
| test-save-migration.mjs | 存檔版本遷移 |
| test-story-command-router.mjs | 劇情指令路由 |
| test-story-runtime.mjs | Story runtime |
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Compiler } from 'inkjs/full'
const narrativeDir = join(dirname(fileURLToPath(import.meta.url)), '../src/narrative')
function loadStory() {
const main = readFileSync(join(narrativeDir, 'main.ink'), 'utf-8')
const included = []
const mainBody = main.replace(/^\s*INCLUDE\s+(.+?)\s*$/gm, (_, file) => {
included.push(readFileSync(join(narrativeDir, file), 'utf-8'))
return ''
})
const source = [mainBody, ...included].join('\n')
const errors = []
const compiler = new Compiler(source, {
errorHandler: (message, type) => errors.push({ message, type }),
})
const story = compiler.Compile()
const compileErrors = errors.filter((error) => error.type === 2)
assert.deepEqual(compileErrors, [])
return story
}
function continueToChoices(story, tags, guard = 500) {
let lastText = ''
while (story.currentChoices.length === 0 && story.canContinue && guard > 0) {
const text = story.Continue().trim()
recordTags(story, tags)
if (text) lastText = text
guard -= 1
}
assert.notEqual(guard, 0, 'story entered a likely infinite loop before choices')
return lastText
}
function continueUntil(story, tags, predicate, guard = 900) {
let lastText = ''
while (guard > 0) {
while (story.canContinue && guard > 0) {
const text = story.Continue().trim()
recordTags(story, tags)
if (text) lastText = text
if (predicate(story, text)) return lastText
guard -= 1
variable(scenarioStory, scenario.expected.flag),
scenario.expected.value,
`${scenario.name} did not set ${scenario.expected.flag}`,
)
assertObservedTag(tags, 'vfx:screen_shake:medium')
}
const trialScenarios = [
{
answer: '完全裝傻',
assertion: (currentStory) => assert.equal(variable(currentStory, 'disguise') >= 68, true),
},
{
answer: '半真半假',
privateChoice: '沉默',
assertion: (currentStory) => assert.equal(variable(currentStory, 'palace_intel') >= 8, true),
},
{
answer: '直接質問淑妃案',
assertion: (currentStory) => assert.equal(variable(currentStory, 'flag_openly_questioned_mother_case'), true),
},
{
answer: '將矛頭指向皇后',
assertion: (currentStory) => assert.equal(variable(currentStory, 'rel_empress_hostility') >= 55, true),
},
{
answer: '將罪名推給內侍',
assertion: (currentStory) => assert.equal(variable(currentStory, 'rel_gao_hostility') >= 35, true),
},
]
for (const scenario of trialScenarios) {
const { story: trialStory, tags } = reachImperialTrial({
investigations: ['先看傷勢', '檢查酒壺與香爐'],
jadeChoice: '交出玉佩',
})
chooseIncludes(trialStory, scenario.answer)
continueUntil(trialStory, tags, (currentStory) => choiceTexts(currentStory).some((text) => text.includes('黛藍披風')))
scenario.assertion(trialStory)
finishChapter00(trialStory, tags, '黛藍披風', scenario.privateChoice)
}
console.log('CH00 vertical slice tests passed')
Assets 管理方式:清單先行,資料夾照規則命名
素材這塊也是先寫文件、再產圖,而不是先產圖再回頭補文件:
SCENE_ASSET_LIST.md 定義場景美術的命名規則
CHARACTER_SPRITE_LIST.md 定義每個角色的立繪、服裝、表情、姿勢組合
BACKGROUND_PROMPTS.md 存各章背景圖的 AI 生成提示詞
ASSET_LICENSES.md 逐筆登記素材授權來源(AI 輔助/委託/第三方套件都分開記)
實際檔案放在 public/assets/ 底下按類型分資料夾(bg、cg、characters、scenes、audio、props、ui……)。
generate:manifest(generate-preload-manifest.ts)才是產生素材預載清單的腳本;>generate:sections(generate-chapter-sections.mjs)做的是完全不同的事——它解析每章 Ink 劇本># scene:CHxx_Syy 這類標籤,抓出每個章節底下有哪些「小節」,輸出 chapter-sections.json 給後
台管理介面的「章節跳轉」功能用。兩支腳本只是剛好都在 build 流程裡跑,跟素材預載沒有關係。
validate:assets 抓的不只是「檔案不見了」
validate:assets 實際的檢查邏輯是雙向的
缺漏:Ink 引用了某個角色/場景/CG/音效 id,但 characters-scenes.json/asset-manifest.ts 裡>沒有對應資料,或資料指到的檔案路徑實際上不存在
未使用:資料表裡定義了某個角色表情、場景、CG、音訊,但整個 Ink 劇本裡完全沒有任何地方引用到——這類>會標成警告而不是錯誤,代表「可能是廢棄資產,值得回頭確認要不要清掉」
尺寸校驗:對於道具/證物這類圖,腳本會直接讀檔案的 PNG/WebP 二進位標頭算出實際寬高,跟 asset->manifest.ts 裡登記的預期尺寸比對——換圖時如果忘記同步更新資料表的尺寸欄位,驗證會抓到,不用等到玩>家端顯示跑版才發現
這套驗證機制抓到的問題,實際上都有明確的數字可以對:
例如:
有一批 40 筆場景/CG 圖鑑條目,圖檔明明放在 ch04/ch07/ch08 各自的資料夾底下,chapter 欄位卻沿用舊值標成 'all'(15 筆 ch04、12 筆 ch07、13 筆 ch08),導致章節篩選鈕看不到對應圖片,靠寫腳本逐一比對「資料夾實際檔案」跟「圖鑑資料的 chapter 標記」才抓出來、修正完
縮圖產生腳本(generate_portal_thumbs.py)有兩個 bug,最終導致約 64 個 CG 縮圖從未產生、5 個資料夾的同名檔案互相覆蓋縮圖。
細節是:一、只掃 public/assets/cg/ 頂層,不會遞迴進 ch07/、ch08/、extraNN/、alternates/ 等子資料夾;
二、輸出縮圖只用檔名不含資料夾路徑,不同資料夾裡剛好同名的檔案(例如各資料夾共用的 day.webp/night.webp/hall.webp)會互相覆蓋——目前是手動繞過補產缺的縮圖,腳本本身還沒回頭修
這些坑的共通點是:都不是靠「感覺哪裡怪怪的」發現的,而是寫一支比對腳本,把「文件/資料表宣稱的狀態」跟「資料夾實際的檔案」兩邊攤開來對,落差自然就浮出來。
文件包解決的是「內容從哪裡來」,這一篇的專案管理流程解決的是「怎麼知道內容做完了、做對了」——兩者合起來,才是一個人也能穩定推進,而不是中途在自己的文件迷宮裡迷路的原因。