iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Modern Web

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

Day7 技術選型與專案架構:為什麼選 Vue3、PixiJS、Ink.js?

  • 分享至 

  • xImage
  •  

前六天都在講企劃跟文件,今天正式講講用了哪些技術架構,也是後面所有實作文章的地基。

為什麼是這個組合

《九重燼》目前的核心依賴版本:

"vue": "^3.5.13"
"pixi.js": "^8.5.2"
"inkjs": "^2.3.0"
"pinia": "^2.2.6"
"vite": "^6.0.3"

Ink.js:Ink 是 inkle, 開發這類分支敘事遊戲用的劇本語言,核心概念是 knot(場景節點)、選項、變數、旗標,天生就是為「大量分支+可回溯狀態」設計的。對一款「選項會寫入 764 個全域變數」的遊戲來說,比起自己刻一套 DSL,直接用一個已經被驗證過的敘事引擎划算得多。inkjs 是它的 JS/TS 執行環境,可以直接跑在瀏覽器裡。

PixiJS:只負責畫面——背景、角色立繪、CG、特效、轉場。選它是因為《九重燼》本質上是 2D 分層疊圖(背景 + 立繪 + CG + UI 特效),不需要 3D 引擎的重量,PixiJS 的 WebGL 渲染在處理大量 Sprite 疊層轉場時效能足夠,API 也比 Canvas 原生操作省事很多。

Vue 3 + Pinia:所有「畫面以外」的東西——對話框、選項清單、主選單、存讀檔、地圖、證據面板——都是 UI 狀態,用 Vue 的 reactivity 處理遠比在 Pixi 裡手刻 DOM-like 元件輕鬆。Pinia 則是整個系統唯一的狀態真相來源,UI 跟劇情狀態要同步,都得經過它。

Vite:單純是開發體驗——熱更新快、TypeScript 開箱即用,跟前面三個沒有特別的耦合關係,純粹是「現在寫 Vue 3 專案沒有理由不用 Vite」。

系統架構:四層分工,互不越界

技術 PRD 裡把整個系統畫成四層:

劇情邏輯層 → Ink (.ink 腳本) + inkjs runtime
渲染層     → PixiJS(背景/角色/CG/特效/轉場)
UI 層      → Vue 3(對話框/選項/主選單/設定/存讀檔/地圖/證據/文辯/算籌/武鬥介面)
狀態管理   → Pinia
建置/部署  → Vite / Vercel

這張圖最重要的不是列了什麼技術,而是明確劃出每一層不負責什麼——Ink 不碰 DOM、不碰 Pixi Sprite、不管存檔介面;PixiJS 不判斷劇情條件、不算好感度、不存檔案。這條界線在 Day5 提過,這裡是它真正的技術落地:越界的程式碼在 code review 時一眼就能看出來「這行邏輯放錯層了」。

專案目錄:照職責分,不照檔案類型分

實際的 src/ 目錄長這樣:

