模組五|關卡、UI 與資料(Day 21–25)
昨天講場景切換要清乾淨自己配置的東西。今天往前推一步:這些東西是怎麼進到記憶體裡的。
先攤開數字。這個專案的素材是 19 個 SVG,零張 PNG(find public/assets -name '*.svg' | wc -l → 19)。它們全部登記在 src/config/assets.js,透過一份 PixiJS Manifest 一次載完。整個載入層是兩個檔案:src/config/assets.js(220 行)與 src/core/AssetManager.js(210 行)。
結論先講:SVG 一旦被 PixiJS 載成 Texture,它在畫面上就是一張點陣圖了,「向量可以無限縮放」這句話從那一刻起不成立。 這決定了哪些東西該當素材、哪些該用程式畫。而這一篇還有第二條線——PRD 裡有一份 267 行的 AssetManager 完整骨架,實作交出來的東西跟它有三處差異,那三處差異剛好落在最能說明「規格能交代什麼、不能交代什麼」的位置。
PixiJS 的 Assets API 有三層概念:Manifest 是全部素材的清單、Bundle 是分組、Alias 是程式裡用的名字。這個專案的 Manifest 長這樣(src/config/assets.js:170-180):
export const ASSET_MANIFEST = {
bundles: [
{
name: 'boot',
assets: FIRST_STAGE_ASSET_ALIASES.map((alias) => ({
alias,
src: assetUrl(`assets/${ASSET_ALIASES[alias].path}`)
}))
}
]
}
這個陣列的長度是 1。
PRD 裡規劃的是三組(PRD.md:510-631):boot 放首頁需要的四個、game-core 在首頁背景載入、level-advanced 進第三關前才載。實作只做了 boot,而且把 19 個全塞進去,main.js:146 是全 repo 唯一一個 loadBundle 呼叫點。進關卡沒有第二次載入,沒有預載下一關,沒有 lazy load。
我不打算把這寫成「還沒做完」。分批載入的價值要在素材量大到會拖住首屏時才出現,這個專案不是。Bundle 這層抽象我留著,但它現在的長度是 1——而長度 1 的抽象要誠實承認自己在空轉:AssetManager.js:171-190 的 releaseBundle 寫了引用計數(卸載某個 alias 前先確認沒有其他已載入的 bundle 在用它),但只有一個 bundle 時,那個 [...this.loadedBundles].some(...) 永遠掃過一個空集合。
真正每天在用的是 Alias 這層。19 條登記把 alias 與檔案路徑綁在一起(assets.js:34-168),程式裡取用寫的是 getTexture('dogIdle')。我數過 src/ 底下 18 個 getTexture 呼叫點(grep -rn "getTexture(" src/,扣掉 AssetManager.js 裡定義它的那一行),沒有一個硬寫檔名或路徑。而 URL 一律過 assetUrl()(src/config/paths.js:1-6),它負責把 Vite 的 import.meta.env.BASE_URL 接上去——部署路徑改變的時候,要改的是這一個函式,不是 18 個呼叫點。

