Day8 提過 SceneManager 是 PixiJS 這邊的場景管理者,這篇往下鑽細節:它實際上怎麼用 Container 分層、哪些東西交給 PixiJS、哪些東西留在 Vue DOM。
這裡有個很容易誤會的地方。畫面上看到背景、角色、CG、雨、閃白、對話框,並不代表它們全都在同一個 canvas 裡。SceneManager 管的是 PixiJS 畫布;角色立繪和 UI 則是 Vue 元件疊在 canvas 上面。兩邊合在一起,才是玩家看到的畫面。
this.bgLayer = new Container() // 背景
this.charLayer = new Container() // 保留給角色立繪(實際未使用,見下)
this.cgLayer = new Container() // CG
this.fxLayer = new Container() // 特效(雨、燭光、血漬、灰塵)
this.transitionLayer = new Container() // 轉場(閃白、淡黑)
this.app.stage.addChild(this.bgLayer, this.charLayer, this.cgLayer, this.fxLayer, this.transitionLayer)
這裡沒有動態調整 zIndex 或 sortableChildren。疊放順序完全靠 addChild 呼叫時的順序決定,一開始就釘死。之後所有 Pixi 畫面元素都只是往對應的層塞 Sprite 或 Graphics,不需要每次問「這個特效應該蓋過 CG,還是被 CG 蓋過」。
charLayer 這個 Container 確實存在,但目前沒有角色 Sprite 被塞進去。它會被 CG 和記憶模糊效果影響,例如 setCg() 顯示滿版 CG 時會把 charLayer.visible 關掉,memory_blur 也會對它套 BlurFilter。
實際角色立繪是 CharacterPortrait.vue:一個純 Vue 元件,用 <img> 標籤和 CSS <Transition> 呈現,疊在 Pixi canvas 之上。它還做了一個很實用的修正:有些立繪圖檔下緣帶很多透明留白,元件會用 canvas 掃描圖片底部最後一列不透明像素,算出 bottomShift,把透明留白往舞台外推掉,讓角色本體看起來真的貼著對話框。
立繪只需要淡入淡出、位置切換、左右站位和透明留白修正,用 CSS 和 DOM 管起來比在 Pixi 裡管理 Sprite 生命週期簡單很多。真正需要 Pixi 的畫面效果,例如背景 cover 裁切、CG 縮放位移動畫、雨滴粒子、燭光閃爍、轉場閃白,才留在 Pixi 這一層。
這也讓技術邊界很清楚:PixiJS 負責背景、CG、特效;Vue DOM 負責角色立繪和所有 UI。兩者疊在一起呈現,各自處理自己擅長的內容。
Day8 提過的 SCENE_PALETTES,這裡補一個實作細節:每個場景 id 對應一組上色/下色的漸層值。_drawGradient() 會用 Graphics 畫 48 條色帶,模擬一個垂直漸層當背景。
如果場景有背景圖,setScene() 會先嘗試用 Assets.load() 載入圖片。載入成功才清掉舊背景,建立新的 Sprite,用 cover 演算法縮放到填滿畫面;如果載入失敗,會重試 2 次,也就是總共 3 次嘗試。全部失敗後才退回漸層 fallback。
這段保留舊背景的邏輯很重要。它避免場景切換時先清空畫面,結果新圖還沒載完,玩家看到一瞬間黑屏。背景載入是非同步的,不能假設圖片一定會在下一幀就好。
SceneManager 的所有特效最後都會透過 setEffect(effectId: string | null) 進來,但在 Ink 腳本端,StoryCommandRouter.ts 會先把 # vfx tag 依照第三個欄位分流:
# vfx:rain:start → setPersistentVfx('rain') // 持續型,需要明確 stop
# vfx:rain:stop → stopPersistentVfx('rain') // 停掉持續型
# vfx:tension_flash:once → triggerOneShotVfx('tension_flash') // 一次性,播完自動結束
持續型特效(rain、candlelight、blood、dust、memory_blur):塞進 fxLayer,用 activeEffectId 記錄目前哪個效果啟動,每幀在 _tick 裡更新位置/透明度,直到劇本送來 # vfx:xxx:stop tag 才呼叫 _clearPersistent() 移除。
一次性特效(tension_flash、screen_shake、fade_out、lightning):不設 activeEffectId,所以不需要 stop tag。tension_flash、fade_out、lightning 會在 transitionLayer 建立一個全畫面 Graphics,播完後自己 destroy() 並從 ticker 移除;screen_shake 比較特別,它不建立圖形,而是在一段時間內抖動 app.stage.position。
GameCanvas.vue 負責把這兩條路徑接到 store:它 watch store.effectId 處理持續型特效,watch store.oneShotFx.seq 處理一次性特效。同名一次性特效能重播,靠的就是 seq 遞增,而不是單純看 name 有沒有變。
雨(_startRain):預先建立 140 個雨滴物件(數量會依 particleScale 縮放),每滴有隨機位置、速度(10–24 px/frame)和長度(14–32px),每幀用 Graphics 重畫所有線條。
燭光(_startCandle):在整個畫面鋪一層橙色矩形(0xff9a3c)。建立時 fill alpha 是 0.1,但進入 _tick 後,物件 alpha 會依正弦波和隨機值在約 0.75–0.99 之間浮動,模擬燭光呼吸感。
血漬(_startBlood):30 個粒子從畫面上方掉落,有隨機大小(5–30px)和速度(3–11 px/frame),持續在 _tick 更新,不是播一次就結束。
灰塵(_startDust):80 個粒子緩慢漂浮(水平 ±0.25,垂直 -0.2 至 -1.7),依正弦波讓 alpha 閃爍,也是持續型。
記憶模糊(_startMemoryBlur):對 bgLayer、charLayer、cgLayer 同時套 PixiJS 的 BlurFilter(strength 8),並在 fxLayer 疊一層白色半透明濾膜(alpha 0.15),動態 import BlurFilter 避免初始包體積過大。
Day13 講 UI 時提過,行動裝置不能只靠縮放版面處理。Pixi 這層也一樣,需要有降載策略。
GameCanvas.vue 會依設定算出 particleScale():
SceneManager 裡的 _particleCount(base) 會用這個比例縮放粒子數。例如雨的基準是 140,低效能模式會降到約 56,高密度則會拉到約 224。設定變更時,setParticleScale() 會重建目前正在播放的持續型特效,讓數量即時生效。
另外,SceneManager.init() 在低效能模式會關掉 antialias,resolution 也固定 1x,避免高 DPI 螢幕一次渲染太多像素。這些調整看起來不像華麗功能,但對 Web 遊戲很實際:特效漂亮是一回事,手機不要燙到玩家想關掉頁面是另一回事。
SceneManager 的 canvas 是用 resizeTo: container 貼合容器,但實作沒有只相信瀏覽器的 window resize 事件。初始化時還掛了 ResizeObserver 監聽 host container,並且在 _tick() 裡每 250ms 節流檢查一次容器尺寸和 canvas 尺寸。
這是為了處理 iframe、預覽面板或某些嵌入環境:容器大小變了,window 不一定會觸發 resize。Canvas 尺寸一旦沒跟上,背景 cover、CG 位置、雨滴範圍都會偏掉。與其等畫面壞掉才猜,不如讓渲染層自己定期校正。
SceneManager.ts 它的複雜度幾乎都集中在特效和生命週期那一段。五層 Container 的結構本身很簡單,真正需要想清楚的是:這個效果要不要每幀更新?它什麼時候該被清掉?設定變更時要不要重建?畫面 resize 時要不要重新排版?
把這些問題放進 SceneManager,Vue 元件就能保持單純。GameCanvas.vue 只看 store,然後呼叫 setScene()、setCg()、setEffect();它不需要知道雨滴怎麼畫,也不需要知道 fade out 怎麼銷毀。這就是我現在比較喜歡的分工:Vue 負責狀態和掛載,Pixi 負責畫布裡的世界。