Day8要進到實作核心——一行 Ink 文字,到底怎麼一步步變成畫面上的背景切換、立繪出現、對話框打字?
其實說穿了就是一條路:advance() 被叫一次,Ink 往前走一行,tag 被解析,各層收到通知各自做各自的事。把這條路搞清楚,後面很多問題就自然有答案了。
src/engine/StoryRuntime.ts 是最底層,它包了 inkjs 的 Story 物件,但刻意只暴露幾個動作:
continueLine() — 往前走一行,回傳 { text, tags, choices }
choose(index) — 做選擇jumpTo(knot) — 跳到指定節點getVariable / setVariable — 讀寫 Ink 變數exportState / importState — 存讀檔用的 JSON 快照薄到什麼程度?它完全不知道畫面長什麼樣。你問它「現在背景是什麼」,它不知道,也不在乎。它唯一的任務是「照 Ink 腳本邏輯,給我下一行的內容和附帶指令」。
生產環境不會在瀏覽器即時編譯 .ink,而是直接 import 預先編譯好的 main.json:
import compiledStory from '../narrative/compiled/main.json'
start(): void {
this.story = new Story(compiledStory)
}
編譯這步在 CI build 時就跑完了,瀏覽器只管執行——inkjs 的 browser 端即時編譯又慢又大包,能省就省。StoryRuntime 是整個系統裡最穩定的部分,自從設計確定之後幾乎就沒動過,因為它負責的事情實在太少了。
advance():所有事情的起點StoryRuntime 是底層,Pinia store 的 advance() 才是「按一下繼續」真正發生的地方:
async advance(): Promise<void> {
if (!runtime || this.choices.length > 0) return
if (!runtime.canContinue) {
this.ended = true
return
}
let text = ''
while (runtime.canContinue) {
const line = runtime.continueLine()
text = line?.text ?? ''
await this._applyTags(line?.tags ?? []) // ← tag 在這裡被處理
if (text) break // ← 跳過 tag-only 空行
}
if (text) {
this.text = text
this.log.push({ speakerId: this.speakerId, text, chapterId: this.chapterId })
if (this.log.length > 1000) this.log.shift() // ← 回顧紀錄上限 1000 筆
}
this.choices = runtime.choices.map(c => c.text)
this._syncVariables() // ← Ink 變數同步回 Pinia
if (this._pendingAutosave) {
const label = this._pendingAutosaveLabel
this._pendingAutosave = false
void this.save(AUTO_SAVE_SLOT_ID, 'auto', label)
}
// 地圖面板開著但沒有選項、劇本還能繼續 → 遞迴自動吃完剩餘文字
if (this.mapPanel && this.choices.length === 0 && runtime.canContinue) {
await this.advance()
}
}
注意那個 while:Ink 腳本裡有些行是純 tag、沒有對白文字,例如剛進場景時連著打好幾行 # bg:xxx、# bgm:xxx、# char:xxx。advance() 會一直往前吃,直到找到真正有文字的那一行才停下來——這樣玩家不會看到一個空白的對話框閃一下。
結尾那個地圖遞迴也值得說一下。地圖面板打開的當下,DialogueBox 會擋住玩家點擊繼續。
但 # ui:show_map 這個 tag 常常出現在還有幾行旁白文字之前——地圖開了,旁白還沒讀完,玩家看到地圖介面,但選項遲遲不出現,就會卡住。
解法是:偵測到地圖開著但沒有選項,advance() 自動吃完剩下的行直到選項出現,玩家打開地圖就能直接看到可以選的地點。
_applyTags():tag 到畫面的橋接層advance() 裡的 _applyTags() 是整個架構裡最長的一個方法,做的事是:把每一行附帶的 tag 字串陣列,交給 InkTagParser 解析,再交給 StoryCommandRouter 分派到五個 context:
raw tags: ['bg:chenghua_dian', 'bgm:opening_theme', 'char:prince:default:smile:left']
↓ InkTagParser.parseInkTags()
typed commands: [
{ type: 'bg', args: ['chenghua_dian'] },
{ type: 'bgm', args: ['opening_theme'] },
{ type: 'char', characterId: 'prince', outfitId: 'default',
expressionId: 'smile', position: 'left' }
]
↓ StoryCommandRouter.routeStoryCommands(commands, context)
→ pixi.setBackground('chenghua_dian') ← 更新 store.sceneId
→ audio.setBgm('opening_theme') ← AudioManager 播放
→ pixi.setCharacter(...) ← 更新 store.charId / expr / charPos
StoryCommandRouter 接到 typed command 陣列,用一個 switch 分派到五個 context:
| context | 負責的 tag 類型 |
|---|---|
pinia |
chapter, scene, event, line, speaker, minigame, ui, ending |
pixi |
bg, bg_transition, char, char_hide, cg, cg_hide, cg_anim, vfx |
audio |
bgm, bgm_stop, ambience, ambience_stop, sfx |
save |
autosave, checkpoint |
system |
ending:resolve, unlock, toast |
Router 本身不含任何業務邏輯,只負責分派。這條界線在實際開發裡很有用:像新增 toast tag 讓 Ink 劇本直接彈出提示框這個功能,InkTagParser.ts 和 StoryCommandRouter.ts 只是負責把 tag 解析成 typed command 再分派出去;真正要能在畫面上跳出提示,還是得建一個新的 Vue 元件 SystemToasts.vue,掛進 GameView.vue,並在 story.ts 接上 useToastStore。Router 這層省掉的是「Ink 腳本怎麼觸發」的判斷邏輯,不是整個功能的畫面實作。
_applyTags() 裡還有兩條自動清理規則,這是從實際踩坑經驗加進去的:
換背景時沒有重新指定立繪 → 自動清空上一幕角色
切場景了但角色殘留在畫面上,看起來像鬼魂。現在只要 changedBackground && !changedCharacter,自動把 charId / expr / charPos 設成 null。
換背景時沒有重新指定 CG → 自動關閉 CG
滿版 CG 如果沒有明確 # cg_hide,會一路蓋住後續場景,變成黑屏。changedBackground && !changedCg 就自動清。
choose():選項不只是送數字玩家選了一個選項,choose() 做了幾件事:
choose(index: number): void {
const choiceText = this.choices[index]
if (choiceText) {
// 把選擇加進對話回顧,讓玩家能回頭看自己選了什麼
this.log.push({ speakerId: null, text: choiceText, isChoice: true, chapterId: this.chapterId })
}
this.mapPanel = null // 選完自動關地圖
this.mapError = null
runtime.choose(index) // 告訴 inkjs 選哪個
this.choices = [] // 清掉選項,避免 UI 殘留
void this.advance() // 直接往下走
}
對話回顧(backlog)把玩家自己做的選擇也記進去,而不只記台詞,這讓回顧面板讀起來更像完整的對話紀錄,而不是只有一堆旁白跟台詞、看不出選了什麼。
地圖選點是 choose() 的一個特化版本——chooseMapLocation() 先做路徑判斷(是否還有足夠行動點、是否已完成必要地點),再把地名轉成 choices 陣列裡對應的 index,最後呼叫同一個 choose(),不另外刻一套邏輯。
_syncVariables():Ink 變數怎麼流進 Vue每次 advance() 結束都會跑一次 _syncVariables(),把 inkjs runtime 裡的各項數值讀出來,寫進 Pinia state,讓 Vue 的 reactivity 自動觸發 UI 更新:
_syncVariables(silent = false): void {
// 數值(power / compassion / ...)
for (const key of STAT_KEYS) this.stats[key] = read(key)
// 關係數值 → 有變動時自動 toast 提示
for (const { id, relKey } of REL_CHARACTERS) {
const rel: RelationMetrics = {}
for (const metric of REL_METRICS) {
const val = read(`rel_${relKey}_${metric}`)
const oldVal = this.relations[id]?.[metric]
if (!silent && oldVal !== undefined && val !== oldVal) {
const name = gameData.characters[id]?.displayName || id
const metricName = gameData.relationMetrics[metric] || metric
if (val > oldVal) useToastStore().add('positive', `【${name}】${metricName}上升了`)
else useToastStore().add('negative', `【${name}】${metricName}下降了`)
// metricName 直接來自 relationMetrics 的顯示名(好感/信任/立場/畏懼/理念/敵意),不含「度」字尾
}
rel[metric] = val
}
this.relations[id] = rel
}
// 第一章自由行動狀態
this.ch01ActionPoints = read('ch01_action_points')
this.ch01PlacesVisited = read('ch01_places_visited')
for (const [locationId, flagKey] of Object.entries(CH01_MAP_LOCATION_FLAGS)) {
this.ch01VisitedLocations[locationId] = runtime.getVariable(flagKey) === true
}
// 番外篇完成度
this._syncExtraStoryCompletions(readValue)
}
關係數值變化的 toast 提示是一個有趣的設計點:同步時如果偵測到某個角色的好感、信任、立場、畏懼、理念、敵意任一項和上一次不一樣,就自動彈出「【柳如煙】好感上升了」這種提示(指標名稱直接取自 relationMetrics 的顯示名,不加「度」字尾),不需要 Ink 劇本裡手動寫 # toast:...。這樣劇本作者不用每個選項後面都補一行 toast tag,_syncVariables() 自動比對差值就能做到。
silent = true 是讀取存檔時用的——還原存檔狀態的時候不應該噴一堆「某某關係變化」的舊提示,所以靜默模式跳過 toast。
SceneManager 是 PixiJS 這邊的管理者。它的 setScene() 裡有個細節值得講:
async setScene(sceneId: string, backgroundUrl?: string): Promise<void> {
const request = ++this.sceneRequest // ← 遞增請求編號
if (backgroundUrl) {
const texture = await this._loadTextureWithRetry(backgroundUrl)
if (request !== this.sceneRequest) return // ← 競態保護:舊請求直接丟棄
if (texture) {
this._clearBackground()
this.currentBackground = new Sprite(texture)
this._layoutScene()
return
}
}
if (request === this.sceneRequest) this._drawGradient() // ← fallback
}
sceneRequest 計數器解決了一個非同步競態問題:場景切換要等圖片下載,如果快速跳轉,可能「第二張圖先下載完、第一張圖後下載完,第一張圖反而把畫面蓋掉」。有了計數器,最新的請求編號才有資格更新畫面,舊的請求圖片回來了也直接忽略。
圖片下載:最多重試三次
private async _loadTextureWithRetry(url: string, retries = 2) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await Assets.load(url)
} catch {
if (attempt === retries) {
console.warn(`場景素材載入失敗(已重試 ${retries} 次),改用漸層:${url}`)
return null
}
await new Promise(r => setTimeout(r, 300 * (attempt + 1))) // 300ms / 600ms
}
}
}
第一次失敗等 300ms,第二次等 600ms,三次全失敗才 fallback 到漸層色,不會因為一次短暫網路抖動就立刻退化。
漸層 fallback:48 個色帶,不是 CSS gradient
真正的漸層不是用 CSS,是用 PixiJS Graphics 一條一條畫:
private _drawGradient(): void {
const [top, bottom] = this.currentPalette
const g = new Graphics()
const bands = 48
for (let i = 0; i < bands; i++) {
const t = i / (bands - 1)
g.rect(0, (this.h / bands) * i, this.w, this.h / bands + 1)
.fill(lerpColor(top, bottom, t))
}
this.bgLayer.addChild(g)
}
為什麼不用 CSS?因為這是 PixiJS canvas 內部的東西,CSS gradient 沒辦法塞進去。48 個色帶夠多,視覺上看起來平滑,承華殿是深藍紫、朝堂是深琥珀、聽雨樓是深紫——至少不是一片黑。
resize 的兜底機制
ResizeObserver 監聽容器尺寸變化,讓 canvas 隨視窗縮放。但有些嵌入環境(iframe、預覽面板)不一定會正確觸發 ResizeObserver,所以還有一個 ticker 兜底:每 250ms 比對一次容器尺寸和 canvas 尺寸,不一致就強制 resize:
private _checkResize(deltaMS: number): void {
this.resizeCheckAccum += deltaMS
if (this.resizeCheckAccum < 250) return
this.resizeCheckAccum = 0
if (host.clientWidth !== canvas.clientWidth || host.clientHeight !== canvas.clientHeight) {
this.app.resize()
}
}
兩層保護,ResizeObserver 掛了還有 ticker 兜著,不會出現「視窗縮小了但 canvas 還是原來大小」的情況。
特效不靠任何粒子函式庫,全部是用 PixiJS Graphics + Ticker 手刻的程序式動畫,分成兩類:
持續型(進場景後一直跑)
| 特效 | 粒子數 | 說明 |
|---|---|---|
rain |
140 個 | 每幀重繪所有雨滴,隨機速度 10–24、長度 14–32 |
candlelight |
— | 全畫面暖橙色半透明遮罩,亮度隨 sin 波動 |
blood |
30 個 | 從畫面上方落下的血滴,隨機大小與 alpha |
dust |
80 個 | 細碎浮塵,帶左右隨機漂移速度 |
memory_blur |
— | 動態載入 PixiJS BlurFilter,套在 bg/char/cg 三層上 |
一次性(播完自動移除)
| 特效 | 說明 |
|---|---|
tension_flash |
白色遮罩快速淡出,每幀 alpha -= 0.045 |
lightning |
兩段閃爍:0–50ms 全亮 → 50–100ms 滅 → 100–150ms 八成亮 → 淡出 |
screen_shake |
360ms 震動,強度 10,每幀在 stage 上加隨機偏移 |
fade_out |
黑色遮罩淡入(hold 600ms)再淡出 |
雨滴預設 140 個粒子,低效能模式用 particleScale 縮減。重要的是:改設定之後如果特效正在跑,會立刻清掉重建,不用等到下個場景才生效:
setParticleScale(scale: number): void {
if (this.particleScale === scale) return
this.particleScale = scale
if (this.activeEffectId) {
const active = this.activeEffectId
this._clearPersistent()
this.setEffect(active) // ← 立即以新粒子數重建
}
}
story.ts 的 state 分六塊,大到小列一次:
// 劇情位置
chapterId, sceneCode, eventId, lineId
// 當前這一行
text, speakerId, choices
// 畫面狀態(會同步給 PixiJS 元件)
sceneId, charId, charOutfit, expr, charPos,
cgId, cgAnim, effectId, oneShotFx, bgm, ambience
// 玩家數值與關係(從 Ink 同步過來)
stats, relations,
investigationFlags, investigationRemaining,
ch01ActionPoints, ch01PlacesVisited, ch01VisitedLocations,
evidenceRecords, characterEventFlags
// UI 面板狀態
uiPanel, mapPanel, minigame, evidencePopupQueue, mapError
// 對話回顧
log[](最多 1000 筆,超過自動 shift 掉最舊的)
_syncVariables() 在每次 advance() 結束後跑一次,把 inkjs 裡的值全部讀出來更新到 store。Vue 元件(例如 StatsPanel)只讀 store,不直接碰 inkjs——這樣哪天換掉底層的敘事引擎,Vue 元件那邊理論上一行都不用改。
把這整條路徑收回來看:
玩家點擊 / 按空格
↓
store.advance()
↓
StoryRuntime.continueLine() → 吐出 text + tags + choices
↓
_applyTags()
├─ InkTagParser → typed commands
└─ StoryCommandRouter
├─ pixi.setBackground() → store.sceneId 更新 → GameCanvas watch → SceneManager
├─ pixi.setCharacter() → store.charId/expr 更新 → CharacterPortrait 更新
├─ audio.setBgm() → AudioManager.playBGM() 直接呼叫
├─ pinia.setSpeakerId() → store.speakerId 更新
└─ save.requestAutosave()→ 標記 pendingAutosave
↓
_syncVariables()
└─ 讀所有 Ink 變數寫回 store.stats / store.relations
→ StatsPanel / 關係提示 Toast 自動更新
↓
store.text / speakerId / choices 全部 reactive
↓ (Vue reactivity)
DialogueBox 顯示新文字、ChoiceMenu 顯示新選項
這條流只有一個方向——畫面不會主動改劇情,劇情不會主動改畫面,所有狀態的更新都從 advance() 這個入口進來。這在 debug 的時候特別有用:看到畫面上哪裡不對,直接往 advance() 和 _applyTags() 找,不用猜是哪個 Vue 元件偷偷改了什麼。
這週把 advance() 這條核心路徑的每個環節都看了一遍。
下週要講的是這個架構裡兩個更複雜的部分:存讀檔(怎麼把 Ink state + Pinia state + 畫面狀態全部打包成一份快照,以及讀回來的時候畫面要怎麼還原)、以及 inkjs 的 Variable Bridge(Ink 變數怎麼跟 Vue 的 reactivity 保持同步而不出現雙向修改的問題)。