iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

模組五|關卡、UI 與資料(Day 21–25)

昨天講場景切換要清乾淨自己配置的東西。今天往前推一步:這些東西是怎麼進到記憶體裡的。

先攤開數字。這個專案的素材是 19 個 SVG,零張 PNGfind 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 完整骨架,實作交出來的東西跟它有三處差異,那三處差異剛好落在最能說明「規格能交代什麼、不能交代什麼」的位置。


Manifest、Bundle、Alias:三個名字,我只真的用到一個

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-190releaseBundle 寫了引用計數(卸載某個 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 個呼叫點。


SVG 變成 Texture 那一刻,程式做了什麼

拓下來之後我手上只剩這張紙,紙上的邊緣已經是一格一格的了

這是這篇最反直覺的一點,而且它不是我的推測,可以直接讀 node_modules。專案用的是 PixiJS 8.19.0,Assets.load 遇到 .svgloadSVG 這個 parser,預設分支是 loadAsTexturenode_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 條目只有 aliassrc,沒有 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-177data 欄位

夾在 2 是有理由的:高 DPI 手機回報 3 甚至 4,照單全收等於多算兩三倍的像素,而這個專案的效能預算在 Day 27 才會展開。但renderer 畫多細,跟那張材質本身有多少像素,是兩個獨立的旋鈕,我只轉了其中一個。第二個旋鈕在哪、預設值是多少,我是寫這篇的時候翻 node_modules 才確認的。


什麼交給 Texture、什麼交給 Graphics

分工可以完全用 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:99sceneManager.goTo('boot'):143 啟動 GameLoop,:146await assetManager.loadBundle('boot')。開機畫面要在素材載完之前就掛在畫面上,所以它一個 Texture 都不能碰。整個 BootScene.js 67 行,只有一個 Graphics 跟一串 rectcircleroundRect

「這個畫面在什麼時候要出現」這個約束,直接決定了它只能用哪一種畫法。 這比任何「Texture 比較快、Graphics 比較靈活」的通論都具體。


規格給了骨架,實作補了三件規格沒說的事

PRD.md:744-1012 有一份 267 行的「AssetManager 完整骨架」,方法名一路寫到 delay。我把它跟實作逐個方法比對過:11 個方法名完全相同,一個不多一個不少。但差異在細節,而且方向不一致:

# PRD 骨架 實作 方向
1 constructor(manifest),內部直接呼叫模組層級的 AssetsTexture constructor({ manifest, assetsApi = Assets, textureClass = Texture, retryDelayMs = 300 }) 實作加強:三個相依都可注入
2 init()if (initialized) return + await Assets.init(...) 多存一個 this.initPromise,把並行呼叫收斂到同一個 promise(:46-56 實作加強:規格版本兩個並行呼叫會各自 init 一次
3 releaseBundlePromise.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:140runtime.destroy 掛在 pagehide 上。離開頁面時可能丟出一個沒人接的 rejection,這是我讀規格才發現的。

這張表就是本節結論:規格能交出去的是形狀,交不出去的是「這東西要怎麼被測」。


寫好、但沒有接線的四個東西

四個郵筒我一個一個鎖上牆,信全部從下面那條被走亮的路過去了

這一節最不好看,也最有價值。以下四件都存在於程式碼裡,而且都沒有在執行時發揮作用

一、軟失敗的參數。 getTexture 第二個參數有 requiredfallbackAssetManager.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 內部的兩行。也就是說,這條軟失敗路徑一次都沒有被走過。

二、載入進度。 loadBundleWithRetryonProgress 帶了 bundleNameprogresspercentattemptcached 五個欄位(:104-110),寫得很完整。而唯一的呼叫點 main.js:146await 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.errorwindow.__SAVE_THE_DOG_DEBUG__.assetError,畫面停在 BootScene,而 BootScene 全檔 67 行只有一個 Graphics沒有任何 Text、沒有按鈕、updateFrame() 是空函式。玩家看到的是一張不會動的背景圖。

這四件有同一個形狀:機制寫了,接線沒接。 而且都不會被任何檢查抓到——lint 不會說「你這個參數沒人傳」,測試也不會,因為測試測的是機制本身,機制是好的。這種債只有在讀自己的程式碼時才會浮出來。


交給 AI

這個檔案有多少是我寫的、多少是 AI 寫的,我沒有留下紀錄,所以不把它寫成故事(這個系列的規矩:沒有紀錄的協作過程一律不補寫)。

但有一件不需要紀錄也能查的事:PRD 那份 267 行骨架,跟最後交出來的 210 行實作,11 個方法名完全一致。這種程度的規格已經不是「需求」,是「介面定義」——交給誰做都不會做出結構不同的東西。

分界線在上一節那張表:規格能鎖住方法有哪些、參數叫什麼、失敗丟什麼錯;鎖不住「相依要不要可注入」「並行呼叫會不會重複 init」。那兩件只有在寫測試時才會被逼出來,而規格不會替你寫測試。 反過來,第 3 項那個從 allSettled 退回 Promise.all 的變更也說明了另一面:沒有人在實作時回頭讀規格,包括我自己。


帶走什麼

一句話:

「向量圖可以無限縮放」在瀏覽器裡成立,在 WebGL 裡不成立。用哪一種方式呈現,取決於這個東西會不會變。

三件今天就能做的事:

  1. 打開 node_modules 確認你的載入器到底做了什麼。 「SVG 載進來還是向量」這個直覺,只要讀一次 drawImage 那三行就會被修正,成本是五分鐘。
  2. 把「寫了沒接線」的東西列一次清單。 找法很簡單:對每個帶預設值的參數 grep 一次呼叫點,看有沒有人傳過非預設值。沒有人傳過的,要嘛接線,要嘛刪掉,不要讓它假裝存在。
  3. 檢查你的抽象層現在長度是多少。 Bundle 陣列長度 1、策略模式只有一個策略、工廠只生產一種東西——留著可以,但要知道它現在不提供任何保護。

明天 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

參考資料

如果你卡在語法

深入原理


上一篇
Day 23|場景切換:沒清的 listener 就是記憶體洩漏
下一篇
Day 25|版本號我第一天就加了,它只買到偵測,沒買到遷移
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言