iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Modern Web

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

Day16 Vue3 響應式狀態與 Ink.js State 同步機制

  • 分享至 

  • xImage
  •  

正向:Ink 變數 → Pinia,靠一次性「全量讀取」而不是逐筆推播

StoryRuntime 裡的 inkjs Story 物件沒有直接放進 Pinia state,而是用模組層級變數 runtime 存在 store 外面。
原因很實際:inkjs runtime 不是拿來做 Vue reactive tracking 的資料結構,它有自己的狀態、選項、變數表和 JSON 匯出匯入流程。

所以同步策略不是「Ink 變數一改就推事件」。story.ts 會在幾個明確節點呼叫 _syncVariables(),把一批前端的 Ink 變數整批讀回 Pinia:

  • advance() 讀完一行、套完 tag、更新選項後
  • choose() 選項往前推進後,透過後續 advance() 同步
  • load() 匯入存檔後,用 _syncVariables(true) 靜默校正
  • loadLegacySave() 匯入舊版存檔後,也用靜默同步
for (const key of STAT_KEYS) this.stats[key] = read(key)
this.investigationRemaining = read('investigation_remaining')
this.ch01ActionPoints = read('ch01_action_points')

for (const { id, relKey } of REL_CHARACTERS) {
  for (const metric of REL_METRICS) {
    this.relations[id][metric] = read(`rel_${relKey}_${metric}`)
  }
}

這比較像「每走一步,把目前所有關心的變數重新拉一次」的快照同步。Ink 腳本裡一個選項可能同時改十幾個變數,逐筆推播事件反而麻煩;全量拉一次雖然看起來粗,但不容易漏。

_syncVariables() 讀的不只數值和關係,也包含第一章自由行動用的 ch01_action_points、調查旗標、已去過的地點,以及角色事件判斷需要的一批變數。這也是為什麼它是 store 內部方法,而不是散落在各元件裡。元件不該知道 Ink 變數名。

一個順手做的細節:拉取時順便做「差異偵測」

_syncVariables() 在更新角色關係數值時,會拿新值跟 Pinia 裡的舊值比較,如果變了就丟一個 Toast 通知:

if (val > oldVal) toast.add('positive', `【${characterName}】${metricName}上升了`)
else if (val < oldVal) toast.add('negative', `【${characterName}】${metricName}下降了`)

這代表「玩家看到的『信任上升了』提示」,不是 Ink 腳本主動告訴前端的,而是前端自己比對前後兩次同步的差異算出來的。好處是 Ink 腳本完全不用管 UI 要不要顯示提示;該不該顯示、怎麼顯示,是前端這一層自己的責任。

這裡也有一個防洗版設計:_syncVariables(silent = false) 可以關掉 Toast。讀檔時會用 _syncVariables(true),否則一讀進舊存檔,所有關係值都跟預設值不同,畫面右上角會瞬間跳出一串「上升了/下降了」。那不是玩家剛剛做出的選擇,只是還原狀態,不該顯示。

Toast 自己也不是 Event Bus。useToastStore() 只是另一個 Pinia store,裡面有 queueadd(),最多保留 5 則提示。它是 UI 狀態,不是跨系統訊息總線。


Tag 指令:先寫 Pinia,再讓畫面自己反應

Ink 除了變數,還會透過 tag 告訴前端要換背景、換角色、播 BGM、觸發特效。這條路徑也沒有直接操作 Vue 元件。

advance() 每讀到一行,就把當行 tags 丟給 _applyTags()_applyTags() 先用 InkTagParser 解析,再交給 StoryCommandRouter 分派。Router 收到的 context 分成幾組 handler:

  • pinia:章節、場景代碼、說話者、小遊戲、結局
  • pixi:背景、角色、CG、VFX
  • audio:BGM、環境音、音效
  • save:自動存檔
  • system:結局判定、解鎖、Toast

pixi.setBackground(),在 story.ts 這層做的事情是寫入 store:

