iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Modern Web

《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄系列 第 6

Day6 建立遊戲開發文件與專案管理流程

  • 分享至 

  • xImage
  •  

昨天談的是企劃文件包本身,今天想往下一層,談這些文件在實際開發裡怎麼被「用」
包含專案的入口文件、跟開發直接掛鉤的管理流程,以及素材怎麼從一堆散圖變成可以被程式讀取的資源。

規劃大綱裡寫的「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)會互相覆蓋——目前是手動繞過補產缺的縮圖,腳本本身還沒回頭修

這些坑的共通點是:都不是靠「感覺哪裡怪怪的」發現的,而是寫一支比對腳本,把「文件/資料表宣稱的狀態」跟「資料夾實際的檔案」兩邊攤開來對,落差自然就浮出來。

文件包解決的是「內容從哪裡來」,這一篇的專案管理流程解決的是「怎麼知道內容做完了、做對了」——兩者合起來,才是一個人也能穩定推進,而不是中途在自己的文件迷宮裡迷路的原因。


上一篇
Day5 建立完整遊戲企劃文件(PRD)
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言