Day8 講了整體架構怎麼分層,這篇要往下鑽一層——Vue 3 跟 inkjs 之間,實際上是怎麼「說話」的?
src/engine/StoryRuntime.ts 把 inkjs 的 Story 物件整個包起來,對外只露出幾個方法。這不是過度設計,是刻意的封裝邊界——如果每個 Vue 元件都能直接 import inkjs、直接呼叫 story.Continue(),那劇情狀態就會散落在十幾個元件裡,沒有人知道現在「真正的進度」在哪。
規則很簡單:inkjs 的 API 只能透過 StoryRuntime,其他地方一律不准 import inkjs。
TypeScript 幫忙強化了這條邊界——StoryRuntime 定義了一個自己的最小介面 InkStory,把 inkjs 原生的 Story 物件轉成這個介面後對外使用:
interface InkStory {
canContinue: boolean
Continue(): string
currentTags: string[] | null
currentChoices: Array<{ index: number; text: string }>
ChooseChoiceIndex(index: number): void
ChoosePathString(path: string): void
variablesState: Record<string, unknown> & {
$: (name: string, value?: unknown) => unknown
}
state: {
ToJson(): string
LoadJson(json: string): void
}
onError: ((message: string, type: unknown) => void) | null
}
這個介面只列出真正用到的方法,不暴露 inkjs 全部的 API surface——這樣哪天 inkjs 升級改了某個沒在用的方法,也不用動 StoryRuntime 外面的任何程式碼。
inkjs 提供的 API 其實不少,但《九重燼》真正用到的,濃縮起來就這幾個:
story.canContinue // 還能不能往下走
story.Continue() // 往下走一行,回傳文字
story.currentTags // 這一行附帶的 #tag
story.currentChoices // 目前的選項清單
story.ChooseChoiceIndex(i) // 選擇第 i 個選項
story.ChoosePathString(k) // 直接跳到某個 knot
story.variablesState.$(name, value?) // 讀/寫 Ink 變數
story.state.ToJson() // 序列化整個執行狀態(存檔)
story.state.LoadJson(json) // 還原執行狀態(讀檔)
story.onError // 掛錯誤處理
這十個東西就是整個整合層的骨架。StoryRuntime 的每一個公開方法,本質上都是把這些 inkjs 原生呼叫包成更安全、更有語意的版本。
continueLine():一次拿到這行的所有資訊inkjs 的 Continue() 呼叫本身只回傳文字,tag 和選項要分別再問 currentTags 和 currentChoices。StoryRuntime.continueLine() 把這三件事包在一起:
continueLine(): StoryLine | null {
if (!this.story?.canContinue) return null
const text = this.story.Continue().trim()
return {
text,
tags: [...(this.story.currentTags ?? [])], // ← 複製一份,避免 inkjs 內部修改污染
choices: this.choices,
}
}
text 後面有個 .trim()——inkjs 有時候會在行尾留空白(Ink 語法的換行規則造成的),trim 掉讓上層不用處理空白細節。
tag 那邊用展開運算子複製了一份,不直接回傳 inkjs 內部陣列的參考,這樣就算 inkjs 後續更動了 currentTags,外面拿到的陣列不會被改動。
Continue() 的設計:一次只吐一行inkjs 的設計是「每呼叫一次 Continue(),往下走到下一個換行點就停」,不會一口氣把一整段劇情吐出來。這個特性直接決定了 Day8 提過的 advance() 驅動模式——advance() 本質上是「呼叫一次 continueLine(),把結果分派出去,然後等使用者下一次點擊」。
這也解釋了為什麼 advance() 裡有個 while 迴圈:Ink 腳本裡有些行是純 tag(沒有對白文字),例如:
ink
=== chapter_00_start ===
# chapter:CH00 ← 純 tag,沒有文字
# scene:CH00_S01 ← 純 tag,沒有文字
# bg:chenghua_dian ← 純 tag,沒有文字
# bgm:opening_theme ← 純 tag,沒有文字
# vfx:rain:start ← 純 tag,沒有文字
雨落在琉璃瓦上。 ← 第一行有文字的行
如果 advance() 只呼叫一次 Continue(),它會停在第一個空行,對話框就是空的。while 迴圈解決這個問題:一直往前走,直到找到有文字的行才停。
choose():邊界守衛choose(index: number): void {
if (!this.story) return
if (index < 0 || index >= this.story.currentChoices.length) return // ← 邊界檢查
this.story.ChooseChoiceIndex(index)
}
index >= this.story.currentChoices.length 這個檢查看起來像廢話,但實際上踩過坑:如果 UI 元件在短時間內連送兩次選擇(例如雙擊按鈕),第二次呼叫時 inkjs 已經處理完第一次、內部選項陣列已清空,ChooseChoiceIndex 會拋錯。有了邊界守衛,第二次的呼叫靜默忽略,不崩潰。
jumpTo():跳轉要包 try/catchjumpTo(knotName: string): void {
if (!this.story) return
try {
this.story.ChoosePathString(knotName)
} catch (e) {
console.error(`[StoryRuntime] jumpTo failed: ${knotName}`, e)
}
}
ChoosePathString 如果傳入的 knot 名字不存在,inkjs 會直接拋例外。這在 Admin 面板手動跳轉章節時特別容易發生(打錯 knot 名)。包 try/catch 讓跳轉失敗只是在 console 留錯誤紀錄,不把整個遊戲拉掛。
getVariable / setVariable:變數橋接getVariable(name: string): unknown {
if (!this.story) return undefined
try {
return this.story.variablesState.$(name)
} catch {
return undefined
}
}
setVariable(name: string, value: unknown): void {
if (!this.story) return
try {
this.story.variablesState.$(name, value)
} catch (err) {
console.error(`[StoryRuntime] setVariable failed: ${name}`, err)
}
}
inkjs 的 variablesState.$ 同時充當 getter 和 setter——不傳第二個參數就讀,傳了就寫。getVariable 用 try/catch 包著是因為讀取不存在的變數名稱時 inkjs 會拋錯,包起來讓它回傳 undefined 就好,不讓它把整個 _syncVariables() 的同步流程中斷。
_syncVariables() 每次 advance() 後都會調用,把所有 Ink 變數讀進 Pinia store:
// 讀取所有數值
for (const key of STAT_KEYS) this.stats[key] = read(key)
// power / popular_support / emperor_favor / ambition / compassion / suspicion / ...
// 讀取所有關係六維度
// rel_liu_affection / rel_liu_trust / rel_liu_alignment / rel_liu_fear / ...
for (const { id, relKey } of REL_CHARACTERS) {
for (const metric of REL_METRICS) {
const val = read(`rel_${relKey}_${metric}`)
// ...
}
}
十個有關係追蹤的角色(柳如煙、顧清漪、阿依娜、沈夢蝶、白蘅、卓雲翎、七皇子、德妃、大皇子、六皇子),每個角色六個維度(affection, trust, alignment, fear, ideal, hostility),加上各章節旗標、行動點、證據旗標——_syncVariables() 一口氣讀完全部,寫進 Pinia。Vue 元件只讀 Pinia,不直接碰 inkjs 的 variablesState。
從序章調查場景可以看到一個典型的模式——玩家做選擇,Ink 腳本用 ~ 語句修改變數,然後跳回調查選單:
ink
+ {not flag_checked_qinghe_wound} [先看傷勢。死人不會說話,傷口卻未必會騙人。]
# choice:CH00_S01_C01_A
-> chapter_00_s01_check_wound
=== chapter_00_s01_check_wound ===
# char:xiao_chengyuan:default:calm:center
他俯身按住青禾腹部。刀口狹窄……
# speaker:xiao_chengyuan
她不是在這裡受的第一刀。
~ palace_intel = CLAMP_100(palace_intel + 3) ← 宮廷情報 +3
~ suspicion = CLAMP_100(suspicion + 2) ← 謹慎度 +2
~ flag_checked_qinghe_wound = true ← 旗標設定
~ investigation_remaining -= 1 ← 調查次數 -1
-> chapter_00_s01_investigation ← 跳回選單
CLAMP_100 是 Ink 自定義函式,確保數值不超過 100。~ 語句在 Ink 執行時立即生效——下次 _syncVariables() 呼叫 getVariable('palace_intel') 時,就會拿到已經加 3 的新值。
Vue 端完全不知道「選這個選項會加 3 點 palace_intel」這件事,也不應該知道。這條邊界確保:數值邏輯在 Ink,顯示邏輯在 Vue,兩邊各自獨立。
inkjs 吐出來的 tag 是純字串陣列,例如:
['bg:chenghua_dian', 'char:xiao_chengyuan:default:wounded:center', 'bgm:opening_theme']
InkTagParser 的任務是把這些字串解析成有型別的指令物件,格式是 type:arg1:arg2:...,用冒號分隔:
export function parseInkTag(rawTag: string): InkTagCommand {
const raw = rawTag.trim().replace(/^#\s*/, '') // 去掉開頭的 # 號
const [rawType, ...rawArgs] = raw.split(':')
const typeName = rawType.trim()
const args = rawArgs.map(a => a.trim()).filter(a => a.length > 0)
const type = KNOWN_TAG_TYPES.has(typeName as InkTagType)
? (typeName as InkTagType)
: 'unknown'
// 驗證必要參數數量
const required = REQUIRED_ARG_COUNTS[type] ?? 0
if (args.length < required) {
return { raw, type: 'invalid', args, isValid: false,
error: `${type} expects at least ${required} argument(s)` }
}
// ...
}
每個 tag type 有自己的必要參數數量,驗證失敗的 tag 會被標成 invalid,讓 router 跳過並記錄,不靜默失敗——這樣 Ink 腳本寫錯格式時(例如 # char:prince 少了表情和位置),錯誤會被抓到,不是畫面無聲無息地不動。
char tag 的特殊解析
char tag 有兩種寫法:三個參數(省略服裝)和四個參數(包含服裝):
ink
# char:prince:smile:center ← 三個參數,outfit 預設 'default'
# char:prince:casual:smile:center ← 四個參數,outfit 是 'casual'
InkTagParser 幫 Ink把這兩種寫法統一:
if (type === 'char') {
const [characterId, second, third, fourth] = args
const outfitId = fourth ? second : 'default'
const expressionId = fourth ? third : second
const position = fourth ?? third
return { ..., characterId, outfitId, expressionId, position }
}
有四個參數時,second 是 outfit;沒有四個參數時,second 直接當 expression,outfit fallback 成 'default'。這讓 Ink 腳本作者不用每次都記得寫 outfit,大部分情況省略就好。
exportState(): string | null {
if (!this.story) return null
return this.story.state.ToJson()
}
importState(json: string): void {
if (!this.story) this.start() // ← 還沒初始化就先初始化
try {
this.story?.state.LoadJson(json)
} catch (err) {
console.error('[StoryRuntime] importState failed:', err)
throw err // ← 重新拋出,讓上層知道失敗了
}
}
state.ToJson() 會把 inkjs 目前所在的位置(執行堆疊)、所有全域變數值、已訪問的 knot 記錄,整個序列化成一段 JSON 字串。LoadJson() 整包灌回去,就能精準回到玩家離開的那一刻。
不需要另外維護一套「目前在哪個章節哪個場景」的追蹤邏輯,inkjs 的執行狀態就是最完整的存檔資料。SaveManager 只是把這段 JSON 跟 Pinia 的 UI 狀態、音訊狀態、場景視覺狀態一起打包進 IndexedDB。
importState 裡有個 throw err——讀檔失敗應該讓上層知道,不能靜默吃掉。SaveManager 那邊收到錯誤後會顯示讀取失敗的提示,讓玩家選擇重試或選別的存檔,不讓遊戲進入一個狀態不一致的中間態。
isEnded():故事真的結束了嗎isEnded(): boolean {
return !!this.story && !this.story.canContinue && this.choices.length === 0
}
inkjs 的故事結束條件是「不能繼續、也沒有選項」。兩個條件都要滿足——不能繼續但還有選項,代表在等玩家選;有選項但能繼續,在 Ink 的執行模型裡不會同時發生(選完才能繼續)。這個方法讓 advance() 可以在每次推進之後判斷「現在是否到達 story 的終點」。
生產環境的路徑是 StoryRuntime import 預編譯的 main.json,但 scripts/ 下的測試腳本需要在 Node.js 環境執行,不能用 Vite 的靜態 import。ink-loader.ts 是專為測試腳本設計的另一條路:
// 用 import.meta.glob 把所有 .ink 檔全部載入
const sources = import.meta.glob('../narrative/**/*.ink', {
query: '?raw', import: 'default', eager: true,
})
export function loadInkSource(entry = 'main.ink'): string {
// 解析 INCLUDE 指令,把所有被包含的檔案組合成一個大字串
const mainBody = main.replace(/^\s*INCLUDE\s+(.+?)\s*$/gm, (_match, file) => {
included.push(byPath[file])
return ''
})
return [mainBody, ...included].join('\n')
}
這個 loader 把 Ink 的 INCLUDE 指令手動解析,把所有 .ink 檔拼成一個大字串,供測試腳本傳給 inkjs 即時編譯。生產環境完全不走這條路,只有 scripts/*.mjs 的測試腳本用它。
Vue 3 跟 inkjs 的整合,實際上就是這樣一條路:
inkjs Story 物件
↓ 封裝為 StoryRuntime(唯一接觸點)
StoryRuntime
↓ continueLine() / choose() / exportState() 等
Pinia store (_applyTags, _syncVariables)
↓ 解析 tag → 更新 state → 觸發 Vue reactivity
Vue 元件
每一層的邊界都是刻意的:inkjs 不知道 Vue 存在,Vue 不知道 inkjs 存在,兩者透過 Pinia store 這個中介溝通。這條薄薄的 StoryRuntime 包裝,換來的是:以後 inkjs 升版升 API、甚至哪天想換掉底層敘事引擎,理論上只要重寫這一個 124 行的檔案,Vue 元件跟 Pinia store 完全不用動。