iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Modern Web

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

Day8 遊戲架構設計:Scene、State、UI 如何分層?

  • 分享至 

  • xImage
  •  

Day8要進到實作核心——一行 Ink 文字,到底怎麼一步步變成畫面上的背景切換、立繪出現、對話框打字?

其實說穿了就是一條路:advance() 被叫一次,Ink 往前走一行,tag 被解析,各層收到通知各自做各自的事。把這條路搞清楚,後面很多問題就自然有答案了。


StoryRuntime:刻意做「薄」的執行核心

src/engine/StoryRuntime.ts 是最底層,它包了 inkjs 的 Story 物件,但刻意只暴露幾個動作:

  • continueLine() — 往前走一行,回傳 { text, tags, choices }
  • choose(index) — 做選擇
  • jumpTo(knot) — 跳到指定節點
  • getVariable / setVariable — 讀寫 Ink 變數
  • exportState / importState — 存讀檔用的 JSON 快照

薄到什麼程度?它完全不知道畫面長什麼樣。你問它「現在背景是什麼」,它不知道,也不在乎。它唯一的任務是「照 Ink 腳本邏輯,給我下一行的內容和附帶指令」。

生產環境不會在瀏覽器即時編譯 .ink,而是直接 import 預先編譯好的 main.json

import compiledStory from '../narrative/compiled/main.json'

start(): void {
  this.story = new Story(compiledStory)
}

編譯這步在 CI build 時就跑完了,瀏覽器只管執行——inkjs 的 browser 端即時編譯又慢又大包,能省就省。StoryRuntime 是整個系統裡最穩定的部分,自從設計確定之後幾乎就沒動過,因為它負責的事情實在太少了。


advance():所有事情的起點

StoryRuntime 是底層,Pinia storeadvance() 才是「按一下繼續」真正發生的地方:

async advance(): Promise<void> {
  if (!runtime || this.choices.length > 0) return
  if (!runtime.canContinue) {
    this.ended = true
    return
  }
  let text = ''
  while (runtime.canContinue) {
    const line = runtime.continueLine()
    text = line?.text ?? ''
    await this._applyTags(line?.tags ?? [])  // ← tag 在這裡被處理
    if (text) break                           // ← 跳過 tag-only 空行
  }
  if (text) {
    this.text = text
    this.log.push({ speakerId: this.speakerId, text, chapterId: this.chapterId })
    if (this.log.length > 1000) this.log.shift()  // ← 回顧紀錄上限 1000 筆
  }
  this.choices = runtime.choices.map(c => c.text)
  this._syncVariables()   // ← Ink 變數同步回 Pinia
  if (this._pendingAutosave) {
    const label = this._pendingAutosaveLabel
    this._pendingAutosave = false
    void this.save(AUTO_SAVE_SLOT_ID, 'auto', label)
  }
  // 地圖面板開著但沒有選項、劇本還能繼續 → 遞迴自動吃完剩餘文字
  if (this.mapPanel && this.choices.length === 0 && runtime.canContinue) {
    await this.advance()
  }
}

注意那個 while:Ink 腳本裡有些行是純 tag、沒有對白文字,例如剛進場景時連著打好幾行 # bg:xxx# bgm:xxx# char:xxxadvance() 會一直往前吃,直到找到真正有文字的那一行才停下來——這樣玩家不會看到一個空白的對話框閃一下。

結尾那個地圖遞迴也值得說一下。地圖面板打開的當下,DialogueBox 會擋住玩家點擊繼續。
# ui:show_map 這個 tag 常常出現在還有幾行旁白文字之前——地圖開了,旁白還沒讀完,玩家看到地圖介面,但選項遲遲不出現,就會卡住。
解法是:偵測到地圖開著但沒有選項,advance() 自動吃完剩下的行直到選項出現,玩家打開地圖就能直接看到可以選的地點。


_applyTags():tag 到畫面的橋接層

advance() 裡的 _applyTags() 是整個架構裡最長的一個方法,做的事是:把每一行附帶的 tag 字串陣列,交給 InkTagParser 解析,再交給 StoryCommandRouter 分派到五個 context:

raw tags: ['bg:chenghua_dian', 'bgm:opening_theme', 'char:prince:default:smile:left']
    ↓ InkTagParser.parseInkTags()
typed commands: [
  { type: 'bg',   args: ['chenghua_dian'] },
  { type: 'bgm',  args: ['opening_theme'] },
  { type: 'char', characterId: 'prince', outfitId: 'default',
    expressionId: 'smile', position: 'left' }
]
    ↓ StoryCommandRouter.routeStoryCommands(commands, context)
