iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Modern Web

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

Day10 建立 Dialogue System:打造可重複使用的對話系統

  • 分享至 

  • xImage
  •  

有了 Day9 的資料流之後,這篇要回到玩家眼前:對話框。

這聽起來像是很小的 UI。拿一段文字,放進框裡,點一下換下一句,好像就結束了。但真正寫到 src/components/DialogueBox.vue 才會發現,對話框其實是整個遊戲最忙的元件之一。它要接 Pinia 的劇情文字,要跑打字機效果,要判斷點擊是在「跳過」還是「推進」,還要跟自動播放、長按快進、選項、面板、小遊戲互相避讓。

所以 Day10 表面上是在做 Dialogue System,實際上是在處理一個問題:玩家讀劇情時,每一次點擊都應該剛好符合他的預期。


打字機效果:速度是設定值,不是寫死的動畫

打字機效果被拆進獨立的 useTypewriter.ts composable,不寫死在 DialogueBox 裡。核心是一張速度對照表:

const SPEED_MAP = { instant: 0, fast: 18, normal: 40, slow: 72 } // 每字元毫秒數

startTyping(text) 從頭開始逐字 setTimeout 疊字;如果玩家設定是 instant(0ms),就直接跳過逐字動畫全部顯示。這個 composable 完全不知道自己被用在對話框——它只認得「給我文字跟速度,我負責吐出目前該顯示到第幾個字」,這也是為什麼標題說它是「可重複使用」的對話系統:理論上證據面板、回顧記錄要用同一套打字效果,直接 import 就能用。

startTyping 的計時器鏈是這樣的:每一次 typeNextChar() 在顯示完一個字後,自己排程下一個 typeNextChar(),形成一條沒有 setInterval 的遞迴計時鏈。這樣做的好處是速度設定不會被鎖死在一開始那個 interval 裡。每次 typeNextChar() 執行時,都會重新讀一次 options.getTextSpeed(),再決定下一次 setTimeout 要等多久。

這裡要說精準一點:如果玩家在兩個字之間剛好改了速度,已經排出去的那一次 timeout 不會被重算;新的速度會從下一輪排程開始生效。這不是大問題,因為文字速度設定本來就不是逐幀操作,但這個細節也提醒我,打字機效果最好不要寫成一個永遠固定頻率的 setInterval。它不是動畫迴圈,比較像一串可以被設定影響的短排程。


誰觸發打字?watch 監聽 store.text

DialogueBox 不主動呼叫 startTyping——它用 watch 監聽 store.text,只要 Pinia 的文字換了,就自動啟動新一輪打字:

watch(
  () => store.text,
  (newText) => {
    if (!newText) return
    startTyping(newText)
  },
  { immediate: true },
)

{ immediate: true } 確保元件掛載時,如果 store.text 已經有值(例如讀檔進入中間場景),不用等下一次文字變化就能立刻開始打字。

這個設計把「什麼時候換文字」的決策權完全留在 Pinia store(advance() 更新 store.text),DialogueBox 只是反應式地跟著走——元件不用知道文字從哪裡來、為什麼換,只要值變了就打字。


對話框的雙模式:對白與旁白

DialogueBox.vue 同時處理兩種情境:有說話者的台詞,跟沒有說話者的旁白。判斷依據是 store.speakerId

const isNarration = computed(() => !store.speakerId)

這個布林值會同時改變三個地方的視覺:

  • 對話框本體套上 is-narration class,背景從金色邊框深色切換為更暗的底色
  • 名牌 <div v-if="speaker"> 直接消失(不需要 isNarration 判斷,speaker 為 null 就不渲染)
  • 台詞文字套上 text-narration class,變斜體、字色偏灰、行高拉寬到 1.9

同一個元件,兩套視覺靠 class 切換,沒有另外做一個 NarrationBox 元件。


名牌動畫:Vue <Transition> + CSS 位移

說話者名牌套著 Vue <Transition name="name-tag">,只需要兩行 CSS:

.name-tag-enter-active {
  transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.name-tag-enter-from {
  opacity: 0;
  transform: translateX(-10px);
}

進場時從左邊 10px 飛入、透明度從 0 淡入,0.3 秒完成。沒有特別寫 leave-activeleave-to,所以名牌消失時是瞬間的——這是刻意選擇,說話者換人時視覺上讓新名牌「接替」出現,而不是舊名牌先飛出去再飛進來,前者感覺更俐落。


游標閃爍:step-end 而不是 ease

打字中畫面會出現一個 游標,CSS animation 是:

animation: blink 0.8s step-end infinite;

用的是 step-end,不是一般的 easelinearstep-end 讓透明度在 0 和 1 之間直接跳變,沒有淡入淡出的過渡——這是模仿終端機游標「一閃一閃」的視覺效果,用 ease 的話游標會緩慢淡入再淡出,感覺比較像呼吸燈,不像打字機。


對話流程控制:點擊的意義會隨狀態改變

DialogueBox.vue 的點擊處理只有一個函式 handleClick(),但行為分兩種:

  • 如果正在打字(isTyping)→ 點擊會呼叫 skipToEnd(),直接補完全文
  • 如果已經打完(isDone)→ 點擊才會真的呼叫 store.advance() 推進下一句

這個「同一個點擊、不同語意」的設計,是為了讓玩家不用切換模式就能同時做到「想仔細看就等」跟「想快速看就狂點」。


Skip:連點合併,減少黏滯感

單純的「點擊補全文」有個手感問題:玩家想快速讀劇情時,得先點一下補全文、再點一下才推進下一句,等於每句話要點兩次。這在一般閱讀時沒感覺,一旦讀第二輪或測試分支,就會很煩。

後來加了一個優化:如果瀏覽器判斷這是多次連點(event.detail > 1),而且這次點擊仍然落在 isTyping 狀態,就直接把「補全文」跟「推進下一句」合併成一個動作。

function handleClick(event?: MouseEvent) {
  if (isTyping.value) {
    skipToEnd()
    if ((event?.detail ?? 1) > 1) {
      advanceIfReady()
    }
    return
  }
  advanceIfReady()
}

event.detail 是瀏覽器原生記錄的連擊次數。同一位置的第二次點擊 detail 為 2,第三次為 3,以此類推。利用這個值就不需要自己從零開始判斷「這是不是連點」。

這是實際 QA 時抓到的手感問題。修法沒有動到原本「打字中補全文」的行為,只是在多擊情境下讓玩家少等一次。

另外還有一個長按快進:按住 Ctrl/Cmd 會啟動一個 130ms 一次的計時器,不斷做「還在打字就補完、打完就推進」,放開就停——這個給想跳過已經看過的劇情的玩家用。


Auto Play:打完字之後,依速度設定自動前進

自動模式是另一條獨立的邏輯:只要設定開了 autoAdvanceisDone 一變成 true,就會依 autoAdvanceSpeed 啟動一個計時器,時間到自動呼叫 store.advance()。畫面右上角會顯示一個 AUTO 標籤,讓玩家知道現在不是普通等待狀態。

這裡最後整理成一張很直白的速度表:

const AUTO_ADVANCE_DELAY_MS: Record<string, number> = {
  slow: 2500,
  normal: 1400,
  fast: 800,
}

設定面板裡的「自動前進速度」會寫進 store.settings.autoAdvanceSpeed,對話框再用這個值換算等待毫秒數。
這樣玩家在設定裡選慢/標準/快,實際對話節奏才會真的跟著變。

這個計時器在每次 isDone 變化時都會先清掉舊的 autoTimer,再決定要不要排新的 timeout。這一點很重要。對話系統最怕的不是少前進一次,而是舊句子的 timer 留在背景裡,等新句子剛出現就突然把它跳掉。這種 bug 很難從畫面上判斷原因,玩家只會覺得「是不是我剛剛按太快」。


一個容易漏掉的細節:什麼時候「不能」推進

canAdvance 這個判斷式(store.choices.length === 0 && !store.ended)只是最基本的門檻,三條路徑各自的防護覆蓋面不完全相同:

  • 長按快進同時檢查 store.uiPanelstore.mapPanelstore.minigamestore.evidencePopupQueue
  • 自動推進少了 minigame
  • 點擊路徑的 canInteractWithDialogue() 只檢查 uiPanelmapPanelminigameevidencePopupQueue 都沒納入

這不是立刻會炸的 bug,但它是典型的「之後會忘記同步」的形狀。現在有三條路徑,就有三份條件;未來如果多一個彈窗、一個教學遮罩,或某個章節小遊戲開始真的接進主流程,就要記得三個地方都補。比較理想的做法,是把「現在能不能推進對話」收成同一個 computed 或 store getter,讓點擊、鍵盤、自動播放、快進都問同一個答案。


打字機效果、點擊語意切換、連點合併、自動播放,四個機制疊在一起,才是玩家實際感受到的「這個對話系統讀起來很順」。它們不是一開始就設計得很完整,而是先有最簡單的版本,再依實際試玩手感一層層補上去。

Day10 回頭看,最有價值的不是哪一段 CSS 動畫,而是這個元件暴露出來的兩個提醒:
第一,對話系統的「手感」通常藏在小到不會進 PRD 的地方;第二,只要同一個判斷散在三條路徑裡,技術債就已經開始長出來了。
只要把核心邊界算清楚:文字速度在 useTypewriter.ts,劇情推進在 Pinia store,對話框負責把玩家的操作翻譯成下一個合理動作。這個邊界守住,後面要修手感就不會變成整包重寫。


上一篇
Day9 Ink.js 劇本整合:如何讓 Vue3 與 Ink.js 溝通?
下一篇
Day11 選項系統實作:Choice、Flag 與劇情控制
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言