src/
  narrative/    Ink 劇本正本(chapters/ 逐章 + 編譯後的 compiled/)
  engine/       PixiJS 渲染 + StoryRuntime(inkjs 執行)+ StoryCommandRouter(#tag 分派)
  stores/       Pinia,UI/系統狀態唯一來源
  components/   Vue UI 元件
  services/     存讀檔(SaveManager)等服務層
  composables/  Vue composition 邏輯複用
  data/         靜態資料(角色/場景 JSON)
  views/        頁面層級的 Vue 元件
  types/        TypeScript 型別定義

一、劇情邏輯層:Ink + inkjs 實際用在哪裡

Ink 檔案的完整結構:

src/narrative/
  main.ink              入口,INCLUDE 所有章節與結局
  globals.ink           全域數值/旗標(907 行,764 個變數)
  characters.ink        角色關係六維度變數
  extra_globals.ink     番外解鎖旗標
  chapters/
    chapter_00.ink      序章〈夢醒九重宮〉
    chapter_01.ink ~ chapter_08.ink   八章主線
    endings/            11 組正式結局 + 6 條戀愛線
    extra_01 ~ extra_09.ink   九篇番外

globals.ink 管理的數值橫跨玩家的政治資本、道德傾向、外交進度、改革完成度,以及最後的結局衍生分——這些變數在整個遊玩過程中持續累積,最終由 EndingResolver 用來判定觸發哪一條結局線。

Ink Tag 如何驅動畫面

Ink 劇本不直接碰 DOM 或 PixiJS,但每一行末尾可以掛 # tag,把畫面指令夾帶進劇情流程。以序章第一場為例:

ink
=== chapter_00_start ===
# chapter:CH00
# scene:CH00_S01
# bg:chenghua_dian          ← 切換背景到承華殿
# bgm:opening_theme          ← 播放開場主題曲
# ambience:palace_rain       ← 播放宮中雨聲環境音
# vfx:rain:start             ← 啟動雨滴特效

雨落在琉璃瓦上。

# char:xiao_chengyuan:default:wounded:center  ← 主角立繪,受傷表情,置中
蕭承淵睜開眼時,先聞到了一股血腥氣。

# speaker:xiao_chengyuan
# line:CH00_S01_L001
這不是學校。

這幾行 tag 對 Ink runtime 來說完全透明,只是附帶資料;但 StoryCommandRouter 會把它們翻譯成對 PixiJS 和 AudioManager 的真實呼叫。

inkjs 在瀏覽器端如何運行

瀏覽器無法即時編譯 .ink 原始碼,所以建置流程會先用 inkjs/full 內建的 JS 版 Compiler(不是官方獨立的 Inklecate 工具)把 main.ink(連同所有 INCLUDE)編譯成一份 main.jsonStoryRuntime 直接 import 這個 JSON:

import { Story } from 'inkjs'
import compiledStory from '../narrative/compiled/main.json'

start(): void {
  this.story = new Story(compiledStory)
}

每次推進劇情:story.Continue() 回傳下一行文字、story.currentTags 回傳附帶的 tag 陣列、story.currentChoices 回傳目前可選的選項清單。


二、渲染層:PixiJS 實際用在哪裡

PixiJS 負責五件事:

職責 說明
背景 根據 # bg:scene_id 載入對應場景圖,做 cover 裁切填滿 1920×1080
角色立繪 根據 # char:id:outfit:expression:position 顯示立繪,支援左中右定位
CG 根據 # cg:id 全螢幕覆蓋 CG 圖
特效 (VFX) 雨滴、燭火閃爍、震動、閃光。持續型 (vfx:rain:start) 或一次性 (vfx:tension_flash:once)
轉場 場景切換時的淡入淡出或黑幕過場

五層 Container

SceneManager 把 PixiJS 的 stage 分成五個疊加的 Container:

stage
  ├─ bgLayer         背景(最底層)
  ├─ charLayer       角色立繪
  ├─ cgLayer         CG
  ├─ fxLayer         特效(雨、燭火、震動等)
  └─ transitionLayer 轉場遮罩(最上層)

CG 出現時只需讓 cgLayer 可見,不影響背景和角色;轉場動畫蓋在最上層,不需要動其他元素。

素材缺失時的 fallback

如果某個場景的背景圖還沒製作完成,SceneManager 不會顯示空白,而是用預先定義的氛圍漸層色填充:

const SCENE_PALETTES: Record<string, ScenePalette> = {
  chenghua_dian:  [0x232a3d, 0x0b0d15],  // 承華殿・夜(深藍紫)
  imperial_court: [0x4a3b28, 0x1a140d],  // 朝堂(深琥珀)
  tingyu_lou:     [0x39284a, 0x140d1c],  // 聽雨樓(深紫)
  // ...共 15 個場景
}

PixiStage 的 resize 策略:信箱模式

畫布固定以 1920×1080 渲染,resize()Math.min 取最小縮放比,讓畫面整體縮放而不裁切,多出來的空間留黑邊:

const scale = Math.min(parentWidth / this.targetWidth, parentHeight / this.targetHeight)
const newWidth  = Math.floor(this.targetWidth  * scale)
const newHeight = Math.floor(this.targetHeight * scale)
canvas.style.left = `${(parentWidth  - newWidth)  / 2}px`
canvas.style.top  = `${(parentHeight - newHeight) / 2}px`

美術以固定解析度設計,不需要擔心不同螢幕比例導致構圖跑版。


三、UI 層:Vue 3 實際用在哪裡

Vue 負責全部 21 個元件:

元件 功能
DialogueBox.vue 對話框:打字機效果、說話者名牌、點擊/空格推進、自動模式
ChoiceMenu.vue 選項清單:顯示當前可選項、送出選擇
MainMenu.vue 主選單(新遊戲/讀檔/設定)
SettingsPanel.vue 設定面板(音量/文字速度/自動推進/無障礙選項)
SystemPanel.vue 存讀檔 UI(12 個手動存檔位、自動存檔、讀取存檔——快速存檔功能已於開發中途移除)
EvidenceModal.vue 證據面板(取得新證據時彈出)
FreeActionMap.vue 自由行動地圖(第一章行動點消耗)
DebatePlanner.vue 文辯(使臣論辯小遊戲)
LogisticsPlanner.vue 算籌(後勤規劃小遊戲)
DuelArena.vue 武鬥(格鬥選將小遊戲)
EndingScreen.vue 結局畫面
GalleryPanel.vue 圖鑑(CG/場景)
StatsPanel.vue 數值面板(各項指標)
LoadingScreen.vue 載入畫面
CharacterPortrait.vue 角色立繪包裝(與 Pixi 協作)
GameCanvas.vue PixiJS canvas 的 Vue 掛載點
AdminPanel.vue 開發者工具(章節跳轉/數值偵錯,正式環境不顯示)
ChapterLockScreen.vue 章節鎖定提示
ConfirmModal.vue 通用確認對話框
OrientationOverlay.vue 手機橫轉提示
SystemToasts.vue Toast 通知(獲得證據/解鎖章節等)

DialogueBox 的互動細節

DialogueBox.vue 是玩家接觸最多的元件,從 Pinia store 讀取目前的文字和說話者:

const store = useStoryStore()
const speaker = computed(() => findCharacter(store.speakerId))
const isNarration = computed(() => !store.speakerId) // 無 speaker = 旁白

互動邏輯:

  • 點擊/空格:打字中 → 跳到結尾;打字完成 → 推進到下一行
  • Ctrl/Cmd 按住:快進模式(每 130ms 自動推進一行)
  • Auto 模式:打字完成後等 1400ms 自動推進

四、狀態管理:Pinia 實際用在哪裡

整個遊戲只有一個主 store:stores/story.ts(1121 行)。它是連接所有層的中樞:

Ink 劇本
  ↓ StoryRuntime.continueLine()
Pinia store.advance()
  ↓ parseInkTags() + routeStoryCommands()
  ├─→ PixiJS(背景/角色/CG/特效)
  ├─→ AudioManager(BGM/環境音/SFX)
  └─→ Pinia state 更新(text/speaker/choices/...)
           ↓ Vue reactivity
       DialogueBox / ChoiceMenu / EvidenceModal / ... 自動更新畫面

Pinia 管理的狀態橫跨六個類別:

類別 範例欄位
劇情狀態 text, speakerId, choices, ended
UI 面板狀態 uiPanel, mapPanel, minigame, evidencePopupQueue
玩家數值 從 Ink 同步過來的 power, compassion
關係數值 六個主要角色的 affection, trust, alignment
設定 textSpeed, bgmVolume, autoAdvance, lowPerformance
解鎖內容 unlockedEndings, unlockedExtraStories

結局判定在 Pinia 觸發

當 Ink 劇本執行到 # ending:resolve 時,Pinia store 呼叫 EndingResolver.determineEnding(),傳入當前所有 Ink 變數的快照,計算觸發哪一條結局線。以柳如煙戀愛線為例:

{
  id: 'END_03_LIU',
  relKey: 'liu',
  min: { affection: 80, trust: 75, alignment: 55 },
  requiredTrue:  ['protected_liu_family'],
  requiredFalse: ['used_nianyan_as_evidence', 'abandoned_liu_family', 'liu_dead'],
  anyTrue:       ['recognized_nianyan', 'liu_returned_by_choice', 'abandoned_throne'],
}

所有結局條件都集中在 EndingResolver.ts 一個地方,不分散在各 Vue 元件裡。


五、建置/部署:Vite + Vercel 實際用在哪裡

engine/ 是 Vite 建置的核心樞紐

engine/ 下面的十個檔案分工明確:

engine/
  StoryRuntime.ts        inkjs 執行層(讀行、做選擇、存讀狀態)
  StoryCommandRouter.ts  #tag 分派層(把 Ink tag 翻譯成對 Pixi/Pinia/Audio 的呼叫)
  InkTagParser.ts        tag 解析層(raw string → typed command)
  SceneManager.ts        場景資源管理(背景/角色/CG 的切換邏輯)
  PixiStage.ts           PixiJS 舞台初始化與 resize 處理
  AudioManager.ts        BGM / 環境音 / SFX 控制
  EndingResolver.ts      結局判定(根據 Ink 變數推算觸發哪個 ending)
  ExtraUnlockResolver.ts 番外解鎖判定
  PreloadManager.ts      資源預載
  ink-loader.ts          Ink JSON 載入器(動態匯入)

Vite 的 chunk 拆分:讓玩家不用重下載 vendor

vite.config.ts 裡的 manualChunks 把三個大依賴拆開:

manualChunks: {
  'vendor-pixi': ['pixi.js'],
  'vendor-vue':  ['vue', 'pinia'],
  'vendor-ink':  ['inkjs'],
},

遊戲版本更新時,pixi.jsvueinkjs 通常不動,只有遊戲邏輯(src/)改變——拆開後玩家不需要重下 vendor bundle。另有 ANALYZE=1 npm run build 可以產出 treemap 分析報告來確認 chunk 大小。

Vercel 的部署策略

三個子系統各自的 routing 規則:/index.html(官網),/playplay.html(遊戲),/rankingranking.html(投票榜)。
遊戲本體全靜態,存檔在 IndexedDB(瀏覽器本地),不需要後端——不需要帳號系統、不需要資料庫維護費,玩家進度也不會因為伺服器問題消失。


層與層之間的隔離規則

規則
Ink 不碰 DOM / Pixi Sprite
PixiJS 不判斷劇情條件
不得用 v-html 顯示劇情
結局判定不得分散在元件
Pinia 是 UI 狀態唯一來源
Vue 與 Ink 不各自修改同一數值

本週成果

✅ 完成所有企劃文件與專案骨架

這週從「為什麼想做」走到「技術骨架長什麼樣子」,下週開始就要動到真正的核心邏輯——Scene、State、UI 怎麼分層,以及 Vue 3 要怎麼跟 Ink.js 對話。


上一篇
Day6 建立遊戲開發文件與專案管理流程
下一篇
Day8 遊戲架構設計:Scene、State、UI 如何分層?
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言