模組五|關卡、UI 與資料(Day 21–25)
《Save the Dog》是一款畫線解謎小遊戲:蜜蜂會從洞穴的開口衝進來,玩家只能畫一筆線把牠們擋在外面,撐過十秒倒數就算保住底下那隻狗。全遊戲五關,畫面由 PixiJS(網頁 2D 繪圖引擎)負責,碰撞由 Matter.js(2D 物理引擎)負責。
今天講一個完全不同的面向:畫面之間切來切去的時候,上一個畫面到底有沒有真的消失。
先講三個詞,整篇都會用到。場景(scene)就是一個獨立的畫面,選關頁是一個、遊戲本身是一個。監聽器(listener,全稱事件監聽器 event listener)是一段登記:「以後每次發生某某事,就回頭呼叫我這個函式」——手指按在畫布上、視窗被拉大、兩個物體撞在一起,都是這樣接起來的。記憶體洩漏(memory leak)是指該被回收的東西,因為還有人指著它而回收不掉;一個沒被解除註冊的監聽器,就會這樣一路指著整個舊場景,切走十次就留下十份。
先攤開事實。src/scenes/ 只有 4 個檔、941 行:BootScene.js(67,開機時只用色塊畫出天空與地面的過場畫面)、LevelSelectScene.js(190,選關列表)、AssetGalleryScene.js(202,把素材排成卡片格線的檢視頁,靠網址參數叫出來)、GameScene.js(482,真正在玩的那一個畫面)。負責調度它們——決定何時切到哪一個、順便把舊的銷毀掉——的 SceneManager.js 全檔 79 行,零 import(不引用任何其他模組)【實測:grep -c '^import' src/core/SceneManager.js 回傳 0】。
結論先講:這個專案的 listener 清理做對了,但「做對了」跟「被驗證過」是兩件事,而後者昨天之前並不存在。 AGENTS.md(放在專案根目錄、給人與 AI 共用的規約檔)有一條規則叫「每個 listener 都要有清理」,它沒有任何靜態檢查在執行——而同一份合約裡「SVG 不准出現 emoji」那條有。不是因為前者比較不重要,是因為前者沒有一個可以 parse 的目標。
我原本的規劃裡有六個場景:Boot、Menu、LevelSelect、Game、Result、AssetGallery。實際只有四個,而少掉的兩個各有各的原因:
| 規劃的場景 | 實際 |
|---|---|
| Menu | 沒做。首頁的角色由 LevelSelectScene 兼任(main.js:199 的預設路由就是 level-select) |
| Result | 不是場景,是覆蓋層。src/ui/ResultOverlay.js(151 行),在 GameScene.js:77-84 被 addChild 掛進同一個容器 |
結算畫面要不要當成一個場景,是一個真實的取捨。當成場景的好處是生命週期一致、責任乾淨;代價是結算的當下要把遊戲畫面整個銷毀,玩家就看不到自己那條線最後停在哪裡了。這個專案選了覆蓋層:結算面板(勝負字樣、星數、重試與下一關按鈕)直接蓋在還活著的遊戲畫面上。代價是 GameScene 變成全專案最大的檔(482 行)。沒有免費的那一邊。
SceneManager 只做四件事場景怎麼換掉、舊的什麼時候死,全部寫在一個方法裡,所以這段值得逐行看。goTo(name) 的工作是「把畫面換成叫做 name 的那個場景」:79 行、零 import 的檔案裡,這一段就把切換的全部規則講完了(src/core/SceneManager.js:28-46):
this.destroyCurrent()
const scene = factory(params)
if (!scene?.container) {
throw new Error(`Scene "${name}" must expose a container.`)
}
this.stage.addChild(scene.container)
this.current = { name, scene }
scene.enter?.(params)
if (this.viewport) {
scene.resize?.(this.viewport)
}
return scene
}
三件事:先銷毀舊的、再建新的(順序不可換,否則同一時間有兩個場景掛在 stage 上);場景必須交出一個 container,否則直接拋錯;每一個生命週期方法都是可選的——全部用 ?. 呼叫。
destroyCurrent()(:68-78)也只有三行實質內容:從 stage 移除容器、呼叫 scene.destroy?.()、把 this.current 設成 null。
零 import 的意思是它不碰物理、不碰素材路徑、不碰 PixiJS——全檔唯一出現 "PixiJS" 的地方是第 4 行那句錯誤訊息的字串。責任邊界窄到這種程度是有代價的:它完全不知道一個場景該清掉什麼,它只知道要問一聲。清理的正確性 100% 落在場景自己身上。
所謂「生命週期方法」,指的是一個場景從被建立、每一幀被更新、到最後被銷毀的這一路上,SceneManager 會在固定時機回頭呼叫的那幾個方法——場景只要把它們寫出來,就會在對的時間被叫到。SceneManager 實際會呼叫五個方法,不是四個:enter(:39)、resize(:42、:58)、updateFixed(:49)、updateFrame(:53)、destroy(:76)。
其中 enter 四個場景沒有一個實作【實測:grep -n " enter(" src/scenes/*.js 零命中】。它們全部在建構子裡做完了初始化。
這是一個留了但沒用到的抽象層。我不打算把它包裝成「為未來預留」——比較誠實的說法是:我照著「場景應該有進場鉤子」這個習慣留了介面,然後每一次實作都發現建構子已經夠了。 它現在的成本是一行 scene.enter?.(params),收益是零。跟 Day 21 講關卡資料時提到的 schemaVersion 是同一類東西——五個關卡檔都宣告了這個資料格式版本號,但全專案沒有任何一行程式碼去讀它。
updateFixed 與 updateFrame 分開就完全不是這樣了,那條分界有實質作用:前者跟物理同節奏(一幀可能跑多次或零次),後者每幀一次。遊戲邏輯放前者,動畫與 UI 刷新放後者。BootScene 與 AssetGalleryScene 兩個靜態畫面根本沒有 updateFixed,靠的就是「每個方法都是可選的」。

大綱裡我原本寫的紀律是:任何 addEventListener 的下一行,就在 destroy 裡寫對應的 removeEventListener。 這條紀律在 DOM(Document Object Model,瀏覽器裡那棵網頁元素樹,畫布本身也是其中一個元素)那一側確實被貫徹了,可以自己查【實測:grep -rn "addEventListener\|removeEventListener" src/】:
| 位置 | 註冊 | 移除 |
|---|---|---|
DrawingSystem.js |
:96-100(5 個 pointer 事件) |
:126-130 |
AssetGalleryScene.js |
:150-154 |
:158-162 |
main.js |
:139-140(resize、pagehide) |
:127-128 |
AudioManager.js |
:96(一次性解鎖) |
:106 |
13 個註冊、13 個移除,全部配對。 能配對的關鍵細節是:所有 handler(處理函式,事件發生時被呼叫的那一段)都事先 bind 好存在 this.boundXxx 上(例如 AssetGalleryScene.js:26-29)——bind 會產生一個綁好 this 的新函式,這裡只做一次、存起來,註冊與移除都用同一個。removeEventListener 比對的是函式參考,寫 this.handleX.bind(this) 兩次會產生兩個不同的函式,移除會靜默失敗——這是這類 bug 最常見的長相。
但 Matter 那一側用的是更好的寫法。下面這段值得看,是因為它把「怎麼清」這件事從呼叫端手上收了回來——PhysicsManager 是專案裡包住物理引擎的那一層,onCollision 則是全專案唯一登記碰撞事件的入口(src/core/PhysicsManager.js:44-52):
onCollision(eventName, callback) {
Events.on(this.engine, eventName, callback)
this.listeners.push({ eventName, callback })
return () => {
Events.off(this.engine, eventName, callback)
this.listeners = this.listeners.filter((listener) => listener.callback !== callback)
}
}
註冊的動作回傳一個解除函式。 呼叫端不需要記得事件名稱、不需要保留 handler 參考、不需要知道 Events.off 長什麼樣——它只要把回傳值存起來,在 destroy 裡叫一次。
而且這裡是雙保險:PhysicsManager.destroy()(:71-78)會再掃一次自己的 listeners 清單,把每一個都 Events.off 一輪。全 repo 的 Events.on 只有這一個入口,所以這道保險蓋得住全部。
「回傳解除函式」比「記得寫對應的移除」可靠一個等級,理由很簡單:前者把清理需要的所有資訊封裝在閉包裡,後者要求呼叫端在另一個地方重建那些資訊。 同樣的模式在 EventBus.on(src/core/EventBus.js:17)也用了——EventBus 是專案自己寫的事件中心,讓場景裡的各個系統互相通知而不用彼此持有對方。
以上都是「我覺得我寫對了」。問題是:我怎麼知道自己沒漏?
昨天(2026-08-07)進 repo 的 tests/unit/leakCycles.test.js(216 行,commit 0c43a96)就是在回答這件事——這是一支自動化測試檔,反覆建立又銷毀同一批東西,然後檢查有沒有殘留。它跑三組 50 次循環:
| 測試 | 斷言 |
|---|---|
| 五十次重試後物理世界歸零 | 每次 destroy 後 world.bodies.length === 0;而且 50 次的峰值 body 數完全相同(沒有前一次殘留墊高後一次) |
| 五十次重試後 Matter listener 歸零 | 把 engine.events 底下所有 callback 陣列加總,50 次全部是 0 |
| 五十次場景切換 | 每次切換後 stage.children 恆為 1;結束時建立數 = 銷毀數 = 50,且每個場景的 destroy 恰好被呼叫一次 |
第二條特別值得看,因為它把「listener 沒清」這件事翻譯成了一個可以斷言的數字:Events.off 會把 callback 陣列清空,所以任何大於零的殘留,就是 AGENTS.md 那條規則被違反的當場證據。一條寫在文件裡沒人執行的規則,在這裡第一次有了執行者。
現在是這篇最重要的一段:這組數字證明不了什麼。
測試檔自己的註解(:22-31)把界線寫在裡面:
These run headless, so they prove the physics/system layer releases what it allocates. They do not prove the browser reclaims Pixi textures — that still needs a real device.
拆開講(headless 指的是沒有瀏覽器視窗、直接在 Node 裡跑的測試環境,所以畫布與材質那一半根本沒被建立起來):
| 已驗證 | 未驗證 |
|---|---|
| 系統層放得掉自己配置的 Matter body 與 listener | 瀏覽器回收不回收得掉 Pixi 材質 |
SceneManager 每次切換恰好銷毀一個場景 |
真實的 Scene 有沒有被建構過——全 repo 零命中【實測:grep -rn "new GameScene|new LevelSelectScene|new BootScene|new AssetGalleryScene" tests/】 |
| Matter listener 殘留為 0 | DOM listener 殘留數(headless 沒有 canvas) |
第二列的第二格是最尷尬的一格:leakCycles.test.js 用的是 vi.fn()(Vitest 的假函式,用來冒充真元件)拼出來的假場景,SceneManager.test.js 也是。測到的是 SceneManager 的契約,不是四個場景的清理。 而 listener 面最大的那一個——DrawingSystem(接手玩家的畫線輸入、把那一筆線變成物理剛體的系統)每次重試重新註冊 5 個 pointer listener——它的 destroy() 一條測試都沒有:tests/unit/DrawingSystem.test.js 驗了 attach() 註冊五個,全檔從未呼叫過 system.destroy()【實測:grep -c "\.destroy()" tests/unit/DrawingSystem.test.js 回傳 0】。
所以我不會寫「我驗過,沒有洩漏」。我能寫的是:頭一半有自動化,另一半還躺在待補清單上。

為了寫這篇,我把 destroy 路徑逐條走了一次,找到四個真的沒清乾淨的東西。它們都不是「材質不釋放」那個等級,我不誇大。表格裡有幾個名詞先說清楚:window.__SAVE_THE_DOG_DEBUG__ 是掛在全域 window 上的除錯快照,方便開發時在瀏覽器主控台直接看目前場景的狀態;pagehide 是使用者關掉或離開分頁時瀏覽器發出的事件;HMR 是 Hot Module Replacement(熱模組替換,開發時改了程式不整頁重載、只抽換那一個模組),dispose 就是它抽換前給你收拾的機會。
| # | 漏什麼 | 位置 |
|---|---|---|
| 1 | window.__SAVE_THE_DOG_DEBUG__ 在 destroy 時沒清,全域變數一直指著最後一次的完整快照(含每個 Matter body 的 label 陣列) |
寫入 GameScene.js:480、LevelSelectScene.js:184;GameScene.js:260-271 與 LevelSelectScene.js:187-189 都沒清 |
| 2 | GameScene.js:148 那個 eventBus.on(BEE_SPAWNED, ...) 丟棄了回傳的解除函式,是全場景唯一沒存 handle 的訂閱 |
靠 :268 的 eventBus.destroy() → EventBus.js:50-52 的 handlers.clear() 兜底 |
| 3 | main.js:54 的 requestAnimationFrame handle 沒存,runtime.destroy()(:126-136)沒有對應的 cancelAnimationFrame |
pagehide 或 HMR dispose 時有一個窄窗會踩到已經被設成 null 的 stage |
| 4 | AssetGalleryScene.js:176 的 setPointerCapture 只在 handlePointerUp 釋放,destroy 時若正在拖曳就不釋放 |
:187-192 |
這四個的共同點很說明問題:它們沒有一個長得像 listener。 一個全域變數賦值、一個被丟棄的回傳值、一個 rAF(requestAnimationFrame,跟瀏覽器約好「下一次重繪時呼叫我」)、一個 pointer capture(setPointerCapture,把後續的指標事件鎖定在同一個元素上,手指滑出範圍也照樣收得到)——紀律做到了 95%,剩下的 5% 全部是「這個東西不長得像 listener,所以沒被當成 listener 對待」。
第 2 條還有一個額外的教訓:它今天沒事,是因為 eventBus.destroy() 排在 :268,會把整張 handler 表清掉。只要有人動了那十行的順序,它就會漏。 靠兜底活著的東西,脆弱程度取決於兜底的位置。
最後一件要標明是推論的事:截至 2026-08-07,34 個 commit 裡 fix 類型只有 3 個,而且這個專案沒經過長時間的實機遊玩。「沒發現洩漏」的更可能解釋是「還沒有人玩夠久」,不是「寫得特別乾淨」。我沒有辦法區分這兩者。
這一段有真材料,而且它的形狀跟我預期的不一樣。
原本的計畫是自己開瀏覽器做一次手動量測:切場景 50 次、重試 50 次、每次先手動 GC 再拍記憶體快照。這條任務在文章 repo 的待辦清單上掛了兩天沒動——因為它要人、要時間、要記裝置型號跟瀏覽器版本,而且做完只會得到三組沒有人會再跑第二次的數字。
我交出去的任務是明確的:把那條手動量測改寫成一個 headless 測試,並且不准宣稱它證明了材質回收。 第二句話是閘門,不是客套。
結果就是 leakCycles.test.js。它做到了第一句,也做到了第二句——那段界線註解(:22-31)現在寫在測試檔自己身上,任何人打開這個檔都會先讀到「這個測試不能證明什麼」。能寫進版控的誠實,比寫在文章裡的誠實耐久。
要標清楚的是:這件事沒有「AI 幫我抓出了 listener 洩漏」。它一個洩漏都沒抓到(第一次跑就全綠),上面那四個漏清是我自己逐條走 destroy 路徑找到的。它做的是把一個一次性的手動量測,變成一個每次 CI(持續整合,程式碼一推上去就自動跑一輪測試)都會跑的斷言。 這兩件事的價值完全不同,不要混為一談。
一句話:
清理程式碼沒有人會幫你檢查——編譯器不會,測試通常也不會。但「通常不會」不等於「不能」:只要你願意把清理的結果翻譯成一個數字,它就變得可以斷言。
三件今天就能做的事:
const off = bus.on(...) 然後 destroy() { off() },比對照著寫 removeEventListener 可靠一個等級。requestAnimationFrame、setInterval、setPointerCapture、被丟棄的 unsubscribe 回傳值。這些是紀律的盲區,因為紀律是靠「看到 addEventListener 就警覺」運作的。明天 Day 24 講素材:19 個 SVG 怎麼載、什麼時候載、載成什麼。最反直覺的一點是——SVG 進了 PixiJS 的 Assets 之後就不是向量圖了,它在載入時被光柵化成一張點陣圖,之後就當一般材質用。這件事會直接決定 Texture 跟 Graphics 各自該畫什麼,也解釋了為什麼開機畫面那個 67 行的 BootScene 一個素材都不能用。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
EventTarget.removeEventListener()
AbortController——用一個 signal 一次移除多個 listener
Events 文件
深入原理