Day23 講的是 PixiJS 拿到素材清單之後怎麼快取和預載。Day25 要往前一步,看那份素材清單本身是怎麼生出來的。
真正占流量、也真正需要分批處理的,不是 Vue 元件,而是圖片、CG、立繪和音訊。這篇講的「動態載入」,主要指資源層:建置時先整理章節需要哪些素材,執行時再由 Pixi bundle 分批載入。
專案裡跟資源有關的檔案不少,最容易混在一起的是這三份:
src/data/characters-scenes.json
src/data/asset-manifest.ts
src/data/preload-manifest.json
characters-scenes.json 是遊戲資料表。它現在有 2463 行,裡面有 60 個角色、230 個場景、108 個 CG 定義和 17 個數值欄位。角色顯示名稱、立繪路徑、場景背景、CG 圖片、色票,大多都在這裡。
asset-manifest.ts 是資產登記表,現在有 3349 行,登記了 413 筆資產:61 筆道具、227 筆音訊、93 筆背景、32 筆 CG。這份資料偏向「檔案資產」本身,包含 kind、path、尺寸、章節歸屬等資訊。
preload-manifest.json 則是建置後產物。它不是手寫設定,而是由 scripts/generate-preload-manifest.ts 掃描劇本和資產表後產生,給 Day23 的 PreloadManager 使用。
這三份各管一件事:
characters-scenes.json 回答「這個遊戲 id 對應哪個顯示資料」。asset-manifest.ts 回答「這個 asset id 對應哪個檔案」。preload-manifest.json 回答「這個章節要預先載哪些圖片」。分清楚這一層,後面才不會把 Resource Manager、Assets Manager、Preload Manager 全部講成同一個東西。
package.json 裡的正式 build 會先跑:
"build": "npm run check:ink && npm run generate:manifest && npm run generate:sections && vite build"
其中 generate:manifest 對應:
"generate:manifest": "tsx scripts/generate-preload-manifest.ts"
也就是說,預載清單是在 build time 產生的,不是在玩家進遊戲時才現場分析劇本。這很重要。執行期越少做這種掃描,越容易讓遊戲啟動穩定。
腳本開頭讀兩份來源:
import { assetManifest } from '../src/data/asset-manifest.ts'
import charactersScenes from '../src/data/characters-scenes.json' with { type: 'json' }
接著遞迴掃描 src/narrative 底下的 .ink 檔。現在不是只掃主線章節,連 endings、extra、globals、characters 這些 .ink 也會走過一次,只要檔案在資料夾裡。
腳本主要靠正則抓 tag:
const bgRegex = /#\s*bg:\s*([^:\s]+)/g
const cgRegex = /#\s*cg:\s*([^:\s]+)/g
const charRegex = /#\s*char:\s*([^:\s]+):([^:\s]+)/g
const bgmRegex = /#\s*bgm:\s*([^:\s]+)/g
const sfxRegex = /#\s*sfx:\s*([^:\s]+)/g
const ambienceRegex = /#\s*ambience:\s*([^:\s]+)/g
掃到 # bg:xxx,就去 characters-scenes.json 的 scenes 找背景路徑。掃到 # cg:xxx,就去 cgs 找圖片。掃到 # char:角色:表情,就去角色資料裡找對應立繪。
音訊 tag 也會被掃到,但這裡有個限制:腳本最後只會把 Pixi 能預載的圖片副檔名放進 group。
function isPixiPreloadAsset(url: string): boolean {
return /\.(avif|gif|jpe?g|png|svg|webp)$/i.test(url)
}
所以 # bgm、# sfx、# ambience 目前比較像是被看見了,但 mp3 不會進 Pixi preload group。這跟 Day23 的結論一致:圖片走 Pixi Assets,音訊走 AudioManager 和瀏覽器原生 HTMLAudioElement。
掃描每個 Ink 檔時,腳本會先給一個預設章節。它嘗試從檔名抓:
const match = path.basename(file).match(/^(ch\d+)/i)
if (match) defaultChapter = match[1].toUpperCase()
const chapterMatch = line.match(/#\s*chapter:\s*(CH\d+)/)
const knotMatch = line.match(/^===\s*(CH\d+)/)
也就是 # chapter:CHxx 標籤,以及 === CHxx... === 這類 knot 名稱。
這套規則能處理不少主線內容,但還不是完美。Day23 已經提過,現在 common bundle 有 146 筆,代表仍有一批素材沒有被分到更精準的 CH group。像 # chapter:ch07 這種小寫標籤也不會被目前的 /CH\d+/ 正則吃到。
大部分資源靠掃描,但 boot 不是。
generate-preload-manifest.ts 一開始就手動放了一張圖:
preloadGroups.boot.add('/assets/scenes/chenghua-dian/night-rain.webp')
這張圖是開場和首頁優先需要的資源。它不適合完全交給掃描推斷,因為「玩家第一眼會看到什麼」不是正則表達式能判斷的事。
腳本後面也嘗試把 opening BGM 加進 boot:
if (isPixiPreloadAsset(asset.path) && (asset.id === 'bgm_001_dream_wakes_in_nine_palaces' || asset.id === 'opening_theme')) {
preloadGroups.boot.add(asset.path)
}
Ink tag 掃完之後,腳本還會看 asset-manifest.ts 裡每個資產有沒有 chapters 欄位:
for (const asset of Object.values(assetManifest.assets)) {
if (asset.chapters) {
for (const ch of asset.chapters) {
if (!preloadGroups[ch]) preloadGroups[ch] = new Set()
if (isPixiPreloadAsset(asset.path)) preloadGroups[ch].add(asset.path)
}
}
}
這一段補的是「不一定直接出現在 Ink tag 裡,但仍然屬於某章」的資產。道具、證據圖、某些 UI 或章節素材,會比較適合從 asset manifest 補進來。
注意這裡也一樣只收圖片副檔名。chapters 可以幫圖片進入章節 group,但不會讓音訊突然變成 Pixi preload。
目前 src/data/preload-manifest.json 有 469 行,實際 group 是:
boot, common, CH00, CH01, CH02, CH03, CH04, CH05, CH06, CH08, CH07, EXTRA
數量大概是:
boot 1
common 146
CH00 6
CH01 16
CH02 16
CH03 30
CH04 41
CH05 34
CH06 16
CH07 5
CH08 8
EXTRA 125
這些數字很有用,因為它們讓我們看到分組品質。CH03、CH04、CH05 有各自一批素材,代表章節 preload 確實在工作;但 common 和 EXTRA 都很大,代表還有資料混在比較粗的 group 裡。
產生 JSON 之後,輪到 src/engine/PreloadManager.ts。
它 constructor 會呼叫 addBundles(),把 manifest 裡每個 group 註冊成 Pixi bundle:
Assets.addBundle(bundleId, assetsList)
每個 asset 會有帶 bundle 前綴的 alias:
alias: `${bundleId}:${url}`
這個設計 Day23 已經講過,主要是避免不同 bundle 裡的同名檔案撞 alias。執行期真正載入時,GameView.vue 會在進遊戲時呼叫 initBoot(),章節切換時呼叫 loadChapter(chapterId),離開上一章時再 unloadChapter(oldChapter)。
所以整個流程其實是兩段:
build time:掃 Ink / asset manifest → 產生 preload-manifest.json
runtime:PreloadManager 讀 JSON → 註冊 Pixi bundle → 載入章節圖片
把這兩段分開,除錯會容易很多。manifest 內容不對,就查 generate script;切章節沒有載入,就查 PreloadManager 或 GameView。
Day25 也要把音訊講清楚,因為原稿很容易讓人以為所有資源都進了同一套 preload。
AudioManager.ts 用的是 HTMLAudioElement。BGM 有雙軌 crossfade,Ambience 有獨立環境音軌,SFX 是單發 new Audio(...),Voice 也是單軌播放。來源會透過 resolveAssetPath() 從 asset-manifest.ts 找,如果找不到才 fallback 到 /audio/${audioId}.mp3。
它也處理瀏覽器 autoplay 限制:BGM 或 ambience 播放失敗時,先記到 pendingBgmId / pendingAmbienceId,等玩家第一次 pointer 或 keydown 互動後再補播。
這些是播放控制,不是預載系統。現在沒有一個「AudioPreloadManager」或音效池。這也是目前可以改進的地方:如果未來 SFX 更多、語音更多,音訊可能需要自己的預熱策略,而不是只靠播放時載入。
scripts/validate-assets.ts 也是掃 Ink tag,但目的不同。它不是產生 preload manifest,而是驗證:
asset-manifest.ts 裡登記的檔案是否真的在 public/。它會吐出 error 或 warning,最後決定驗證是否通過。也就是說:
generate-preload-manifest.ts:為了載入效能,產生章節圖片清單
validate-assets.ts:為了內容正確性,檢查引用和檔案是否對得上
兩者都掃素材,但目的不同。這種分工如果不講清楚,很容易把「驗證」和「資源管理」混在一起。
Day25 的重點不是「我們用了某個高級的 dynamic import 技術」。其實剛好相反,這篇最重要的是:目前沒有路由層級的程式碼懶載入,主要載入壓力都在素材。
《九重燼》的做法是先用 build-time 腳本把 Ink tag 和 asset manifest 整理成章節圖片清單,再交給 runtime 的 Pixi bundle 載入。對現在的 Web Demo 來說,它已經讓資源管理從「看到圖再載」進到「章節進場前先準備」。下一步,就是讓分組更準、音訊策略更清楚。