iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Modern Web

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

Day25 遊戲資源管理:Assets、章節與動態載入設計

  • 分享至 

  • xImage
  •  

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 全部講成同一個東西。

generate-preload-manifest.ts 是建置時腳本

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 也會走過一次,只要檔案在資料夾裡。

它從 Ink tag 反推素材需求

腳本主要靠正則抓 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.jsonscenes 找背景路徑。掃到 # 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 bundle 是人工指定的

大部分資源靠掃描,但 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)
}

asset-manifest.ts 的 chapters 欄位會補第二輪

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。

現在生成出的 preload manifest

目前 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

這些數字很有用,因為它們讓我們看到分組品質。CH03CH04CH05 有各自一批素材,代表章節 preload 確實在工作;但 commonEXTRA 都很大,代表還有資料混在比較粗的 group 裡。

PreloadManager 只負責執行期載入

產生 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。

AudioManager 是另一條線

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 更多、語音更多,音訊可能需要自己的預熱策略,而不是只靠播放時載入。

validate-assets 是另一種掃描

scripts/validate-assets.ts 也是掃 Ink tag,但目的不同。它不是產生 preload manifest,而是驗證:

  • Ink 引用的角色、表情、背景、CG 是否存在。
  • asset-manifest.ts 裡登記的檔案是否真的在 public/
  • prop 圖片尺寸是否符合登記。
  • 音訊 id 是否有定義。

它會吐出 error 或 warning,最後決定驗證是否通過。也就是說:

generate-preload-manifest.ts:為了載入效能,產生章節圖片清單
validate-assets.ts:為了內容正確性,檢查引用和檔案是否對得上

兩者都掃素材,但目的不同。這種分工如果不講清楚,很容易把「驗證」和「資源管理」混在一起。


Day25 的重點不是「我們用了某個高級的 dynamic import 技術」。其實剛好相反,這篇最重要的是:目前沒有路由層級的程式碼懶載入,主要載入壓力都在素材。

《九重燼》的做法是先用 build-time 腳本把 Ink tag 和 asset manifest 整理成章節圖片清單,再交給 runtime 的 Pixi bundle 載入。對現在的 Web Demo 來說,它已經讓資源管理從「看到圖再載」進到「章節進場前先準備」。下一步,就是讓分組更準、音訊策略更清楚。


上一篇
Day24 多平台展望:Web 之外還能怎麼走?
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言