→ pixi.setBackground('chenghua_dian')   ← 更新 store.sceneId
→ audio.setBgm('opening_theme')         ← AudioManager 播放
→ pixi.setCharacter(...)               ← 更新 store.charId / expr / charPos

StoryCommandRouter 接到 typed command 陣列,用一個 switch 分派到五個 context:

context 負責的 tag 類型
pinia chapter, scene, event, line, speaker, minigame, ui, ending
pixi bg, bg_transition, char, char_hide, cg, cg_hide, cg_anim, vfx
audio bgm, bgm_stop, ambience, ambience_stop, sfx
save autosave, checkpoint
system ending:resolve, unlock, toast

Router 本身不含任何業務邏輯,只負責分派。這條界線在實際開發裡很有用:像新增 toast tag 讓 Ink 劇本直接彈出提示框這個功能,InkTagParser.tsStoryCommandRouter.ts 只是負責把 tag 解析成 typed command 再分派出去;真正要能在畫面上跳出提示,還是得建一個新的 Vue 元件 SystemToasts.vue,掛進 GameView.vue,並在 story.ts 接上 useToastStore。Router 這層省掉的是「Ink 腳本怎麼觸發」的判斷邏輯,不是整個功能的畫面實作。

_applyTags() 裡還有兩條自動清理規則,這是從實際踩坑經驗加進去的:

  1. 換背景時沒有重新指定立繪 → 自動清空上一幕角色
    切場景了但角色殘留在畫面上,看起來像鬼魂。現在只要 changedBackground && !changedCharacter,自動把 charId / expr / charPos 設成 null。

  2. 換背景時沒有重新指定 CG → 自動關閉 CG
    滿版 CG 如果沒有明確 # cg_hide,會一路蓋住後續場景,變成黑屏。changedBackground && !changedCg 就自動清。


choose():選項不只是送數字

玩家選了一個選項,choose() 做了幾件事:

choose(index: number): void {
  const choiceText = this.choices[index]
  if (choiceText) {
    // 把選擇加進對話回顧,讓玩家能回頭看自己選了什麼
    this.log.push({ speakerId: null, text: choiceText, isChoice: true, chapterId: this.chapterId })
  }
  this.mapPanel = null   // 選完自動關地圖
  this.mapError = null
  runtime.choose(index)  // 告訴 inkjs 選哪個
  this.choices = []      // 清掉選項,避免 UI 殘留
  void this.advance()    // 直接往下走
}

對話回顧(backlog)把玩家自己做的選擇也記進去,而不只記台詞,這讓回顧面板讀起來更像完整的對話紀錄,而不是只有一堆旁白跟台詞、看不出選了什麼。

地圖選點是 choose() 的一個特化版本——chooseMapLocation() 先做路徑判斷(是否還有足夠行動點、是否已完成必要地點),再把地名轉成 choices 陣列裡對應的 index,最後呼叫同一個 choose(),不另外刻一套邏輯。


_syncVariables():Ink 變數怎麼流進 Vue

每次 advance() 結束都會跑一次 _syncVariables(),把 inkjs runtime 裡的各項數值讀出來,寫進 Pinia state,讓 Vue 的 reactivity 自動觸發 UI 更新:

_syncVariables(silent = false): void {
  // 數值(power / compassion / ...)
  for (const key of STAT_KEYS) this.stats[key] = read(key)

  // 關係數值 → 有變動時自動 toast 提示
  for (const { id, relKey } of REL_CHARACTERS) {
    const rel: RelationMetrics = {}
    for (const metric of REL_METRICS) {
      const val = read(`rel_${relKey}_${metric}`)
      const oldVal = this.relations[id]?.[metric]
      if (!silent && oldVal !== undefined && val !== oldVal) {
        const name = gameData.characters[id]?.displayName || id
        const metricName = gameData.relationMetrics[metric] || metric
        if (val > oldVal) useToastStore().add('positive', `【${name}】${metricName}上升了`)
        else               useToastStore().add('negative', `【${name}】${metricName}下降了`)
        // metricName 直接來自 relationMetrics 的顯示名(好感/信任/立場/畏懼/理念/敵意),不含「度」字尾
      }
      rel[metric] = val
    }
    this.relations[id] = rel
  }

  // 第一章自由行動狀態
  this.ch01ActionPoints = read('ch01_action_points')
  this.ch01PlacesVisited = read('ch01_places_visited')
  for (const [locationId, flagKey] of Object.entries(CH01_MAP_LOCATION_FLAGS)) {
    this.ch01VisitedLocations[locationId] = runtime.getVariable(flagKey) === true
  }

  // 番外篇完成度
  this._syncExtraStoryCompletions(readValue)
}

