iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Modern Web

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

Day21 打造更有沉浸感的演出:動畫與音效系統整合

  • 分享至 

  • xImage
  •  

前面幾天分別講了轉場動畫(Day17)、響應式介面(Day18)和 AI 輔助素材(Day19)。
Day21 想把焦點拉回玩家實際感受到的東西:一段劇情推進時,背景換了、角色出現了、BGM 慢慢接上,某個瞬間再補一記音效或畫面特效。

這些效果單獨看都不大。可是真的玩起來,沉浸感往往就藏在這些小地方。

演出系統不是一支大怪獸

一開始很容易想像成有一個「演出控制器」統一管理所有東西:背景、立繪、BGM、音效、CG、震動、淡入淡出,全都排進一條 timeline。那種做法當然可以,但對《九重燼》現在這個 Web Demo 來說太重了。

目前比較務實的做法是:Ink 劇本只下 tag,Pinia 接收狀態,真正的效果分散到各自適合的位置。

Ink tag
  ↓
InkTagParser
  ↓
StoryCommandRouter
  ↓
Pinia / AudioManager / GameCanvas / CharacterPortrait

也就是說,劇本不直接呼叫 DOM、不直接碰 Pixi,也不直接 new Audio。劇本只說「現在要播哪首 BGM」、「誰站在哪裡」、「這裡來一次閃白」。後面怎麼播、怎麼淡入、怎麼避開瀏覽器限制,是系統層的事。

這條邊界守住,寫劇本的人才不會被實作細節拖住。

音訊四軌:BGM、Ambience、SFX、Voice

src/engine/AudioManager.ts 目前把聲音拆成四類:

  • BGM:背景音樂,雙軌 crossfade
  • Ambience:環境音,單獨循環播放
  • SFX:單發音效,每次建立一個新的 Audio
  • Voice:語音單軌,保留給未來語音

目前 public/assets/audio 底下實際有的是 bgm/,裡面放了 8 首正式接入的 mp3 和 1 個 placeholder
劇本格式不用等素材全部完成才定下來。Ink 裡可以先寫:

ink
# bgm:opening_theme
# ambience:palace_rain
# sfx:door_hit_01

目前有素材的就正常播,沒有素材的會走 fallback 或在驗證階段被抓出來。這比到後期才補音訊系統穩很多,因為 tag 規格、router、設定面板音量欄位都可以先跟著主流程一起測。

BGM:雙軌 crossfade,而不是硬切

BGM 最怕的不是沒聲音,而是切換太硬。劇情從調查場景進入質問場景,如果音樂突然斷掉再接下一首,玩家會立刻注意到「這是系統在換檔」,情緒就掉了。

AudioManager 的做法很直接:準備兩個 HTMLAudioElementbgmAudio1bgmAudio2。目前正在播的是 active audio,下一首先塞進另一條 audio,音量從 0 開始。接著用 setInterval 分 20 步調整音量,每步 50ms,所以一次 crossfade 大約 1000ms。

舊曲每一步降一點,新曲每一步升一點:

const FADE_STEPS = 20
const FADE_STEP_MS = 50

同曲不重播:避免劇本 tag 抖動

playBGM(bgmId, forceReplay = false) 裡還有一個小防護:

if (!forceReplay && bgmId === this.currentBgmId && this.pendingBgmId === null) return

意思是,如果劇本連續幾段都標同一首 BGM,不會每次 advance 都把音樂重播。這種情況在 Ink 很常見,因為不同 scene 或 checkpoint 可能都想明確標出「現在應該是這首歌」。系統如果照單全收,每走幾行就重播一次,聽起來會像音樂一直抽一下。

所以 tag 可以寫得保守一點,AudioManager 負責判斷「同曲不用動」。劇本安全,播放也穩。

Alias:劇本用語意 ID,素材用正式 ID

Day19 提過,BGM 進遊戲後不只是放檔案,還要通過 AudioManager 的 alias。

AudioManager.ts 裡有一張對照表:

