前六天都在講企劃跟文件,今天正式講講用了哪些技術架構,也是後面所有實作文章的地基。
《九重燼》目前的核心依賴版本:
"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 檔案的完整結構:
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.json,StoryRuntime 直接 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 負責五件事:
| 職責 | 說明 |
|---|---|
| 背景 | 根據 # 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`
美術以固定解析度設計,不需要擔心不同螢幕比例導致構圖跑版。
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 = 旁白
互動邏輯:
整個遊戲只有一個主 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 元件裡。
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.js、vue、inkjs 通常不動,只有遊戲邏輯(src/)改變——拆開後玩家不需要重下 vendor bundle。另有 ANALYZE=1 npm run build 可以產出 treemap 分析報告來確認 chunk 大小。
Vercel 的部署策略
三個子系統各自的 routing 規則:/ → index.html(官網),/play → play.html(遊戲),/ranking → ranking.html(投票榜)。
遊戲本體全靜態,存檔在 IndexedDB(瀏覽器本地),不需要後端——不需要帳號系統、不需要資料庫維護費,玩家進度也不會因為伺服器問題消失。
| 規則 |
|---|
| Ink 不碰 DOM / Pixi Sprite |
| PixiJS 不判斷劇情條件 |
不得用 v-html 顯示劇情 |
| 結局判定不得分散在元件 |
| Pinia 是 UI 狀態唯一來源 |
| Vue 與 Ink 不各自修改同一數值 |
✅ 完成所有企劃文件與專案骨架
這週從「為什麼想做」走到「技術骨架長什麼樣子」,下週開始就要動到真正的核心邏輯——Scene、State、UI 怎麼分層,以及 Vue 3 要怎麼跟 Ink.js 對話。