iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Modern Web

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

Day26 章節內容怎麼組織:Ink INCLUDE 與章節鎖

  • 分享至 

  • xImage
  •  

《九重燼》目前沒有外掛式章節系統,也沒有執行期載入新的 Ink 檔案。劇本組織方式很樸素:每章一個 .ink 檔,main.inkINCLUDE 把它們接起來,編譯時全部變成同一份 Ink story JSON。章節密碼鎖是另一層東西,它控制玩家能不能繼續看某章,不控制那章有沒有被編進劇本。

main.ink 是劇本入口

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。拆開看,大概是:

  • 3 個基礎檔:globals.inkcharacters.inkextra_globals.ink
  • 9 個主線章節:chapter_00.inkchapter_08.ink
  • 1 個 Demo 結局檔:endings_demo.ink
  • 19 個正式結局相關檔:dispatcher、正式結局、戀愛結局、formal ending card
  • 9 篇番外:extra_01_...inkextra_09_...ink

這代表劇本已經拆章了,只是拆的是原始碼維護單位,不是玩家下載時的模組。

新增章節是手動流程

現在新增一章,大概就是三步。

先在 src/narrative/chapters/ 新增檔案,例如 chapter_09.ink。接著在 main.ink 加一行:

ink
INCLUDE chapters/chapter_09.ink

最後把上一章結尾接到新章起點:

ink
-> chapter_09_start

這不是自動掃描,也不是註冊表。少加一行 INCLUDE,新章就不會進編譯結果;上一章忘記改跳轉,玩家也走不到新章。
所有劇本入口都在 main.ink 一眼看完,出問題時也很好查。

編譯後是一份 story JSON

正式瀏覽器端不是即時讀 .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:inkscripts/compile-check.mjs,會編譯 src/narrative/main.ink 和所有 INCLUDE,輸出:

src/narrative/compiled/main.json

npm run build:inkscripts/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.tsimport.meta.glob('../narrative/**/*.ink', { eager: true }) 把 Ink 原始碼讀進來,再解析 INCLUDE,組成完整字串。這條路主要給測試腳本用,不是瀏覽器正式 runtime。

所以這個專案其實有兩種「組合劇本」的時機:

正式遊戲:先編譯成 compiled/main.json,再由 StoryRuntime import
測試腳本:讀 .ink 原始碼,解析 INCLUDE,交給 inkjs/full 即時編譯

兩條路的目的不同。正式版要快,測試腳本要方便檢查原始劇本。

章節資料表是前端入口,不是 Ink 結構

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.tsstart() 時會先等:

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.tsload() 會在還原 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。

為什麼現在不做 Plugin 化

如果未來真的要做 Mod 劇情腳本或開發者劇情編輯器,現在這套架構一定不夠。

原因很簡單:Ink 的 story 狀態是整包編譯後的結果。你要在 runtime 塞一個外部章節進來,不只是多抓一個 .ink 檔,還要處理:

  • 變數命名不能和既有 globals 衝突。
  • knot 名稱不能撞。
  • 外部章節要怎麼跳回主線。
  • 存檔要記得玩家目前在哪個 story bundle。
  • 美術、音訊、preload manifest 要跟著章節一起註冊。
  • Mod 內容錯誤時不能把正式劇本拖垮。

這些問題都能解,但不是現在最該解的問題。現在的主要任務是把主線、結局和番外穩定跑完。為了還不存在的 Mod 生態先做一套 plugin runtime,會讓系統變重,也會讓每次改劇本都更痛。

現在的取捨

main.ink 是劇本裝訂線。所有 .ink 章節都被手動 include 進來,編成一份 compiled/main.jsonStoryRuntime 讀這份 JSON,Pinia 根據 tag 更新章節狀態。章節鎖則在 tag 觸發後用 UI 擋住玩家,密碼狀態存在 Vercel Edge Config。

這不是外掛架構。不是 DLC 架構。也不是分章下載。

但它是可維護的連載架構。對現在的《九重燼》來說,這已經夠用:劇本可以拆檔寫,正式版載入一份 JSON,章節開放節奏可以遠端控制,測試腳本也能從 Ink 原始碼即時編譯。


我們有手動 INCLUDE,有單一編譯輸出,有遠端章節鎖。Plugin 化和動態章節載入還沒做,而且暫時不該做。先讓故事穩定、章節可控、測試能跑,這比提前背一套複雜模組系統更實際。


上一篇
Day25 遊戲資源管理:Assets、章節與動態載入設計
下一篇
Day27 正式上線前:測試流程檢查
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言