const bgmAssetAliases: Record<string, string> = {
  opening_theme: 'bgm_001_dream_wakes_in_nine_palaces',
  palace_rain: 'bgm_002_rain_over_chenghua',
  tension_theme: 'bgm_003_blood_on_white_jade',
  emperor_theme: 'bgm_004_emperor_question_at_midnight',
  bgm_hidden_edge: 'bgm_006_hidden_edge',
  bgm_white_plum_scroll: 'bgm_007_white_plum_old_scroll',
}

這層對劇本很友善。Ink 可以寫 # bgm:opening_theme,不用記完整檔名 bgm_001_dream_wakes_in_nine_palaces。如果以後檔案換名或正式曲替換,只要改 alias 或 asset manifest,不需要整批改 Ink。

但這裡也有一個現實限制:alias 目前主要是 BGM 用。Ambience 和 SFX 走同一個 resolveSource(),如果沒有在 asset manifest 或 alias 找到,就會 fallback 到 /audio/${audioId}.mp3。所以未來要大量接環境音和音效時,不能只靠現在這張 BGM alias,還需要把資產資料整理完整。

瀏覽器限制:第一次互動後補播

Web 遊戲做音訊,最常撞到的不是程式邏輯,而是瀏覽器 autoplay policy。頁面一載入就想播 BGM,瀏覽器可能會擋下來,尤其是玩家還沒點過畫面時。

AudioManager 對這件事的處理很務實。playBGM()playAmbience() 呼叫 audio.play() 失敗時,會把 id 存到 pendingBgmIdpendingAmbienceId,然後等 pointerdownkeydown 發生,再重試一次。

window.addEventListener('pointerdown', unlock, { passive: true })
window.addEventListener('keydown', unlock)

這不會讓 autoplay 限制消失,但可以讓玩家第一次點擊遊戲時,把原本被擋下來的音樂補回來。對文字冒險來說,這很重要。開場如果因為瀏覽器限制完全靜音,玩家不一定知道是瀏覽器擋了,只會覺得遊戲沒聲音。

頁面失焦:暫停,回來再恢復

另一個容易忽略的是切分頁。玩家切去查資料或回訊息,如果 BGM 在背景繼續播,會很煩;但切回來又不能完全忘記剛剛播到哪。

AudioManager 在 constructor 裡掛了 visibilitychange

document.addEventListener('visibilitychange', () => {
  if (document.hidden) this.pauseAll(true)
  else this.resumeAll(true)
})

失焦時暫停 BGM、Ambience、Voice;回來時只恢復目前記錄中的 BGM 和環境音。fromVisibility 這個參數用來區分「頁面失焦自動暫停」和「外部手動暫停」,避免玩家手動停掉音樂後,切回頁面又被自動打開。

音量設定:UI 是百分比,AudioManager 收 0 到 1

設定面板裡的音量是玩家看得懂的 0 到 100。story.ts 送進 AudioManager 前會除以 100:

AudioManager.getInstance().setVolumes(
  this.settings.bgmVolume / 100,
  this.settings.sfxVolume / 100,
  this.settings.ambienceVolume / 100,
)

AudioManager.setVolumes() 裡再把值 clamp 到 0 到 1,並且立刻套用到正在播放的通道。BGM、SFX、Ambience、Voice 各自有獨立音量欄位。

UI 用百分比,AudioManager 用瀏覽器 audio element 的標準音量範圍。不要讓 UI 元件到處自己除以 100,也不要讓 AudioManager 去理解「設定面板顯示什麼文字」。

人物進退場:一行 tag,兩層處理

劇本裡控制角色出場只需要一行 tag:

ink
# char:qinghe:palace:wounded:left

固定順序是 character id、outfit id、expression id、position。InkTagParser 會把它解析成 CharacterTagCommandStoryCommandRouter 再呼叫 pixi.setCharacter(characterId, outfitId, expressionId, position)

名字叫 pixi.setCharacter,但目前實際顯示立繪的是 CharacterPortrait.vue 這個 DOM 元件,不是 Pixi sprite。story.ts 收到 command 後只是把狀態寫進 Pinia:

this.charId = characterId
this.charOutfit = outfitId
this.expr = expressionId
this.charPos = position

接著 CharacterPortrait.vue 根據 store.charIdstore.charOutfitstore.expr 找圖,透過 Vue <Transition name="portrait" mode="out-in"> 做淡入淡出、位移、縮放和 blur。角色離場則是:

ink
# char_hide:qinghe

router 會把 charIdexprcharPos 清掉,元件自然離場。

router context 裡叫 pixi,但立繪現在留在 DOM。原因很簡單,立繪是大型 <img>,要配合 CSS、RWD、對話框遮擋和透明邊緣裁切,用 Vue/CSS 處理比較省事。Pixi 留給背景、CG、粒子、震動這類畫面層。

背景、CG、VFX:走 GameCanvas 和 SceneManager

除了立繪之外,畫面演出主要由 GameCanvas.vue 監看 store,再交給 SceneManager

目前 store 裡有和演出有關的欄位:

  • sceneId:背景
  • cgId:CG
  • cgAnim:CG 縮放與位移
  • effectId:持續型特效,例如 rain、candlelight
  • oneShotFx:一次性特效,例如 tension_flash、fade_out、screen_shake

StoryCommandRouter 收到 # vfx:rain:start 會走 setPersistentVfx(),收到其他一次性效果則走 triggerOneShotVfx()oneShotFx 裡的 seq 會遞增,這樣同名效果也可以連續重播,不會因為 Vue watch 覺得值沒變就漏掉。

Day17 已經講過,SceneManager 裡的特效也不是全部都做完美 timeline,而是先支援幾個遊戲真的用得到的:雨、燭光、血跡、dust、memory_blur、閃白、閃電、淡出、震動。screen_shake 還會尊重設定裡的 disableShakereduceMotion 開啟時也會跳過持續型動態效果。

演出節奏:tag 的順序比效果數量重要

真正決定一段演出有沒有感覺的,不是 tag 越多越好,而是 tag 出現的時機。

例如一個驗屍場景可以這樣拆:

ink
# bg:chenghua_bedroom_night_rain
# ambience:palace_rain
# bgm:tension_theme

雨聲壓在窗外。

# char:xiao_chengyuan:casual_red:wounded:center
# speaker:xiao_chengyuan
她不是在這裡受的第一刀。

# vfx:lightning_flash:once
# sfx:thunder_01

如果一開始就把背景、角色、閃電、音效全部同時砸出來,畫面會很忙,讀者反而沒時間吸收。先讓環境成立,再讓角色說話,最後補一次閃電,節奏會比較像一段戲。

這也是系統幫不了太多的地方。AudioManager 可以讓音樂平滑,CharacterPortrait 可以讓立繪淡入,SceneManager 可以讓畫面閃一下;但哪個 tag 先出現、哪一句台詞前要留白,還是劇本和演出直覺。

目前還沒做完的地方

幾個明確的缺口:

  • Ambience / SFX / Voice 通道存在,但素材資料夾和 manifest 還沒完整量產
  • Voice 目前是單軌替換式播放,還沒有角色語音台詞對應規格
  • AudioManager 還是 HTMLAudioElement 做法,未來如果要精準排程、多層混音或音效池,可能需要 Web Audio API

這些缺口不代表現在不能用,而是要分清楚「架構入口已經留好」和「內容已經完整製作」的差別。


Day21 的重點其實很簡單:沉浸感不是某一個華麗特效做出來的,而是一串小決策累積起來的。BGM 不硬切、同曲不重播、瀏覽器擋播放時能補播、切分頁會暫停、立繪進退場有過渡、震動可以關掉,這些都不會單獨讓玩家驚呼。

但少了其中幾個,玩家很快就會覺得哪裡卡。演出系統最好的狀態,反而是玩家不太注意它,因為聲音、畫面和文字都剛好在該出現的時候出現。


上一篇
Day20 AI 如何協助劇情與程式開發
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言