ts
setBackground: (id) => {
  this.sceneId = id
}
setCharacter: (characterId, outfitId, expressionId, position) => {
  this.charId = characterId
  this.charOutfit = outfitId
  this.expr = expressionId
  this.charPos = position
}

這個分工很重要。Ink tag 不直接碰 SceneManager,也不直接碰 DOM;它只改 Pinia。接下來畫面怎麼變,由 Vue 的 reactivity 決定。

反向:Pinia → PixiJS,靠 Vue 原生的 watch

Day8/15 提過 Pixi 只管畫面。它怎麼知道該畫什麼?答案在 GameCanvas.vue:它掛載時對 store 的幾個欄位設 watch

ts
watch(() => store.sceneId, (id) => {
  if (id) void scene?.setScene(id, gameData.scenes[id]?.background)
})
watch(() => store.cgId, (id) => {
  if (id && gameData.cgs?.[id]) void scene?.setCg(gameData.cgs[id].background)
  else scene?.hideCg()
})

Pinia 的 reactivity 系統本身就是一套現成的「狀態變了就通知訂閱者」機制,不需要再另外接一層 Event Bus——watch() 訂閱的目標就是 store 的欄位本身,欄位一變,對應的 Pixi 呼叫就自動觸發。

這個檔案實際上 watch 的不只背景和 CG。還有:

  • store.cgAnim?.seq:同一張 CG 可以重播位移縮放動畫
  • store.settings.lowPerformancevfxParticleDensity:即時調整粒子數量
  • store.effectIdreduceMotion:持續型特效開始或停止
  • store.oneShotFx.seq:一次性特效重播,例如閃白、震動、淡出

還有一個容易漏掉但很重要的細節:元件掛載當下(onMounted)要手動補畫一次目前狀態。GameCanvas.vue 會檢查 store.sceneIdstore.effectIdstore.cgId,如果已經有值,就立刻呼叫 setScene()setEffect()setCg()

原因是 watch 只在「值改變」時觸發。讀檔還原或元件重新掛載時,值可能沒有變化,只是元件是新的;這時候光靠 watch 不會補畫面。


為什麼不用真正的 Event Bus?

這個問題我卡過一陣子。Event Bus 聽起來很適合遊戲:sceneChangedcharacterChangedvfxTriggeredsaveRequested,每個事件都很漂亮。但真做下去會有幾個問題。

第一,事件順序會變得很難追。Ink 一行可能同時有 # bg# char# bgm# vfx,如果每個 tag 都發事件,畫面最後是誰先誰後,要查事件流。

第二,事件通常是瞬間的,但遊戲需要的是可還原狀態。讀檔時你不是要「重新播放所有事件」,而是要把畫面還原成某個狀態:背景是什麼、角色是誰、BGM 是哪首、選項有哪些。Pinia state 很適合做這件事。

第三,Vue 已經有 reactivity。再加一層 Event Bus,很多時候只是把 watch(store.sceneId) 換成 on('sceneChanged'),但失去的是狀態可見性。現在打開 Pinia devtools 或看 store,就能知道畫面為什麼長這樣。


結論
Ink → Pinia 是「走一步,全量拉一次快照,順便算差異」;Ink tag → store handlers 是「把畫面意圖寫成 store 欄位,必要時才觸發音訊、存檔這類副作用」;Pinia → Pixi/Vue UI 是「靠 Vue 原生 reactivity 的 watch 和 computed,值變了就重畫」。

這三條路徑合起來,就是整個系統「狀態永遠只有一份、畫面永遠只是狀態的投影」這件事,在沒有額外事件系統的情況下是怎麼做到的。因為 bug 出現時,我大多只要問一件事:現在 store 裡的值對不對?


上一篇
Day15 PixiJS 進階:Sprite、Container 與圖層管理策略
下一篇
Day17 動畫系統實作:轉場、淡入淡出
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言