iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

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

Day17 動畫系統實作:轉場、淡入淡出

  • 分享至 

  • xImage
  •  

《九重燼》目前沒有一套真正的動畫 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 只說「這裡要閃一下」或「雨開始下」,它不知道畫面上是 GraphicsSprite 還是 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,是因為需求還沒有複雜到那裡

一個完整 Timeline 系統通常會處理很多事:多軌動畫、延遲、串接、暫停、取消、反向播放、事件 callback。現在《九重燼》的畫面效果還沒到那個等級。

目前比較像三種時間來源各自運作:

類型 實作位置 時間來源 用途
Pixi 轉場與 VFX SceneManager.ts app.ticker 閃白、淡出、閃電、震動、雨、燭火
UI 進出場 Vue 元件 CSS <Transition> / CSS animation 立繪、選項、證據彈窗、toast、loading
文字節奏 useTypewriter.ts setTimeout 對話逐字顯示、自動推進

這三種沒有被包成同一個抽象。說白一點,就是先各做各的。這不是最漂亮的架構,但目前比較不會為了「看起來專業」而多塞一層還用不到的控制器。

CG 動畫:手刻的 easeInOutQuad

animateCg() 是最接近「時間軸」的一段。它接收 scalexydurationMs,然後在 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 分成 inholdout 三段,比 _flash() 多了一個中間停頓。

這些轉場都沒有 durationMs 參數。快慢寫死在係數裡,原因很實際:它們不是給劇本細調鏡頭語言用的,而是通用的情緒標點。等到真的需要「0.8 秒淡黑」和「2 秒長黑」同時存在,再把 duration 拉成 tag 參數也不遲。

持續型特效不是一次性 tween

這裡要特別分清楚:雨、燭火、血滴、塵粒不是「掛一個 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。這個差異很小,但如果文章裡把兩者混成同一種動畫管理模式,之後讀程式會很困惑。

畫面震動走主 ticker,不另外掛 update

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 每幀看一次,時間到了自然不再偏移。

UI 動畫留給 Vue,不塞進 Pixi

不是所有動態效果都該放進 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。等這些問題真的開始痛,再升級動畫架構會比較踏實。


上一篇
Day16 Vue3 響應式狀態與 Ink.js State 同步機制
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言