《九重燼》目前沒有一套真正的動畫 Timeline 系統,也沒有引入 GSAP 這類補間函式庫。實作比較土法煉鋼:PixiJS 畫面效果寫在 SceneManager.ts,UI 進出場交給 Vue <Transition>,文字打字機則用 setTimeout 一個字一個字吐出來。
這聽起來沒有很華麗,但對文字冒險遊戲來說反而合理。動畫不是主菜,節奏才是。玩家按下下一句時,畫面要能閃、能暗、能晃一下;角色立繪換人時不要硬切;證據彈窗和選項不要突然砸在畫面上。做到這些,就已經能讓劇情讀起來順很多。
動畫從 Ink tag 開始。例如章節裡可以寫:
# vfx:tension_flash:once
# vfx:screen_shake:medium
# vfx:fade_out:once
這些 tag 不會直接呼叫 PixiJS。它們先被 InkTagParser.ts 解析成 typed command,再由 StoryCommandRouter.ts 分派出去。vfx 的路由很短:
case 'vfx': {
const [name, mode] = command.args
if (mode === 'start') await context.pixi.setPersistentVfx(name)
else if (mode === 'stop') await context.pixi.stopPersistentVfx(name)
else await context.pixi.triggerOneShotVfx(name, mode)
break
}
這段的重點不是程式多精巧,而是責任切得乾淨。Ink 只說「這裡要閃一下」或「雨開始下」,它不知道畫面上是 Graphics、Sprite 還是 CSS transition。真正的播放細節留在 renderer 那邊。
story.ts 接到 command 後,會把狀態寫進 store:
setPersistentVfx: (name) => {
this.effectId = name
},
triggerOneShotVfx: (name) => {
this.oneShotFx = { name, seq: this.oneShotFx.seq + 1 }
},
這裡有個小設計:一次性特效不用單純存 name,而是另外帶一個遞增的 seq。因為如果連續兩次都是 tension_flash,Vue watch 只看字串可能不會觸發;seq 每次增加,就能保證同名特效可以重播。
GameCanvas 是動畫的閘門GameCanvas.vue 只做一件事:看 store,然後叫 SceneManager。例如一次性特效:
watch(() => store.oneShotFx.seq, () => {
const name = store.oneShotFx.name
if (!name || store.settings.reduceMotion) return
if (name === 'screen_shake' && store.settings.disableShake) return
scene?.setEffect(name)
})
reduceMotion 開啟時,CG 補間、持續型 VFX、一次性 VFX 都會被擋掉;disableShake 則只擋畫面震動。SceneManager 本身不知道使用者的偏好,它只負責「你叫我播,我就播」。這跟 Day8 提過的分層一致:系統設定決定要不要播,渲染層決定怎麼播。
一個完整 Timeline 系統通常會處理很多事:多軌動畫、延遲、串接、暫停、取消、反向播放、事件 callback。現在《九重燼》的畫面效果還沒到那個等級。
目前比較像三種時間來源各自運作:
| 類型 | 實作位置 | 時間來源 | 用途 |
|---|---|---|---|
| Pixi 轉場與 VFX | SceneManager.ts |
app.ticker |
閃白、淡出、閃電、震動、雨、燭火 |
| UI 進出場 | Vue 元件 CSS | <Transition> / CSS animation |
立繪、選項、證據彈窗、toast、loading |
| 文字節奏 | useTypewriter.ts |
setTimeout |
對話逐字顯示、自動推進 |
這三種沒有被包成同一個抽象。說白一點,就是先各做各的。這不是最漂亮的架構,但目前比較不會為了「看起來專業」而多塞一層還用不到的控制器。
animateCg() 是最接近「時間軸」的一段。它接收 scale、x、y、durationMs,然後在 PixiJS ticker 裡按時間比例更新 CG:
const update = (ticker: Ticker) => {
elapsed += ticker.deltaMS
const t = Math.min(elapsed / durationMs, 1)
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
currentCg.scale.set(startScale + (endScale - startScale) * ease)
currentCg.position.set(startX + (endX - startX) * ease, startY + (endY - startY) * ease)
if (t >= 1) app.ticker.remove(update)
}
app.ticker.add(update)
easeInOutQuad 沒有從函式庫拿,就是一行數學式。targetX / targetY 用畫面中心當原點,以螢幕寬高百分比換算像素:0 是中央,-0.5 是往左半個畫面寬。這樣腳本只要寫相對位置,不用知道玩家螢幕是手機、筆電還是外接螢幕。
未來可以優化的部分:SceneManager.ts 裡有 cgAnimTween 欄位,但目前沒有真正用它管理或取消前一段 CG 補間。若之後劇本開始連續下多段 cg_anim,就該補上「啟動新補間前先移除舊 update」的保護。
一次性轉場目前主要有三種。
_flash() 是最乾脆的版本:畫一個全白矩形疊在 transitionLayer,透明度從 0.9 開始,每幀扣掉 0.045 * ticker.deltaTime。
flash.alpha -= 0.045 * ticker.deltaTime
if (flash.alpha <= 0) {
app.ticker.remove(fade)
flash.destroy()
}
_lightning() 多了一點節奏。前 50ms 全白,50 到 100ms 關掉,100 到 150ms 再閃一次 0.8,之後才開始淡掉。這不是物理模擬,只是用很短的時間差做出「閃了兩下」的感覺。
_fadeOut() 則是章節結尾常用的黑場:先淡入黑幕,黑到滿版後停 600ms,再淡出。這裡用 phase 分成 in、hold、out 三段,比 _flash() 多了一個中間停頓。
這些轉場都沒有 durationMs 參數。快慢寫死在係數裡,原因很實際:它們不是給劇本細調鏡頭語言用的,而是通用的情緒標點。等到真的需要「0.8 秒淡黑」和「2 秒長黑」同時存在,再把 duration 拉成 tag 參數也不遲。
這裡要特別分清楚:雨、燭火、血滴、塵粒不是「掛一個 ticker,跑完移除」的轉場。它們是持續型狀態。
setEffect('rain') 會建立雨滴資料和 Graphics,後續每一幀由 SceneManager 的主 _tick() 更新:
if (this.rainDrops && this.rainGfx) {
this.rainGfx.clear()
for (const d of this.rainDrops) {
d.y += d.speed * ticker.deltaTime
d.x -= d.speed * 0.15 * ticker.deltaTime
...
}
}
所以它們的收尾也不一樣。一次性轉場跑完會 app.ticker.remove(update);持續型特效則靠 _clearPersistent() 清掉 fxLayer、粒子資料和 filter。這個差異很小,但如果文章裡把兩者混成同一種動畫管理模式,之後讀程式會很困惑。
screen_shake 也不是新增一個臨時 ticker callback。_shake() 只記下三個值:
this.shakeTime = 0
this.shakeDuration = duration
this.shakeStrength = strength
真正的位移在 _tick() 裡處理。每幀先把 app.stage.position 歸零,如果震動還沒結束,就依剩餘時間算出逐漸變小的 strength,隨機偏移 stage:
const remaining = 1 - this.shakeTime / this.shakeDuration
const strength = this.shakeStrength * Math.max(remaining, 0)
this.app.stage.position.set((Math.random() - 0.5) * strength, (Math.random() - 0.5) * strength)
我喜歡這種寫法,因為它不需要管「上一個震動 callback 有沒有清掉」。狀態在 SceneManager 身上,主 ticker 每幀看一次,時間到了自然不再偏移。
不是所有動態效果都該放進 Pixi。立繪是 <img>,選項是 <button>,證據彈窗是 DOM,toast 也是 DOM;這些留給 Vue 和 CSS 反而比較好維護。
CharacterPortrait.vue 用 <Transition name="portrait" mode="out-in">,切換角色或表情時做透明度、位移、縮放和 blur:
.portrait-enter-active,
.portrait-leave-active {
transition: opacity 0.35s ease, transform 0.45s ease, filter 0.45s ease;
}
.portrait-enter-from,
.portrait-leave-to {
opacity: 0;
transform: translateY(1rem) scale(0.985);
filter: blur(6px);
}
ChoiceMenu.vue 的選項淡入是 0.25s,從 translateY(8px) 回到原位;EvidenceModal.vue 的證據彈窗會讓背景遮罩淡入、modal 從 translateY(14px) scale(0.96) 彈進來;SystemToasts.vue 則用 <TransitionGroup> 讓多個 toast 進出時不會互相硬擠。
這些不是劇情演出的一部分,而是操作回饋。用 CSS 寫更直接,也比較容易配合 hover、disabled、focus 這些 UI 狀態。
對文字冒險來說,真正最常被玩家感受到的動畫,其實是打字機。
useTypewriter.ts 裡把文字速度分成四檔:
const SPEED_MAP: Record<string, number> = {
instant: 0,
fast: 18,
normal: 40,
slow: 72,
}
每次 store.text 改變,DialogueBox.vue 就呼叫 startTyping(newText)。如果玩家點擊時文字還在跑,skipToEnd() 會立刻顯示全文;如果已經顯示完,就推進下一句。按住 Ctrl 或 Cmd 會啟動 130ms 一次的快進 interval。自動模式則在文字完成後等 1400ms 再推進。
這些數字都不大,但它們決定了閱讀手感。normal: 40 太慢會煩,太快又沒有演出感;自動推進 1400ms 太短會讓人讀不完,太長又像卡住。這種地方不像存檔格式那樣有標準答案,最後還是要靠玩起來的感覺調。
PixiJS 負責場景內的演出:閃白、黑場、閃電、震動、雨和 CG 補間。Vue 負責 UI 的進出場:立繪、選項、modal、toast、loading。文字節奏由 useTypewriter 管。它們沒有被硬包進同一條 Timeline,因為現在還不需要。
如果之後演出變複雜,真正該補的不是「立刻引入大函式庫」,而是先整理幾個明確缺口:CG 補間要能取消、bg_transition 要接上實作、一次性轉場要不要支援 duration、reduceMotion 是否也要覆蓋 CSS animation。等這些問題真的開始痛,再升級動畫架構會比較踏實。