iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Modern Web

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

Day11 選項系統實作:Choice、Flag 與劇情控制

  • 分享至 

  • xImage
  •  

day4 談過選項在設計層怎麼寫入數值跟旗標,Day9 談過 inkjs 怎麼吐出 currentChoices。這篇要講的是最後一哩路:這些選項到了畫面上,實際是怎麼被 ChoiceMenu.vue 呈現、接住,再丟回 Pinia store。

選項系統很容易被想成「按鈕列表」。但在《九重燼》裡,同一批 currentChoices 可能是序章調查、殿前文辯、戶部算籌,也可能是武鬥人選。它們在 Ink 裡都是選項,到了 UI 層卻不能長得一樣。這也是 Day11 真正要處理的事:讓劇本保持乾淨,同時讓玩家看得懂自己現在正在做哪一種決定。


一份選項清單,跨兩層元件的五種樣子

ChoiceMenu.vue本身只直接畫兩種呈現:序章的調查模式,跟其餘場景共用的一般選項清單(數字編號、Transition 淡入動畫、isMajorChoice() 重大選項樣式)。文辯(CH02_S03)、算籌(CH02_S04)、武鬥(CH02_S05)外觀上也是選項,但畫面其實不是 ChoiceMenu.vue 畫出來的——GameView.vuestore.minigame 前綴(Ink 場景一開頭 # minigame:debate:... 之類 tag 設出來的值)決定要不要蓋上對應的覆蓋元件,一旦啟用,ChoiceMenu 整個被 v-if 掉:

// GameView.vue
const isLogisticsActive = computed(() => store.minigame?.startsWith('logistics') === true)
const isDebateActive = computed(() => store.minigame?.startsWith('debate') === true)
const isDuelActive = computed(() => store.minigame?.startsWith('duel') === true)
const isChoiceOverlayActive = computed(() => isLogisticsActive.value || isDebateActive.value || isDuelActive.value)
<LogisticsPlanner />
<DebatePlanner />
<DuelArena />
<ChoiceMenu v-if="!store.mapPanel && !isChoiceOverlayActive" />

四種特殊場景(調查、文辯、算籌、武鬥)用的是同一招識別「玩家點的是選項清單裡第幾個」:每個選項在 Ink 裡都掛一個 inline 的 # choice:<id> tag,前端拿這個 id 去 store.currentChoices 裡找 index,不比對選項文字本身——下一節細講。

其餘所有場景走的是第五條路:一般選項模式,也是 isMajorChoice() 真正生效的地方。

這個切法務實的地方是:劇本作者不用理解任何 Vue 元件內部怎麼排版,只要把場景 ID、選項的出現條件、效果、還有一個穩定 id 寫對;至於這個選項在畫面上該長什麼標題、有沒有效果說明,全部丟給對應的前端元件自己維護一份清單。


調查模式:標題寫死在前端,但比對用 choiceId 而不是文字

調查模式的選項視覺標籤(傷口/衣袖/窗戶……)並不是從 Ink 的 currentChoices 文字解析出來的,而是在 ChoiceMenu.vue 開頭定義了一份對照表:

ts
const investigationChoices = [
  { id: 'wound', choiceId: 'CH00_S01_C01_A', flag: 'flag_checked_qinghe_wound', title: '傷口' },
  { id: 'sleeve', choiceId: 'CH00_S01_C01_B', flag: 'flag_found_burned_paper', title: '衣袖' },
  { id: 'window', choiceId: 'CH00_S01_C01_C', flag: 'flag_checked_window', title: '窗戶' },
  { id: 'incense', choiceId: 'CH00_S01_C01_D', flag: 'flag_found_sedative_incense', title: '酒壺與香爐' },
  { id: 'bed', choiceId: 'CH00_S01_C01_E', flag: 'flag_found_second_blade', title: '床下' },
  { id: 'help', choiceId: 'CH00_S01_C01_F', flag: 'flag_called_for_help_early', title: '呼喊' },
] as const

前端用 choiceId 欄位去比對 store.currentChoices[i].choiceId,找到對應的 choiceIndex 之後,再用 flag 欄位去查 store.investigationFlags 看有沒有選過、用 investigationRemaining 看還剩幾次機會;按鈕上實際顯示的文字直接取 store.currentChoices[choiceIndex].text,前端不再自己維護一份可能跟劇本兜不起來的副本。

choiceId 從哪裡來?Ink 選項要把 # choice:<id> tag 寫在方括號裡面(inline),而不是另起一行:

ink
+ {not flag_checked_qinghe_wound} [先看傷勢。死人不會說話,傷口卻未必會騙人。 # choice:CH00_S01_C01_A]
    -> chapter_00_s01_check_wound

這個順序很重要——inkjs 只有寫在 [...] 裡的 tag 才會出現在 currentChoices[i].tags,寫在下一行的 tag 只會掛到選項被選中之後的內容上,選項本身讀不到。StoryRuntime.ts 再把 tag 解析成 choiceId

function choiceIdFromTags(tags: readonly string[]): string | null {
  const choiceTag = tags.find((tag) => tag.trim().replace(/^#\s*/, '').startsWith('choice:'))
  if (!choiceTag) return null
  const [, choiceId] = choiceTag.trim().replace(/^#\s*/, '').split(':')
  return choiceId?.trim() || null
}

這個設計讓 Ink 腳本仍然只需要維護選項的「出現條件」({not flag_checked_qinghe_wound})跟「效果」(變數修改),調查介面的標題文字和視覺順序仍由前端負責管理——但耦合點從「選項文字要逐字相同」換成「choiceId 要存在且不能撞名」,改台詞不會再讓按鈕悄悄變成永遠 disabled。


條件選項:Ink 原生的 {} 語法

Day4 提過「不能靠好感度單一數值決定所有事」,實作上這個條件判斷落在 Ink 腳本裡,用 {} 包住條件式:

ink
+ {rel_seventh_trust >= 30} [帶七皇子同行。]
    # choice:CH03_S07_C01_C
    ~ ch03_shen_companion = "seventh"

意思是「只有七皇子信任度達到 30,這個選項才會出現」。story bible 裡設計的前置條件、路線鎖定,最終都是這種條件式的組合。這一句在 chapter_04.ink 也有類似寫法:+ { rel_liu_trust >= 55 } [我沒有資格替妳選。]

值得注意的是,chapter_03.ink 這個例子的 # choice:CH03_S07_C01_C 寫在下一行,不是像調查模式那樣塞進 [...] 裡——因為這是一般選單走的路,前端只靠陣列 index 對應按鈕,不需要用 id 去 currentChoices 裡查找,tag 寫哪裡都不影響行為。只有調查/文辯/算籌/武鬥這四種需要「用穩定 id 反查選項」的場景,才要求 tag 一定要 inline 寫在方括號裡。

inkjs 在呼叫 Continue() 時就已經把不符合條件的選項過濾掉,currentChoices 吐出來的永遠只有「目前可以選的」——前端完全不需要自己實作任何過濾邏輯,條件判斷百分之百在 Ink 層發生。

這條邊界很重要。Vue 不該知道「柳如煙信任 55 以上才可以說某句話」,它只負責把 inkjs 已經吐出的選項畫出來。要是哪天前端也開始自己判斷關係門檻,劇情狀態就會變成兩套來源,各自覺得自己才是對的。


文辯、算籌、武鬥:跟調查模式同一套 choiceId 比對法

DebatePlanner.vueLogisticsPlanner.vueDuelArena.vue 各自維護一份跟 investigationChoices 結構幾乎一樣的清單——id/choiceId/title/summary/effects,用 choiceIdstore.currentChoices 裡找對應的 choiceIndex

// DebatePlanner.vue
const stanceCards = computed(() =>
  stances
    .map((stance) => {
      const choiceIndex = store.currentChoices.findIndex((choice) => choice.choiceId === stance.choiceId)
      return { ...stance, choiceIndex }
    })
    .filter((stance) => stance.choiceIndex >= 0),
)

.filter((stance) => stance.choiceIndex >= 0) 這行順便扛下了 Ink 條件選項的顯示邏輯:CH02_S03_C01_C(君民相制)在 Ink 裡掛了 {flag_found_military_ledger || reform >= 20} 這種前置條件,條件不成立時 inkjs 根本不會把它放進 currentChoiceschoiceIndex 自然是 -1,卡片就不會顯示——不用另外寫一套「隱藏」判斷。算籌(LogisticsPlanner.vue)跟武鬥(DuelArena.vue)是同一套寫法:算籌的「揭露官員侵吞」選項、武鬥裡需要 rel_eighth_affection >= 30 才會出現的「讓八哥出戰」,都是靠這個 choiceIndex < 0 就篩掉,卡片上的 hidden/badge 欄位只是額外加一層「這張卡片其實是隱藏解鎖」的視覺提示,不是判斷要不要顯示的依據。


防呆:重大選項要看得出來,也不能被連點誤觸

兩個小但重要的細節:

重大選項標示isMajorChoice() 用一組關鍵詞掃過選項文字,命中就套用「重大選項」的樣式:

function isMajorChoice(text: string): boolean {
  const majorKeywords = ['背叛', '殺', '出賣', '犧牲', '放棄', '永遠', '決定', '誓', '臣服', '逃', '揭露']
  return majorKeywords.some((kw) => text.includes(kw))
}

這個函式只在一般選項模式(非調查/文辯/算籌/武鬥)裡生效——特殊場景有自己的視覺層級邏輯,不需要靠關鍵詞判斷。對一般劇情選項而言,不是每個選項手動標記,而是用文字內容自動判斷,這代表寫劇本的人只要選項文字本身夠有分量,樣式就會自動跟上。

防雙擊doChoose() 選完之後會鎖住 600ms(submitCooldown),用一個 submitting ref 控制:

function doChoose(index: number) {
  if (submitting.value || index < 0) return
  submitting.value = true
  store.choose(index)
  submitCooldown = setTimeout(() => {
    submitting.value = false
  }, 600)
}

submitting.value 為 true 時,所有選項按鈕都是 :disabled="submitting"——這樣就算玩家手滑連點,第二次呼叫進來直接 returnstore.choose() 只會被叫一次。600ms 結束後解鎖,讓玩家能繼續操作。


鍵盤 1–6 快捷鍵:可及性的基本線

ts
function onKeydown(e: KeyboardEvent) {
  if (store.uiPanel || store.mapPanel) return
  const num = parseInt(e.key)
  if (num >= 1 && num <= 6) {
    const choices = store.choices
    if (num - 1 < choices.length) {
      e.preventDefault()
      doChoose(num - 1)
    }
  }
}

調查模式、文辯、算籌、武鬥四種特殊呈現,標題文字和效果說明都寫死在前端,但「玩家點的是哪個選項」全部靠 Ink inline tag 給的 choiceId 去比對,不再依賴選項文字本身;重大選項則仍然靠關鍵詞自動偵測,因為那只影響樣式,不影響選項按不按得下去。這些設計背後都是同一個目標:讓 Ink 腳本維持最小格式要求(一個穩定 id、出現條件、效果),視覺複雜度往前端推,劇本作者不用學一套 UI 標記語法,改台詞也不會不小心弄壞按鈕。

前端跟劇本之間的耦合點從「選項文字要逐字相同」變成「choiceId 要存在、且不能跟別的選項撞名」;文辯/算籌/武鬥卡片上的效果說明(effects/summary)也還是純前端維護的一份文字,跟 Ink 裡實際修改的變數之間沒有自動同步機制,改了數值公式要記得回來同步卡片文字。
Day11 回頭看,ChoiceMenu.vue 加上三個 Planner 元件最像一組轉接橋:一端接 Ink 的劇情語法,一端接玩家看到的互動形式。橋本身不該決定劇情,但它會決定玩家有沒有在正確的時候,看懂自己正在做什麼選擇。


上一篇
Day10 建立 Dialogue System:打造可重複使用的對話系統
下一篇
Day12 存檔與讀檔機制:LocalStorage 還是 IndexedDB?
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言