關係數值變化的 toast 提示是一個有趣的設計點:同步時如果偵測到某個角色的好感、信任、立場、畏懼、理念、敵意任一項和上一次不一樣,就自動彈出「【柳如煙】好感上升了」這種提示(指標名稱直接取自 relationMetrics 的顯示名,不加「度」字尾),不需要 Ink 劇本裡手動寫 # toast:...。這樣劇本作者不用每個選項後面都補一行 toast tag,_syncVariables() 自動比對差值就能做到。

silent = true 是讀取存檔時用的——還原存檔狀態的時候不應該噴一堆「某某關係變化」的舊提示,所以靜默模式跳過 toast。


SceneManager:背景不會閃、素材缺了也不白屏

SceneManager 是 PixiJS 這邊的管理者。它的 setScene() 裡有個細節值得講:

async setScene(sceneId: string, backgroundUrl?: string): Promise<void> {
  const request = ++this.sceneRequest   // ← 遞增請求編號

  if (backgroundUrl) {
    const texture = await this._loadTextureWithRetry(backgroundUrl)
    if (request !== this.sceneRequest) return  // ← 競態保護:舊請求直接丟棄
    if (texture) {
      this._clearBackground()
      this.currentBackground = new Sprite(texture)
      this._layoutScene()
      return
    }
  }
  if (request === this.sceneRequest) this._drawGradient()  // ← fallback
}

sceneRequest 計數器解決了一個非同步競態問題:場景切換要等圖片下載,如果快速跳轉,可能「第二張圖先下載完、第一張圖後下載完,第一張圖反而把畫面蓋掉」。有了計數器,最新的請求編號才有資格更新畫面,舊的請求圖片回來了也直接忽略。

圖片下載:最多重試三次

private async _loadTextureWithRetry(url: string, retries = 2) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await Assets.load(url)
    } catch {
      if (attempt === retries) {
        console.warn(`場景素材載入失敗(已重試 ${retries} 次),改用漸層:${url}`)
        return null
      }
      await new Promise(r => setTimeout(r, 300 * (attempt + 1)))  // 300ms / 600ms
    }
  }
}

第一次失敗等 300ms,第二次等 600ms,三次全失敗才 fallback 到漸層色,不會因為一次短暫網路抖動就立刻退化。

漸層 fallback:48 個色帶,不是 CSS gradient

真正的漸層不是用 CSS,是用 PixiJS Graphics 一條一條畫:

private _drawGradient(): void {
  const [top, bottom] = this.currentPalette
  const g = new Graphics()
  const bands = 48
  for (let i = 0; i < bands; i++) {
    const t = i / (bands - 1)
    g.rect(0, (this.h / bands) * i, this.w, this.h / bands + 1)
     .fill(lerpColor(top, bottom, t))
  }
  this.bgLayer.addChild(g)
}

為什麼不用 CSS?因為這是 PixiJS canvas 內部的東西,CSS gradient 沒辦法塞進去。48 個色帶夠多,視覺上看起來平滑,承華殿是深藍紫、朝堂是深琥珀、聽雨樓是深紫——至少不是一片黑。

resize 的兜底機制

ResizeObserver 監聽容器尺寸變化,讓 canvas 隨視窗縮放。但有些嵌入環境(iframe、預覽面板)不一定會正確觸發 ResizeObserver,所以還有一個 ticker 兜底:每 250ms 比對一次容器尺寸和 canvas 尺寸,不一致就強制 resize:

private _checkResize(deltaMS: number): void {
  this.resizeCheckAccum += deltaMS
  if (this.resizeCheckAccum < 250) return
  this.resizeCheckAccum = 0
  if (host.clientWidth !== canvas.clientWidth || host.clientHeight !== canvas.clientHeight) {
    this.app.resize()
  }
}

兩層保護,ResizeObserver 掛了還有 ticker 兜著,不會出現「視窗縮小了但 canvas 還是原來大小」的情況。


VFX 的實作方式:純粹的 PixiJS Graphics + Ticker

特效不靠任何粒子函式庫,全部是用 PixiJS Graphics + Ticker 手刻的程序式動畫,分成兩類:

持續型(進場景後一直跑)

