iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Modern Web

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

Day22 利用 Ink Tags 打造可擴充事件系統

  • 分享至 

  • xImage
  •  

Day8、Day9 提過 InkTagParserStoryCommandRouter,Day21 則談到 BGM、立繪、VFX 這些演出怎麼被觸發。Day22 就把這條線補完整:Ink tag 到底在《九重燼》裡扮演什麼角色?

簡單講,tag 是劇本和系統之間的接頭。

Ink 負責寫劇情、選項、變數和分支;Vue、Pixi、AudioManager 負責畫面與聲音。兩邊不能直接黏在一起,否則劇本會變成一堆 UI 指令,程式也會開始讀劇情文字做判斷。tag 的作用,就是讓劇本用很短的一行說:「這裡要換背景」、「這裡要播 BGM」、「這裡要打開小遊戲」。

為什麼不用在 Vue 裡判斷劇情文字

最危險的做法,是讓 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 不亂動。劇本和系統的邊界就乾淨很多。

從 raw tag 到 typed command

inkjs 給 StoryRuntime 的 tag 是字串陣列。也就是說,對程式來說,一開始拿到的只是:

['bg:chenghua_dian', 'char:xiao_chengyuan:default:wounded:center']

InkTagParser.ts 的任務是把這些字串轉成比較安全的 command。流程很小:

  1. 去掉前面的 #
  2. : 拆開 type 和 args
  3. 檢查 type 是否在支援清單
  4. 檢查必要參數數量
  5. 特別處理 char 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 可以選擇忽略它、記錄它,而不是讓錯誤在畫面上靜默消失。

目前 parser 認得哪些 tag

InkTagParser.tsKNOWN_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_hintchoice 會被 parser 視為合法 tag,但在 StoryCommandRouter 裡目前是進 onIgnoredCommand,主要是讓它們能出現在劇本裡、被驗證腳本掃到、保留給其他系統使用,而不是在 router 裡直接改畫面。

這種差別要講清楚。否則很容易把「格式已支援」寫成「功能已完成」。

Router:把 command 分派到不同系統

StoryCommandRouter.ts 不負責解析字串,它只看 InkTagCommand,再把 command 送到對應 context。

目前 context 分成五塊:

  • pinia:章節、場景、事件、line、speaker、小遊戲、UI、ending id
  • pixi:背景、立繪、CG、VFX
  • audio:BGM、環境音、SFX
  • save:autosave、checkpoint
  • system:結局解析、解鎖、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 自己反應。

一個重要保護:換背景時清掉殘留立繪和 CG

_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 記錄 changedBackgroundchangedCharacterchangedCg,讓 store 能在分派後做一次清理。

char tag 的小彈性:服裝可以省略

InkTagParserchar 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 不需要知道 rainscreen_shake 的實作細節,只看第二個參數是 startstop 還是其他模式。

這讓劇本語意很清楚:雨可以開始和停止,閃白或震動則是打一發。

小遊戲:一個字串掛出三種 Vue 元件

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 格式也不用改。

小遊戲元件怎麼選回 Ink choice

這裡還有一個細節:小遊戲 UI 沒有自己改變劇情狀態。

DebatePlanner.vue 為例,它有自己的卡片資料,例如「民為國本」、「君為國本」、「君民相制」。但玩家點卡片時,元件不是直接改變 Ink 變數,而是用 choiceId 找出對應的 Ink choice,最後呼叫:

const choiceIndex = store.currentChoices.findIndex((choice) => choice.choiceId === stance.choiceId)
store.choose(choiceIndex)

LogisticsPlanner.vueDuelArena.vue 也是同一套路。choiceId 從哪來就是這篇一直在講的 # choice:<id> tag——它得寫在 [...] 選項方括號裡面(inline),inkjs 才會把它掛進 currentChoices[i].tagsStoryRuntime.ts 再解析成 choiceId 欄位。

這樣做有一個好處:小遊戲 UI 只是「選項的另一種呈現」,不是第二套劇情邏輯。真正的選項效果仍然留在 Ink 裡,像數值變化、旗標、跳轉,都不會散到 Vue 元件裡。

預留 tag 和已完成 tag 要分清楚

Day22 這篇最容易寫錯的地方,是把「有 case」講成「完整完成」。

例如:

  • bg_transition:parser 和 router 都認得,但 story.ts 目前的 setBackgroundTransition 是空函式
  • relation_hint:parser 認得,router 目前忽略,關係變化提示主要還是靠 store 的差異偵測與 toast
  • choice:大量出現在 Ink 裡,主要用於選項 ID 驗證與唯一性檢查(validate-story.ts);不走 InkTagParser/StoryCommandRouter 這條 pipeline,而是由 StoryRuntime.ts 直接從 currentChoices[i].tags 解析出 choiceId,供調查模式、文辯/算籌/武鬥小遊戲、第一章地圖這幾個需要「用 id 反查選項」的 UI 使用
  • sfx:router 會呼叫 AudioManager.playSFX()

測試:parser 和 router 各有一支

這套 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 進去,確認:

  • chaptersceneline 會進 pinia
  • bgcharcgvfx 會走 pixi context
  • bgmambiencesfx 會走 audio context
  • minigame 會變成 debate:CH02_DEBATE_01
  • ending:resolve:demo 會呼叫 resolve ending
  • relation_hintchoice 會被 ignored
  • unknown_tag 會被 invalid

這些測試沒有模擬完整遊戲畫面,但它們守住了 tag 系統最重要的契約:字串進來後,要被解析、分派或明確忽略,不能卡在中間。

新增一種 tag 的實際成本

「兩個檔案各加一行邏輯」,方向沒錯,但我會講得更精準一點。

如果只是新增一個簡單 tag,通常會碰到:

  1. InkTagParser.ts:加到 InkTagTypeKNOWN_TAG_TYPES,必要時加 REQUIRED_ARG_COUNTS
  2. StoryCommandRouter.ts:新增 case
  3. story.ts:在 router context 裡提供真正改 store 或呼叫 manager 的 handler
  4. 測試:補 parser / router 測試
  5. 如果是新 UI:再加 Vue 元件,並掛到 GameView.vue

也就是說,不是所有 tag 都只改兩個檔案。兩個檔案是核心入口;如果要真的讓畫面動起來,還是要看它分派到哪個系統。

這個成本我覺得合理。因為每一步都很明確,不會出現「到底是哪個元件偷偷處理了這個 tag」的狀況。


Day22 的重點不是 tag 本身多聰明。它其實只是字串,甚至可以說有點笨。真正有用的是它讓劇本和系統都保持在自己的位置:Ink 寫「發生什麼」,Vue / Pixi / AudioManager 負責「怎麼呈現」。

這種邊界在小專案裡看起來有點囉嗦,但劇情量一大就會救命。因為你不會希望三個月後為了改一句台詞,順手把背景、音樂、小遊戲入口一起改壞。tag 系統不是為了炫技,是為了讓劇本能一直長下去,而程式還知道自己該去哪裡接它。


上一篇
Day21 打造更有沉浸感的演出:動畫與音效系統整合
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言