這是這篇最反直覺的一點,而且它不是我的推測,可以直接讀 node_modules。專案用的是 PixiJS 8.19.0,Assets.load 遇到 .svg 走 loadSVG 這個 parser,預設分支是 loadAsTexture(node_modules/pixi.js/lib/assets/loader/parsers/textures/loadSVG.mjs):
async function loadAsTexture(url, asset, loader, crossOrigin) {
const response = await DOMAdapter.get().fetch(url)
const image = DOMAdapter.get().createImage()
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(await response.text())}`
image.crossOrigin = crossOrigin
await image.decode()
const width = asset.data?.width ?? image.width
const height = asset.data?.height ?? image.height
const resolution = asset.data?.resolution || getResolutionOfUrl(url)
const canvasWidth = Math.ceil(width * resolution)
const canvasHeight = Math.ceil(height * resolution)
const canvas = DOMAdapter.get().createCanvas(canvasWidth, canvasHeight)
const context = canvas.getContext('2d')
// ...drawImage 後包成 ImageSource
}
drawImage 到 canvas 上的那一行,就是向量變成像素的地方。 從此之後它是一張固定寬高的點陣圖,跟一張 PNG 沒有分別。PixiJS 官方的 SVG 指南把兩條路的取捨列得很清楚:Texture 這條「不保留向量圖的可縮放性」、「放大可能像素化」、「無法動態修改形狀」,換來的是「以 quad 而非幾何體渲染」的速度。另一條路是 Assets.load(url, { parseAsGraphicsContext: true }),回傳 GraphicsContext,保留向量、可以改色,代價是解析成本。
這個專案 19 個素材全部走 Texture 這條,而且我要誠實標一件事:「放大會糊」我沒有實測過,那是官方文件的描述加上上面那段程式碼的推論,不是我量出來的。
還有一件更值得寫的:那個 resolution 我從來沒有設過。看清楚它怎麼取值——asset.data?.resolution || getResolutionOfUrl(url)。Manifest 的每個 asset 條目只有 alias 跟 src,沒有 data,所以走右邊;而 getResolutionOfUrl 的預設值是 1,只有檔名帶 @2x 這類 retina 前綴才會變(node_modules/pixi.js/lib/utils/network/getResolutionOfUrl.mjs)。我的 19 個檔名一個 @ 都沒有。
所以光柵化倍率是 1,這不是我選的,是我沒選。
這裡要拆掉一個我自己在大綱裡寫過的錯誤。我原本寫「載入時設 2 倍夠用、設 4 倍記憶體翻倍」,那是把兩件事混在一起。這個專案唯一設過的 resolution 是 renderer 的:
| 項目 | 值 | 位置 |
|---|---|---|
| Renderer resolution | Math.min(devicePixelRatio, 2) |
src/core/createApplication.js:17 |
| 上限常數 | MAX_RENDERER_RESOLUTION = 2 |
src/config/game.js:7 |
| SVG 光柵化 resolution | 未設定,取預設 | assets.js:174-177 無 data 欄位 |
夾在 2 是有理由的:高 DPI 手機回報 3 甚至 4,照單全收等於多算兩三倍的像素,而這個專案的效能預算在 Day 27 才會展開。但renderer 畫多細,跟那張材質本身有多少像素,是兩個獨立的旋鈕,我只轉了其中一個。第二個旋鈕在哪、預設值是多少,我是寫這篇的時候翻 node_modules 才確認的。
分工可以完全用 grep 驗證。src/ 底下 new Graphics( 共 11 處、6 個檔案:
| 檔案 | 位置 | 畫的東西 |
|---|---|---|
BootScene.js |
:7 |
開機畫面整片背景 |
GameScene.js |
:63、:66、:463 |
背景、畫線預覽、除錯提示 |
LevelSelectScene.js |
:21、:112 |
背景、卡片底板 |
AssetGalleryScene.js |
:22、:96 |
背景、格子底板 |
ResultOverlay.js |
:24、:32 |
半透明遮罩、結算底板 |
DrawingSystem.js |
:32 |
逐幀重畫的那條線 |
歸納起來很乾淨:Graphics 管大面積純色、底板、遮罩、逐幀變形的線;Texture 管有造型的具象物件。 19 個 SVG 沒有一個是背景或底板,11 個 Graphics 沒有一個在畫角色。
而 BootScene 是這條分界線上最硬的證據——它不能用 Texture,因為它比素材早出現。啟動順序是 main.js:99 先 sceneManager.goTo('boot'),:143 啟動 GameLoop,:146 才 await assetManager.loadBundle('boot')。開機畫面要在素材載完之前就掛在畫面上,所以它一個 Texture 都不能碰。整個 BootScene.js 67 行,只有一個 Graphics 跟一串 rect/circle/roundRect。
「這個畫面在什麼時候要出現」這個約束,直接決定了它只能用哪一種畫法。 這比任何「Texture 比較快、Graphics 比較靈活」的通論都具體。
PRD.md:744-1012 有一份 267 行的「AssetManager 完整骨架」,方法名一路寫到 delay。我把它跟實作逐個方法比對過:11 個方法名完全相同,一個不多一個不少。但差異在細節,而且方向不一致:
| # | PRD 骨架 | 實作 | 方向 |
|---|---|---|---|
| 1 | constructor(manifest),內部直接呼叫模組層級的 Assets 與 Texture |
constructor({ manifest, assetsApi = Assets, textureClass = Texture, retryDelayMs = 300 }) |
實作加強:三個相依都可注入 |
| 2 | init() 是 if (initialized) return + await Assets.init(...) |
多存一個 this.initPromise,把並行呼叫收斂到同一個 promise(:46-56) |
實作加強:規格版本兩個並行呼叫會各自 init 一次 |
| 3 | releaseBundle 用 Promise.allSettled + console.warn,卸載失敗不會炸 |
await Promise.all(...)(:179-189),一個 alias 卸載失敗整個 releaseBundle reject |
實作退步 |
前兩項為什麼會冒出來,答案在測試裡:tests/unit/AssetManager.test.js 全部 6 條測試都靠 createManager(assetsApi) 注入假的 Assets、假的 Texture 類別與 retryDelayMs: 0(:115-123),而第一條測試的名字就叫 initializes PixiJS Assets only once——initPromise 是為了讓那條斷言成立而存在的。
第 3 項則是往回退:destroy() 逐個呼叫 releaseBundle(:192-197),而 main.js:140 把 runtime.destroy 掛在 pagehide 上。離開頁面時可能丟出一個沒人接的 rejection,這是我讀規格才發現的。
這張表就是本節結論:規格能交出去的是形狀,交不出去的是「這東西要怎麼被測」。

這一節最不好看,也最有價值。以下四件都存在於程式碼裡,而且都沒有在執行時發揮作用:
一、軟失敗的參數。 getTexture 第二個參數有 required 跟 fallback(AssetManager.js:145-161):
getTexture(alias, { required = true, fallback = Texture.EMPTY } = {}) {
const resource = this.get(alias, { required: false })
const texture =
resource instanceof this.textureClass ? resource : resource?.texture
if (texture) {
return texture
}
if (required) {
throw new AssetLoadError(`Texture has not been loaded: ${alias}`, { alias })
}
return fallback
}
素材壞掉時回一張空材質、遊戲繼續跑,這個機制是寫好的。但 18 個呼叫點全部用預設的 required: true——grep -rn "required: false" src/ 只命中 AssetManager.js 內部的兩行。也就是說,這條軟失敗路徑一次都沒有被走過。
二、載入進度。 loadBundleWithRetry 的 onProgress 帶了 bundleName、progress、percent、attempt、cached 五個欄位(:104-110),寫得很完整。而唯一的呼叫點 main.js:146 是 await assetManager.loadBundle('boot'),沒有第二個參數。所以沒有進度條,那個 callback 每次都打進預設的空函式。
三、線性退避。 重試迴圈的等待是 this.retryDelayMs * (attempt + 1)(:116),看起來是退避。但 retries 預設 1,迴圈只跑 attempt 0 和 1,而退避只在 attempt < retries 時觸發——也就是只會發生在 attempt 0,乘數永遠是 1。這段程式碼實際的行為是「固定等 300 毫秒」,那個乘法從來沒有大於一過。 而且整個重試迴圈零測試覆蓋:那 6 條測試裡唯一提到 AssetLoadError 的(:93-98)測的是未知 bundle 名稱的早期拒絕,不是重試耗盡。
四、三層錯誤處理。 PRD :1014-1036 寫了一張表:關鍵素材失敗停在 BootScene 顯示重試與錯誤代碼、關卡素材失敗擋住該關但可回選單、裝飾素材失敗用空 Texture 不擋遊戲。實作零分層。 一個 bundle、19 個素材,包含雲(cloud)和畫線圓點(drawPoint)這種純裝飾,任何一個失敗整包 reject——跟 PRD 第三層的要求剛好相反。
而「停在載入畫面顯示重試」的實際樣子:main.js:163-170 的 catch 把錯誤寫進 console.error 與 window.__SAVE_THE_DOG_DEBUG__.assetError,畫面停在 BootScene,而 BootScene 全檔 67 行只有一個 Graphics,沒有任何 Text、沒有按鈕、updateFrame() 是空函式。玩家看到的是一張不會動的背景圖。
這四件有同一個形狀:機制寫了,接線沒接。 而且都不會被任何檢查抓到——lint 不會說「你這個參數沒人傳」,測試也不會,因為測試測的是機制本身,機制是好的。這種債只有在讀自己的程式碼時才會浮出來。
這個檔案有多少是我寫的、多少是 AI 寫的,我沒有留下紀錄,所以不把它寫成故事(這個系列的規矩:沒有紀錄的協作過程一律不補寫)。
但有一件不需要紀錄也能查的事:PRD 那份 267 行骨架,跟最後交出來的 210 行實作,11 個方法名完全一致。這種程度的規格已經不是「需求」,是「介面定義」——交給誰做都不會做出結構不同的東西。
分界線在上一節那張表:規格能鎖住方法有哪些、參數叫什麼、失敗丟什麼錯;鎖不住「相依要不要可注入」「並行呼叫會不會重複 init」。那兩件只有在寫測試時才會被逼出來,而規格不會替你寫測試。 反過來,第 3 項那個從 allSettled 退回 Promise.all 的變更也說明了另一面:沒有人在實作時回頭讀規格,包括我自己。
一句話:
「向量圖可以無限縮放」在瀏覽器裡成立,在 WebGL 裡不成立。用哪一種方式呈現,取決於這個東西會不會變。
三件今天就能做的事:
node_modules 確認你的載入器到底做了什麼。 「SVG 載進來還是向量」這個直覺,只要讀一次 drawImage 那三行就會被修正,成本是五分鐘。grep 一次呼叫點,看有沒有人傳過非預設值。沒有人傳過的,要嘛接線,要嘛刪掉,不要讓它假裝存在。明天 Day 25,換一種資料:玩家自己產生的那份。localStorage 裡只存四個欄位,我第一天就加了版本號,一小時二十四分鐘之後把它從 1 升到 2——然後所有版本 1 的存檔在那個 if 裡被判定為不符、整份重置。這篇要講的是那一行版本號到底買到了什麼,以及它明確地沒有買到什麼。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑與指令自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
Assets.init)
深入原理