Day8、Day9 提過 InkTagParser 和 StoryCommandRouter,Day21 則談到 BGM、立繪、VFX 這些演出怎麼被觸發。Day22 就把這條線補完整:Ink tag 到底在《九重燼》裡扮演什麼角色?
簡單講,tag 是劇本和系統之間的接頭。
Ink 負責寫劇情、選項、變數和分支;Vue、Pixi、AudioManager 負責畫面與聲音。兩邊不能直接黏在一起,否則劇本會變成一堆 UI 指令,程式也會開始讀劇情文字做判斷。tag 的作用,就是讓劇本用很短的一行說:「這裡要換背景」、「這裡要播 BGM」、「這裡要打開小遊戲」。
最危險的做法,是讓 Vue 元件去看目前台詞內容,然後猜要做什麼。
例如:
if (store.text.includes('雨落在琉璃瓦上')) {
scene.setBackground('chenghua_bedroom_night_rain')
}
這看起來很快,實際上會把系統綁死在文字上。只要劇本改一句話,背景就不換了;翻譯、校稿、潤飾都可能順手把功能弄壞。更糟的是,這種 bug 很難查,因為它不是型別錯,也不是測試一定會跑到的錯,而是「某句話改掉後演出消失」。
所以《九重燼》選擇讓 Ink 明確寫 tag:
ink
# bg:chenghua_bedroom_night_rain
# bgm:opening_theme
# char:xiao_chengyuan:casual_red:wounded:center
文字可以改,tag 不亂動。劇本和系統的邊界就乾淨很多。
inkjs 給 StoryRuntime 的 tag 是字串陣列。也就是說,對程式來說,一開始拿到的只是:
['bg:chenghua_dian', 'char:xiao_chengyuan:default:wounded:center']
InkTagParser.ts 的任務是把這些字串轉成比較安全的 command。流程很小:
#
: 拆開 type 和 argschar tag例如 # char:xiao_chengyuan:casual_red:wounded:center 會被拆成:
{
type: 'char',
args: ['xiao_chengyuan', 'casual_red', 'wounded', 'center'],
characterId: 'xiao_chengyuan',
outfitId: 'casual_red',
expressionId: 'wounded',
position: 'center',
isValid: true,
}
這裡最重要的不是資料長得漂亮,而是錯誤會被標出來。空 tag 會變成 invalid,未知類型會變成 unknown,參數不夠也會回傳錯誤訊息。後面的 router 可以選擇忽略它、記錄它,而不是讓錯誤在畫面上靜默消失。
InkTagParser.ts 的 KNOWN_TAG_TYPES 目前包含這些:
chapter / scene / event / line / speaker
bg / bg_transition
char / char_hide
cg / cg_hide / cg_anim
vfx
bgm / bgm_stop
ambience / ambience_stop
sfx
minigame
ui
autosave / checkpoint
ending
unlock
toast
relation_hint
choice
不過「parser 認得」不等於「每個 tag 都有完整 UI 功能」。例如 relation_hint 和 choice 會被 parser 視為合法 tag,但在 StoryCommandRouter 裡目前是進 onIgnoredCommand,主要是讓它們能出現在劇本裡、被驗證腳本掃到、保留給其他系統使用,而不是在 router 裡直接改畫面。
這種差別要講清楚。否則很容易把「格式已支援」寫成「功能已完成」。
StoryCommandRouter.ts 不負責解析字串,它只看 InkTagCommand,再把 command 送到對應 context。
目前 context 分成五塊:
pinia:章節、場景、事件、line、speaker、小遊戲、UI、ending idpixi:背景、立繪、CG、VFXaudio:BGM、環境音、SFXsave:autosave、checkpointsystem:結局解析、解鎖、toast、invalid / ignored command這個分層讓 router 很像一張交通指揮表:
case 'bgm':
await context.audio.setBgm(command.args[0])
break
case 'minigame':
await context.pinia.setMinigame(command.args.join(':'))
break
case 'ending':
if (endingId === 'resolve') await context.system.resolveEnding(scope)
else await context.pinia.setEndingId(endingId)
break
router 不知道 AudioManager 怎麼 crossfade,也不知道小遊戲元件長什麼樣。它只負責把訊號送到正確的地方。
story.ts:真正把 command 套進遊戲狀態src/stores/story.ts 的 _applyTags() 是 tag 真正進入遊戲狀態的地方。
每次 advance() 拿到一行劇情後,store 會把這一行的 tags 丟進 parseInkTags(),再交給 routeStoryCommands()。context 的實作就寫在 _applyTags() 裡。
例如角色進場:
setCharacter: (characterId, outfitId, expressionId, position) => {
this.charId = characterId
this.charOutfit = outfitId
this.expr = expressionId
this.charPos = position
}
背景:
setBackground: (id) => {
this.sceneId = id
}
音樂:
setBgm: (id) => {
this.bgm = id
AudioManager.getInstance().playBGM(id)
}
所以 tag 系統不是一條魔法通道。它其實很樸素:解析字串、分派 command、更新 store,然後 Vue 和 Pixi 的 watch 自己反應。
_applyTags() 裡有兩個很實用的收尾:
if (summary.changedBackground && !summary.changedCharacter) {
this.charId = null
this.expr = null
this.charPos = null
}
if (summary.changedBackground && !summary.changedCg) {
this.cgId = null
}
意思是,換背景時,如果這一行沒有重新指定角色,就把上一幕立繪清掉;如果沒有重新指定 CG,就把滿版 CG 關掉。
這個細節很重要。視覺小說常常一行 tag 換到新場景,如果沒有這種保護,上一幕角色可能會站到下一幕,上一張 CG 也可能一路蓋住後續場景。這種錯誤很破壞沉浸感,而且不一定是劇本作者故意要留下的效果。
所以這裡用 StoryCommandRoutingSummary 記錄 changedBackground、changedCharacter、changedCg,讓 store 能在分派後做一次清理。
char tag 的小彈性:服裝可以省略InkTagParser 對 char tag 做了特別處理。它支援兩種寫法:
# char:liu_ruyan:palace:sad:left
# char:liu_ruyan:sad:left
第一種有 outfit,第二種省略 outfit。省略時 parser 會把 outfitId 設成 default。
這個設計讓劇本比較好寫。大多數角色如果沒有特殊服裝,不需要每次都寫 default;但真的需要宮裝、軍裝、便服時,又可以在 tag 裡明確指定。
當然,彈性不能太多。char 至少要有三個參數,否則 char:liu_ruyan:left 會被判成 invalid。這條線守住,才能避免 parser 開始亂猜。
vfx:同一個 tag,分成持續型和一次性vfx tag 的分派邏輯也值得看:
const [name, mode] = command.args
if (mode === 'start') await context.pixi.setPersistentVfx(name)
else if (mode === 'stop') await context.pixi.stopPersistentVfx(name)
else await context.pixi.triggerOneShotVfx(name, mode)
也就是說:
ink
# vfx:rain:start
# vfx:rain:stop
# vfx:tension_flash:once
# vfx:screen_shake:medium
前兩個會被當成持續型特效,後兩個是一次性特效。router 不需要知道 rain 或 screen_shake 的實作細節,只看第二個參數是 start、stop 還是其他模式。
這讓劇本語意很清楚:雨可以開始和停止,閃白或震動則是打一發。
Day22 原本最有意思的例子,是 # minigame:<type>:<id>。
CH02 有三種小遊戲式選項 UI:
debate:CH02_S03_DEBATE
logistics:CH02_S04_LOGISTICS
duel:CH02_S05_REPRESENTATIVE
duel:CH02_S05_CHEATING
Ink 只下 tag:
ink
# minigame:duel:CH02_S05_REPRESENTATIVE
router 收到後不是解析成物件,而是把後面參數 join 回一個字串:
await context.pinia.setMinigame(command.args.join(':'))
所以 store 裡最後會是:
store.minigame === 'duel:CH02_S05_REPRESENTATIVE'
GameView.vue 永遠掛著三個元件:
<LogisticsPlanner />
<DebatePlanner />
<DuelArena />
元件自己判斷是否啟用:
const isActive = computed(() => store.minigame?.startsWith('debate') === true)
const isActive = computed(() => store.minigame?.startsWith('logistics') === true)
const isRepresentativeStage = computed(() => store.minigame === 'duel:CH02_S05_REPRESENTATIVE')
這個做法有點土,但非常適合現在的規模。store 不需要知道「目前有幾種小遊戲」,router 也不需要 import Vue component。要新增一種小遊戲,最少要做的是:新增元件、掛到 GameView.vue、讓它自己看 store.minigame,再補測試。Pinia 欄位不用改,Ink tag 格式也不用改。
這裡還有一個細節:小遊戲 UI 沒有自己改變劇情狀態。
以 DebatePlanner.vue 為例,它有自己的卡片資料,例如「民為國本」、「君為國本」、「君民相制」。但玩家點卡片時,元件不是直接改變 Ink 變數,而是用 choiceId 找出對應的 Ink choice,最後呼叫:
const choiceIndex = store.currentChoices.findIndex((choice) => choice.choiceId === stance.choiceId)
store.choose(choiceIndex)
LogisticsPlanner.vue 和 DuelArena.vue 也是同一套路。choiceId 從哪來就是這篇一直在講的 # choice:<id> tag——它得寫在 [...] 選項方括號裡面(inline),inkjs 才會把它掛進 currentChoices[i].tags,StoryRuntime.ts 再解析成 choiceId 欄位。
這樣做有一個好處:小遊戲 UI 只是「選項的另一種呈現」,不是第二套劇情邏輯。真正的選項效果仍然留在 Ink 裡,像數值變化、旗標、跳轉,都不會散到 Vue 元件裡。
Day22 這篇最容易寫錯的地方,是把「有 case」講成「完整完成」。
例如:
bg_transition:parser 和 router 都認得,但 story.ts 目前的 setBackgroundTransition 是空函式relation_hint:parser 認得,router 目前忽略,關係變化提示主要還是靠 store 的差異偵測與 toastchoice:大量出現在 Ink 裡,主要用於選項 ID 驗證與唯一性檢查(validate-story.ts);不走 InkTagParser/StoryCommandRouter 這條 pipeline,而是由 StoryRuntime.ts 直接從 currentChoices[i].tags 解析出 choiceId,供調查模式、文辯/算籌/武鬥小遊戲、第一章地圖這幾個需要「用 id 反查選項」的 UI 使用sfx:router 會呼叫 AudioManager.playSFX()
這套 tag 系統有兩支很直接的測試:
scripts/test-ink-tag-parser.mjs
scripts/test-story-command-router.mjs
parser 測試確認主要 tag 會被解析成正確 type,也測了 # char 前綴、預設 outfit、cg_hide 這種無參數 tag、空 tag、缺參數、未知 tag。
router 測試則丟一整串 command 進去,確認:
chapter、scene、line 會進 piniabg、char、cg、vfx 會走 pixi contextbgm、ambience、sfx 會走 audio contextminigame 會變成 debate:CH02_DEBATE_01
ending:resolve:demo 會呼叫 resolve endingrelation_hint 和 choice 會被 ignoredunknown_tag 會被 invalid這些測試沒有模擬完整遊戲畫面,但它們守住了 tag 系統最重要的契約:字串進來後,要被解析、分派或明確忽略,不能卡在中間。
「兩個檔案各加一行邏輯」,方向沒錯,但我會講得更精準一點。
如果只是新增一個簡單 tag,通常會碰到:
InkTagParser.ts:加到 InkTagType、KNOWN_TAG_TYPES,必要時加 REQUIRED_ARG_COUNTS
StoryCommandRouter.ts:新增 case
story.ts:在 router context 裡提供真正改 store 或呼叫 manager 的 handlerGameView.vue
也就是說,不是所有 tag 都只改兩個檔案。兩個檔案是核心入口;如果要真的讓畫面動起來,還是要看它分派到哪個系統。
這個成本我覺得合理。因為每一步都很明確,不會出現「到底是哪個元件偷偷處理了這個 tag」的狀況。
Day22 的重點不是 tag 本身多聰明。它其實只是字串,甚至可以說有點笨。真正有用的是它讓劇本和系統都保持在自己的位置:Ink 寫「發生什麼」,Vue / Pixi / AudioManager 負責「怎麼呈現」。
這種邊界在小專案裡看起來有點囉嗦,但劇情量一大就會救命。因為你不會希望三個月後為了改一句台詞,順手把背景、音樂、小遊戲入口一起改壞。tag 系統不是為了炫技,是為了讓劇本能一直長下去,而程式還知道自己該去哪裡接它。