特效 粒子數 說明
rain 140 個 每幀重繪所有雨滴,隨機速度 10–24、長度 14–32
candlelight 全畫面暖橙色半透明遮罩,亮度隨 sin 波動
blood 30 個 從畫面上方落下的血滴,隨機大小與 alpha
dust 80 個 細碎浮塵,帶左右隨機漂移速度
memory_blur 動態載入 PixiJS BlurFilter,套在 bg/char/cg 三層上

一次性(播完自動移除)

特效 說明
tension_flash 白色遮罩快速淡出,每幀 alpha -= 0.045
lightning 兩段閃爍:0–50ms 全亮 → 50–100ms 滅 → 100–150ms 八成亮 → 淡出
screen_shake 360ms 震動,強度 10,每幀在 stage 上加隨機偏移
fade_out 黑色遮罩淡入(hold 600ms)再淡出

雨滴預設 140 個粒子,低效能模式用 particleScale 縮減。重要的是:改設定之後如果特效正在跑,會立刻清掉重建,不用等到下個場景才生效:

setParticleScale(scale: number): void {
  if (this.particleScale === scale) return
  this.particleScale = scale
  if (this.activeEffectId) {
    const active = this.activeEffectId
    this._clearPersistent()
    this.setEffect(active)   // ← 立即以新粒子數重建
  }
}

Pinia state:store 裡藏了多少東西

story.ts 的 state 分六塊,大到小列一次:

// 劇情位置
chapterId, sceneCode, eventId, lineId

// 當前這一行
text, speakerId, choices

// 畫面狀態(會同步給 PixiJS 元件)
sceneId, charId, charOutfit, expr, charPos,
cgId, cgAnim, effectId, oneShotFx, bgm, ambience

// 玩家數值與關係(從 Ink 同步過來)
stats, relations,
investigationFlags, investigationRemaining,
ch01ActionPoints, ch01PlacesVisited, ch01VisitedLocations,
evidenceRecords, characterEventFlags

// UI 面板狀態
uiPanel, mapPanel, minigame, evidencePopupQueue, mapError

// 對話回顧
log[](最多 1000 筆,超過自動 shift 掉最舊的)

_syncVariables() 在每次 advance() 結束後跑一次,把 inkjs 裡的值全部讀出來更新到 store。Vue 元件(例如 StatsPanel)只讀 store,不直接碰 inkjs——這樣哪天換掉底層的敘事引擎,Vue 元件那邊理論上一行都不用改。


單向資料流,只有一個方向

把這整條路徑收回來看:

玩家點擊 / 按空格
  ↓
store.advance()
  ↓
StoryRuntime.continueLine()     → 吐出 text + tags + choices
  ↓
_applyTags()
  ├─ InkTagParser → typed commands
  └─ StoryCommandRouter
       ├─ pixi.setBackground()  → store.sceneId 更新 → GameCanvas watch → SceneManager
       ├─ pixi.setCharacter()   → store.charId/expr 更新 → CharacterPortrait 更新
       ├─ audio.setBgm()        → AudioManager.playBGM() 直接呼叫
       ├─ pinia.setSpeakerId()  → store.speakerId 更新
       └─ save.requestAutosave()→ 標記 pendingAutosave
  ↓
_syncVariables()
  └─ 讀所有 Ink 變數寫回 store.stats / store.relations
     → StatsPanel / 關係提示 Toast 自動更新
  ↓
store.text / speakerId / choices 全部 reactive
  ↓ (Vue reactivity)
DialogueBox 顯示新文字、ChoiceMenu 顯示新選項

這條流只有一個方向——畫面不會主動改劇情,劇情不會主動改畫面,所有狀態的更新都從 advance() 這個入口進來。這在 debug 的時候特別有用:看到畫面上哪裡不對,直接往 advance()_applyTags() 找,不用猜是哪個 Vue 元件偷偷改了什麼。


這週把 advance() 這條核心路徑的每個環節都看了一遍。
下週要講的是這個架構裡兩個更複雜的部分:存讀檔(怎麼把 Ink state + Pinia state + 畫面狀態全部打包成一份快照,以及讀回來的時候畫面要怎麼還原)、以及 inkjs 的 Variable Bridge(Ink 變數怎麼跟 Vue 的 reactivity 保持同步而不出現雙向修改的問題)。


上一篇
Day7 技術選型與專案架構:為什麼選 Vue3、PixiJS、Ink.js?
下一篇
Day9 Ink.js 劇本整合:如何讓 Vue3 與 Ink.js 溝通?
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言