模組一|立案與選型(Day 1–4)
這個系列在做一款瀏覽器小遊戲:玩家畫一條線,擋住從蜂巢飛出來的蜜蜂,讓狗狗撐過十秒。 打開網頁就能玩,一局十秒可以玩完(https://save-the-dog-web.vercel.app/)。
一款這麼小的遊戲,畫面用什麼畫?我選了 PixiJS 8。這篇要處理的,是選完之後才會發現的那件事:有一整批你以為「這種函式庫應該會附」的東西,它一件都不做——而它不做的每一件,最後都會變成你專案裡的一個檔案。 如果你正打算用某個函式庫開始寫遊戲,這篇可以讓你在動手前先知道自己要多寫多少東西。
(一句前情提要:開工之前我先寫了一份 2,419 行的規格 PRD.md,技術選型在裡面只佔一行。這篇講的就是那一行的後續。)
先攤開事實,看那一行換來多少工作量。這個專案 package.json 的執行期依賴只有三個:pixi.js 8.19.0、matter-js 0.20.0、howler 2.2.4。而 src/ 底下是 46 個檔案、6,081 行。其中 core/ 11 檔 1,219 行、systems/ 8 檔 1,466 行——這兩個目錄合計 2,685 行,裡面沒有任何一個檔案是 PixiJS 提供的。
結論先講:PixiJS 把「畫到螢幕上」這件事做完,其他事情做零。 你直覺以為屬於遊戲引擎的東西——物理、碰撞、遊戲狀態機、關卡、存檔——一項都不在盒子裡。搞錯這件事的代價不是學不會,是你會去找一份不存在的文件。
我原本打算把這篇寫成「連 PixiJS 官方都說自己不是遊戲引擎」。去查了,不是這樣。
PixiJS 8 的架構總覽頁開場就邀請你看看引擎蓋底下有什麼——它用的字是 engine。所以「PixiJS 不是遊戲引擎」不是官方立場,是我從盒子裡有什麼推出來的判斷。那就別吵名詞,看清單。
同一頁有一張 Major Components 表,八項。表裡有兩個詞先解釋一下:場景圖(scene graph)就是「畫面上所有要顯示的東西排成的一棵樹」,移動樹上的一個節點,掛在它底下的東西會跟著一起動;Ticker 是「每要畫一格畫面,就回頭叫你一次」的計時器,遊戲裡每一幀要做的事都掛在這種東西上。
| 元件 | 官方寫的職責 |
|---|---|
| Renderer | 把場景圖畫到螢幕;自動決定要給你 WebGPU 還是 WebGL |
| Container | 建立場景圖,也就是「要被顯示的可繪製物件所構成的樹」 |
| Assets | 非同步載入圖片、音檔之類的資源 |
| Ticker | 依時鐘週期性回呼 |
| Application | 把 Loader、Ticker、Renderer 包成一個物件的便利類別 |
| Events | 指標互動:讓物件可點擊、觸發 hover |
| Accessibility | 鍵盤與螢幕閱讀器支援 |
| Filters | 濾鏡效果,含自訂 shader |
八項全部是「畫面」與「畫面上的互動」。表裡沒有物理、沒有碰撞、沒有遊戲狀態,而且官方也沒有把它們寫成「刻意不做」——就只是沒有出現。
這個「沒有出現」才是坑。如果文件明說「本函式庫不處理碰撞」,你看一眼就走了;它什麼都沒說,你就會一直翻,翻到懷疑是自己關鍵字下得不好。

把「一款 2D 遊戲需要什麼」逐項對到這個 repo,長這樣。第一列出現的剛體(rigid body)是物理引擎裡的用語,指「一塊不會變形的硬東西」——狗、蜜蜂、玩家畫的那條線在物理世界裡都是剛體,它們會互撞、會被重力拉下去,但不會被壓扁。
| 需要的能力 | PixiJS 有沒有 | 這個專案由誰負責(以及那個檔案在做什麼) |
|---|---|---|
| 剛體、重力、碰撞求解 | 沒有 | matter-js 負責算,src/core/PhysicsManager.js 是包住它的那一層:建立物理世界、每次更新推它一步 |
| 物理與畫面的座標對齊 | 沒有 | src/systems/RenderSyncSystem.js:物理算完之後,把剛體的新位置抄到畫面物件上的那一層 |
| 誰在什麼時候被 update | 只給 Ticker | src/core/GameLoop.js:接上 Pixi 的 Ticker,決定每一幀要依序叫哪些東西更新 |
| 場景切換與銷毀 | 沒有 | src/core/SceneManager.js:拿著畫面的根節點,負責換場景、以及把舊場景拆乾淨 |
| 勝負判定 | 沒有 | src/core/GameStateMachine.js:記住現在是進行中、贏了還是輸了,以及什麼時候換 |
| 關卡資料與驗證 | 沒有 | src/levels/(10 檔):每一關的資料放這裡,順便檢查資料本身有沒有寫錯 |
| 進度存檔 | 沒有 | src/core/ProgressStore.js:記住玩家目前的進度 |
| 畫線輸入 | 有 Events,但不夠 | src/systems/DrawingSystem.js:從手指按下到放開,把玩家畫的那條線收出來 |
Matter.js 的自我介紹只有一句話,寫在它的 package.json 裡:a 2D rigid body physics engine for the web。兩個函式庫加起來剛好把「畫」跟「算」都補齊了,中間那條縫則兩邊都不管。 那條縫等一下講。
最後一列是這張表裡唯一「有給但不夠」的,值得單獨拆開。
Pixi 的 Events 是真的能用。這個專案的 UI 就用它:src/ui/Button.js:64 與 src/ui/ResultOverlay.js:22 都寫了 this.eventMode = 'static',然後按鈕就會收到指標事件,不必自己算點擊落在哪個矩形裡。
但畫線那條路完全沒走這裡。src/systems/DrawingSystem.js:96-100 是直接掛在 canvas 元素上的五個原生監聽器——pointerdown、pointermove、pointerup、pointercancel、lostpointercapture——並且在 :153 呼叫 canvas.setPointerCapture()。
為什麼要繞過 Pixi 那層,跟 pointercancel 有關,Day 11 完整講。今天只要記住這個形狀:「有這個功能」跟「這個功能夠你用」是兩件事,而分界線通常剛好落在你最在意的那個互動上。
「PixiJS 做 2D 很快」這句話流傳得很廣,但很少人講後半段。快的來源是 batching。
先解釋兩個詞。draw call,是程式對顯示卡下的一次「把這些東西畫出來」的命令;命令下得越多次,畫面就越慢。batching,就是把原本要分好幾次下的命令合併成一次下完。下面表格裡還會出現「材質」,指的是貼在圖形上的那張圖片。
官方的效能建議頁把條件寫得很清楚:
| 官方說法 | 意思 |
|---|---|
| Sprite 可以跟最多 16 種不同材質一起被 batch(實際數量看硬體) | 不是「同材質才會合併」,而是同一批能帶的材質種類有上限,所以要用 spritesheet 壓低總材質數 |
| Graphics 在 100 個點以下也會被 batch | 小的向量圖形跟 Sprite 一樣快 |
| 不同的 blend mode 會打斷 batch | 官方舉的例子:screen 與 normal 交錯排列會變成 4 次 draw call,分組排列則是 2 次 |
| 混著擺 Sprite 與 Graphics 比分開擺慢 | 繪製順序本身就是效能參數 |
所以「Pixi 很快」正確的說法是:它在你配合它的前提下很快。 而「配合它」的內容——材質怎麼打包、blend mode 怎麼分組、繪製順序怎麼排——沒有一項是它會自動幫你做的。
上面這張表全部是官方文件的說法,不是我在這個專案量到的。這個專案到現在一次實機效能量測都沒做(那是 Day 27 的題目,資料還沒補齊)。沒有裝置型號、瀏覽器版本、蜜蜂數與節點數的數字,我不會寫進來。
這是整個 repo 裡唯一直接對 PixiJS 做設定的檔案,全文二十五行(src/core/createApplication.js):
import { Application } from 'pixi.js'
import {
COLORS,
MAX_RENDERER_RESOLUTION,
WORLD_HEIGHT,
WORLD_WIDTH
} from '../config/game.js'
export async function createApplication({
devicePixelRatio = window.devicePixelRatio || 1
} = {}) {
const app = new Application()
await app.init({
width: WORLD_WIDTH,
height: WORLD_HEIGHT,
resolution: Math.min(devicePixelRatio, MAX_RENDERER_RESOLUTION),
autoDensity: true,
antialias: true,
backgroundColor: COLORS.SKY,
powerPreference: 'high-performance'
})
return app
}
跑完之後你手上有四樣東西:app.stage(一個 Container,也就是場景圖那棵樹的根)、app.ticker、app.canvas、app.renderer。就這四樣。
resolution 為什麼夾在 2、而不是直接吃 devicePixelRatio,是 Day 24 的題目。WORLD_WIDTH 與 WORLD_HEIGHT 為什麼要在寫任何一行程式之前就決定,是 Day 5。
專案啟動的前九行是這樣(src/main.js:32-40):
const app = await createApplication()
const assetManager = new AssetManager({
manifest: ASSET_MANIFEST
})
const sceneManager = new SceneManager({ stage: app.stage })
const gameLoop = new GameLoop({
ticker: app.ticker,
sceneManager
})
AssetManager、SceneManager、GameLoop 三個名字,沒有一個來自 pixi.js,全部是 src/core/ 底下的本地檔案。PixiJS 在這九行裡的貢獻,是被當成參數傳進去的 app.stage 與 app.ticker 兩個值。
連 AssetManager 也是。Pixi 明明有 Assets、官方元件表也列了,它仍然被包了一層——因為載入還牽涉到 Manifest 分組與素材路徑要在兩個部署目標上都指對,那是這個專案的問題,不是渲染器的問題。

Matter 的 body(物理世界裡那個剛體)有 position 與 angle,Pixi 的 display object(場景圖上那個看得見的圖)有 position 與 rotation。它們是兩份數字,不是同一份數字的兩個名字。 物理算完之後,得有人把左邊抄到右邊。
這件事在 AGENTS.md 的 Engineering Rules 裡是一條明文(AGENTS.md:55):
PixiJS handles rendering and Matter.js handles physics; keep synchronization one-way from Matter bodies to Pixi display objects.
執行這條規則的是 src/systems/RenderSyncSystem.js,全檔 51 行。它的檔頭 JSDoc 把「為什麼必須單向」寫了出來:這裡任何東西都不准回頭去讀物理世界,否則模擬就會依賴幀率。
這裡有一個我覺得比程式碼本身更值得講的細節:這層對齊是一張要自己手動維護的名單。 RenderSyncSystem 內部就是一個 entries 陣列,每個要同步的東西都得被明確註冊一次:
| 註冊什麼 | 在哪裡 | 註冊時的參數 |
|---|---|---|
| 狗 | src/scenes/GameScene.js:103 |
offsetY 反向補一次(剛體圓心比圖低),syncAngle: false |
| 每一隻蜜蜂 | :145 |
syncAngle: false |
| 玩家畫的那條線 | :163 |
預設全開,鎖定的當下才註冊 |
Pixi 沒有「實體」這個概念,Matter 也沒有。所以「這個剛體對應到哪個圖」不會有人替你記,你得自己開一份名單。 名單漏了一項不會有錯誤訊息,只會有一個看起來卡住的東西——這個坑真的踩過,Day 10 講。
順帶一提,「單向同步」寫在合約裡,但沒有任何靜態檢查在執行它。哪些合約條文有檢查、哪些沒有,是 Day 8 與 Day 29 的主題。
先講一件不好看的事。我在 PRD.md(2,419 行)、AGENTS.md、docs/、src/、openspec/ 搜過 phaser、unity、godot、cocos,零命中。這個專案從頭到尾沒留下任何選型比較的紀錄。
所以下面這張表不是當初的評估,是我現在回頭補的理由。差別很重要:當初的紀錄可以被檢驗,你能拿它問我「那你怎麼沒考慮 X」;事後的說明只能被相信,而且會不自覺地把結果寫成當初就想清楚了。
| 方案 | 我現在給的理由(事後補的) |
|---|---|
| Phaser | 自帶物理、場景管理、輸入抽象。這個專案自己寫的那 2,685 行裡,有一部分它是現成的 |
| 純 Canvas 2D | 場景圖與 batching 都得自己來,等於重寫半個 Pixi,而那半個跟我想弄懂的東西無關 |
| Unity WebGL | 包體與首次載入時間,對一款十秒可以玩完的小遊戲不成比例 |
這篇最誠實的一句:如果目標是最快做出一款能玩的遊戲,Phaser 大概是更好的選擇。選 PixiJS 是學習取向的決定,不是工程取向的決定。
我沒做過 Phaser 版本,所以也不能說「用 Phaser 會少花幾天」。沒有對照組,那個數字我不編。
PRD.md 的最後一節叫「優先參考來源」,是一張二十列的表,每列有優先度、官方來源、應查閱的內容、以及在本專案的用途。標「最高」的那七列全部是 PixiJS 與 Matter.js 的官方頁面,而且指定到章節:
| 優先度 | 來源 | 應查閱內容 | 本專案用途 |
|---|---|---|---|
| 最高 | PixiJS Assets | Promise 載入、快取、Alias、Unload | AssetManager 基礎 |
| 最高 | Matter.js Engine | Engine 建立與世界更新 | PhysicsManager |
| 最高 | Matter.js Events | Collision Event 註冊與解除 | CollisionSystem |
寫這張表的時候我沒有意識到它在做什麼。現在回頭看:它把「選了 PixiJS」這個決定,翻譯成「AI 動手寫某個模組之前該對照哪一頁」。 選型不是選完就結束,下一步是一份閱讀清單;這份清單不寫下來,就只存在於我腦子裡,AI 拿不到。
誠實邊界:我沒有留下「AI 每次真的去讀了」的紀錄。 這張表能證明的只有它存在、而且被寫成了可以指派的形式,不能證明它被遵守了幾次。
一句話:
選函式庫之前先問「它刻意不做什麼」。它不做的每一件事,都會變成你 repo 裡的一個檔案。
三件今天就能做的事:
明天 Day 4,講範圍。MVP 是五個關卡、每關只能畫一條線、蜜蜂同時最多 16 隻——三個數字全部寫在資料裡,不是寫在共識裡。更有意思的是:這五關在動工當天下午被整組重做過一次,改的不是難度參數,是地形。程式碼註解留下了改動的理由,而那個理由推翻了我原本以為的「難度是調出來的」。順便處理一個不好看的數字:PRD 估的工時,跟實際 commit 的跨度差了一個數量級——這次不談規格,談範圍怎麼估、以及估錯在哪一格。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理