《九重燼》目前沒有外掛式章節系統,也沒有執行期載入新的 Ink 檔案。劇本組織方式很樸素:每章一個 .ink 檔,main.ink 用 INCLUDE 把它們接起來,編譯時全部變成同一份 Ink story JSON。章節密碼鎖是另一層東西,它控制玩家能不能繼續看某章,不控制那章有沒有被編進劇本。
src/narrative/main.ink 開頭的註解已經把規則寫出來:
ink
// 新增章節:1. chapters/ 加檔 2. 此處加 INCLUDE 3. 上一章結尾改跳新章起點
下面就是一串 INCLUDE:
ink
INCLUDE globals.ink
INCLUDE characters.ink
INCLUDE extra_globals.ink
INCLUDE chapters/chapter_00.ink
INCLUDE chapters/chapter_01.ink
...
INCLUDE chapters/chapter_08.ink
INCLUDE chapters/endings_demo.ink
INCLUDE chapters/endings/dispatcher.ink
...
INCLUDE chapters/extra_09_the_nameless_medicine_bowl.ink
-> chapter_00_start
目前 main.ink 裡有 41 行 INCLUDE。拆開看,大概是:
globals.ink、characters.ink、extra_globals.ink
chapter_00.ink 到 chapter_08.ink
endings_demo.ink
extra_01_...ink 到 extra_09_...ink
這代表劇本已經拆章了,只是拆的是原始碼維護單位,不是玩家下載時的模組。
現在新增一章,大概就是三步。
先在 src/narrative/chapters/ 新增檔案,例如 chapter_09.ink。接著在 main.ink 加一行:
ink
INCLUDE chapters/chapter_09.ink
最後把上一章結尾接到新章起點:
ink
-> chapter_09_start
這不是自動掃描,也不是註冊表。少加一行 INCLUDE,新章就不會進編譯結果;上一章忘記改跳轉,玩家也走不到新章。
所有劇本入口都在 main.ink 一眼看完,出問題時也很好查。
正式瀏覽器端不是即時讀 .ink 原始碼。src/engine/StoryRuntime.ts 直接 import 預編譯結果:
import compiledStory from '../narrative/compiled/main.json'
然後用 inkjs 建立 Story:
this.story = new Story(compiledStory as ConstructorParameters<typeof Story>[0])
這代表玩家進遊戲時拿到的是已經編好的 story data,不是現場解析 main.ink 和 41 個 include 檔。這也符合 Day9 講過的架構:Vue 不直接碰 inkjs,執行細節都包在 StoryRuntime 裡。
編譯腳本有兩條。
npm run check:ink 走 scripts/compile-check.mjs,會編譯 src/narrative/main.ink 和所有 INCLUDE,輸出:
src/narrative/compiled/main.json
npm run build:ink 走 scripts/build-ink.mjs,除了同一份 src/narrative/compiled/main.json,還會再輸出:
public/story/main.json
public/story/story.json
正式 npm run build 會先跑 check:ink,所以 Vite 打包時用的是 src/narrative/compiled/main.json。
還有一個容易混淆的檔案:src/engine/ink-loader.ts。
它的註解寫得很清楚:
// 開發時:讀取 .ink 原始碼並組合(供腳本測試用)
// 生產時:由 StoryRuntime 直接 import compiled/main.json(不走此模組)
ink-loader.ts 用 import.meta.glob('../narrative/**/*.ink', { eager: true }) 把 Ink 原始碼讀進來,再解析 INCLUDE,組成完整字串。這條路主要給測試腳本用,不是瀏覽器正式 runtime。
所以這個專案其實有兩種「組合劇本」的時機:
正式遊戲:先編譯成 compiled/main.json,再由 StoryRuntime import
測試腳本:讀 .ink 原始碼,解析 INCLUDE,交給 inkjs/full 即時編譯
兩條路的目的不同。正式版要快,測試腳本要方便檢查原始劇本。
src/data/characters-scenes.json 裡也有 chapters 區塊。目前有 18 筆:9 個 main、9 個 extra。
這份資料給 UI 使用。例如後台章節跳轉、章節標題、番外入口,都會讀這份資料。但它不是 Ink 的 include 清單。就算 characters-scenes.json 有某章,如果 main.ink 沒 include 對應 .ink,StoryRuntime 也跳不到那個 knot。
反過來也一樣。Ink 裡 include 了某個檔案,不代表 UI 一定有入口。劇本結構和前端章節清單是兩層資料,現在靠命名規則和人工維護保持一致。
這是目前架構的一個風險點,但也很正常。等內容穩定後,可以考慮加一個檢查腳本:確認 characters-scenes.json.chapters 裡的 main / extra,都能在 Ink 裡找到對應起點 knot。
章節密碼鎖和劇本組織是兩件事。
章節鎖的資料存在 Vercel Edge Config。API 分三支:
GET /api/chapter-locks:公開端點,只回傳每章是否鎖定,不回傳密碼。POST /api/chapter-locks-verify:檢查某章密碼是否正確。GET/POST /api/chapter-locks-admin:管理端讀寫完整設定,需要 x-admin-token,對應伺服器端 ADMIN_WRITE_TOKEN。底層共用 api/_lib/chapterLocks.ts。讀取時優先用 VERCEL_API_TOKEN + EDGE_CONFIG_ID 走 Vercel REST API,失敗時才 fallback 到 @vercel/edge-config SDK。寫入則用 Vercel REST API PATCH edge-config/{id}/items。
這些都發生在伺服器端。前端拿不到正確密碼本身。
前端有一層 PasswordManager.ts。它負責把遠端鎖定狀態拉到本地快取:
const FETCH_TIMEOUT_MS = 4000
如果 4 秒內抓不到,或本機 dev 沒接 Edge Config,就 fail-open,視為未鎖定。
story.ts 在 start() 時會先等:
await PasswordManager.ensureLoaded()
原因是 # chapter: tag 處理流程需要同步判斷鎖定狀態。當 Ink tag 被解析到章節 id 時,store 會做:
if (id && PasswordManager.isChapterLocked(id)) {
this.isChapterLocked = true
}
GameView.vue 則根據這個狀態顯示鎖定畫面:
<ChapterLockScreen v-if="store.isChapterLocked" />
<footer class="bottom-ui" v-if="!store.isChapterLocked">
玩家輸入密碼時,ChapterLockScreen.vue 呼叫:
PasswordManager.verifyPassword(store.chapterId, passwordInput.value)
成功後只做一件事:store.unlockCurrentChapter(),把當前畫面的鎖定狀態解除,讓底部 UI 回來。
這代表所有章節內容其實已經在 story JSON 裡。章節鎖不是「沒有下載這章」,而是「劇情走到這章時,UI 暫停給你看鎖定畫面」。
這裡有個細節我滿喜歡:讀檔時也會重新抓鎖定狀態。
story.ts 的 load() 會在還原 UI 狀態後呼叫:
await PasswordManager.ensureLoaded()
this.isChapterLocked = !!(this.chapterId && PasswordManager.isChapterLocked(this.chapterId))
這代表如果玩家上次存檔停在 CH05,而這段時間管理端把 CH05 重新上鎖,玩家讀檔時仍會看到鎖定畫面。章節鎖是遠端狀態,不是只存在存檔裡的一個舊結果。
內容可以先編進遊戲,但開放節奏由伺服器端設定控制,不需要每次只為了開章節就重新部署。
AdminPanel 也會讀 characters-scenes.json 的章節清單,產生章節跳轉 UI。主線章節會轉成起點 knot:
return `chapter_${key.replace(/^CH/i, '')}_start`
番外則轉成:
return `extra${key.replace('extra_', '')}_unlock`
按下去後呼叫 store.jumpToChapter(knot),底層是 StoryRuntime.jumpTo(),再呼叫 inkjs 的 ChoosePathString(knotName)。
這是開發和管理用的工具,不是玩家自然遊玩流程。它也再次說明:現在沒有「章節模組載入」。跳章只是跳到同一份 story JSON 裡的某個 knot。
如果未來真的要做 Mod 劇情腳本或開發者劇情編輯器,現在這套架構一定不夠。
原因很簡單:Ink 的 story 狀態是整包編譯後的結果。你要在 runtime 塞一個外部章節進來,不只是多抓一個 .ink 檔,還要處理:
這些問題都能解,但不是現在最該解的問題。現在的主要任務是把主線、結局和番外穩定跑完。為了還不存在的 Mod 生態先做一套 plugin runtime,會讓系統變重,也會讓每次改劇本都更痛。
main.ink 是劇本裝訂線。所有 .ink 章節都被手動 include 進來,編成一份 compiled/main.json。StoryRuntime 讀這份 JSON,Pinia 根據 tag 更新章節狀態。章節鎖則在 tag 觸發後用 UI 擋住玩家,密碼狀態存在 Vercel Edge Config。
這不是外掛架構。不是 DLC 架構。也不是分章下載。
但它是可維護的連載架構。對現在的《九重燼》來說,這已經夠用:劇本可以拆檔寫,正式版載入一份 JSON,章節開放節奏可以遠端控制,測試腳本也能從 Ink 原始碼即時編譯。
我們有手動 INCLUDE,有單一編譯輸出,有遠端章節鎖。Plugin 化和動態章節載入還沒做,而且暫時不該做。先讓故事穩定、章節可控、測試能跑,這比提前背一套複雜模組系統更實際。