前面幾天分別講了轉場動畫(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」、「誰站在哪裡」、「這裡來一次閃白」。後面怎麼播、怎麼淡入、怎麼避開瀏覽器限制,是系統層的事。
這條邊界守住,寫劇本的人才不會被實作細節拖住。
src/engine/AudioManager.ts 目前把聲音拆成四類:
Audio
目前 public/assets/audio 底下實際有的是 bgm/,裡面放了 8 首正式接入的 mp3 和 1 個 placeholder
劇本格式不用等素材全部完成才定下來。Ink 裡可以先寫:
ink
# bgm:opening_theme
# ambience:palace_rain
# sfx:door_hit_01
目前有素材的就正常播,沒有素材的會走 fallback 或在驗證階段被抓出來。這比到後期才補音訊系統穩很多,因為 tag 規格、router、設定面板音量欄位都可以先跟著主流程一起測。
BGM 最怕的不是沒聲音,而是切換太硬。劇情從調查場景進入質問場景,如果音樂突然斷掉再接下一首,玩家會立刻注意到「這是系統在換檔」,情緒就掉了。
AudioManager 的做法很直接:準備兩個 HTMLAudioElement,bgmAudio1 和 bgmAudio2。目前正在播的是 active audio,下一首先塞進另一條 audio,音量從 0 開始。接著用 setInterval 分 20 步調整音量,每步 50ms,所以一次 crossfade 大約 1000ms。
舊曲每一步降一點,新曲每一步升一點:
const FADE_STEPS = 20
const FADE_STEP_MS = 50
playBGM(bgmId, forceReplay = false) 裡還有一個小防護:
if (!forceReplay && bgmId === this.currentBgmId && this.pendingBgmId === null) return
意思是,如果劇本連續幾段都標同一首 BGM,不會每次 advance 都把音樂重播。這種情況在 Ink 很常見,因為不同 scene 或 checkpoint 可能都想明確標出「現在應該是這首歌」。系統如果照單全收,每走幾行就重播一次,聽起來會像音樂一直抽一下。
所以 tag 可以寫得保守一點,AudioManager 負責判斷「同曲不用動」。劇本安全,播放也穩。
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 存到 pendingBgmId 或 pendingAmbienceId,然後等 pointerdown 或 keydown 發生,再重試一次。
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 這個參數用來區分「頁面失焦自動暫停」和「外部手動暫停」,避免玩家手動停掉音樂後,切回頁面又被自動打開。
設定面板裡的音量是玩家看得懂的 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:
ink
# char:qinghe:palace:wounded:left
固定順序是 character id、outfit id、expression id、position。InkTagParser 會把它解析成 CharacterTagCommand,StoryCommandRouter 再呼叫 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.charId、store.charOutfit、store.expr 找圖,透過 Vue <Transition name="portrait" mode="out-in"> 做淡入淡出、位移、縮放和 blur。角色離場則是:
ink
# char_hide:qinghe
router 會把 charId、expr、charPos 清掉,元件自然離場。
router context 裡叫 pixi,但立繪現在留在 DOM。原因很簡單,立繪是大型 <img>,要配合 CSS、RWD、對話框遮擋和透明邊緣裁切,用 Vue/CSS 處理比較省事。Pixi 留給背景、CG、粒子、震動這類畫面層。
除了立繪之外,畫面演出主要由 GameCanvas.vue 監看 store,再交給 SceneManager。
目前 store 裡有和演出有關的欄位:
sceneId:背景cgId:CGcgAnim:CG 縮放與位移effectId:持續型特效,例如 rain、candlelightoneShotFx:一次性特效,例如 tension_flash、fade_out、screen_shakeStoryCommandRouter 收到 # vfx:rain:start 會走 setPersistentVfx(),收到其他一次性效果則走 triggerOneShotVfx()。oneShotFx 裡的 seq 會遞增,這樣同名效果也可以連續重播,不會因為 Vue watch 覺得值沒變就漏掉。
Day17 已經講過,SceneManager 裡的特效也不是全部都做完美 timeline,而是先支援幾個遊戲真的用得到的:雨、燭光、血跡、dust、memory_blur、閃白、閃電、淡出、震動。screen_shake 還會尊重設定裡的 disableShake,reduceMotion 開啟時也會跳過持續型動態效果。
真正決定一段演出有沒有感覺的,不是 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 先出現、哪一句台詞前要留白,還是劇本和演出直覺。
幾個明確的缺口:
這些缺口不代表現在不能用,而是要分清楚「架構入口已經留好」和「內容已經完整製作」的差別。
Day21 的重點其實很簡單:沉浸感不是某一個華麗特效做出來的,而是一串小決策累積起來的。BGM 不硬切、同曲不重播、瀏覽器擋播放時能補播、切分頁會暫停、立繪進退場有過渡、震動可以關掉,這些都不會單獨讓玩家驚呼。
但少了其中幾個,玩家很快就會覺得哪裡卡。演出系統最好的狀態,反而是玩家不太注意它,因為聲音、畫面和文字都剛好在該出現的時候出現。