模組二|工程底座與可驗收的 AI(Day 5–9)
昨天把範圍畫完了。今天進模組二,講開工那一刻第一個真正無法反悔的決定。
不是選 PixiJS,不是選 Vite。是這個遊戲的世界有多大。
結論先講:座標系是所有技術決定裡最早、也最難撤回的一個。訂晚了不叫「之後再補」,叫之後每一個座標都要重算一次。 這個專案把它壓在 src/config/game.js 的第一、二行,換到的結果是:整個 src/(46 個檔、6,081 行)裡,只有 2 個檔案、4 行程式碼知道螢幕實際有多大。

src/config/game.js 的第 1、2 行是 export const WORLD_WIDTH = 750 與 export const WORLD_HEIGHT = 1334。整份檔案 82 行、13 個具名匯出,這兩個排在最前面。
750 × 1334,直式,約 9:16。這組數字在任何一行遊戲程式碼之前就定了:專案的第一個 commit(b4c2c62,08-06 03:07)裡有兩份文件,一份是短版的需求說明,一份是研究報告——兩份都寫了 750 × 1334,後者在 03:12 更名成現在這份 2,419 行的 PRD.md,它的〈基本假設〉表有一列「內部邏輯尺寸:750 × 1334,直式約 9:16」。至於 src/config/game.js 這個檔案本身,要到 04:08 的 314a7b4 才出現。
「邏輯尺寸」聽起來像術語,它要求的事只有一件:程式裡出現的每一個座標都用這套數字,跟裝置解析度完全脫鉤。
關卡資料因此可以這樣寫:第一關的洞口中心 x: 375(src/levels/level01.js:12)、狗狗 x: 375(:13)、蜂巢 { x: 375, y: 300 }(:44)。375 就是 750 的正中間,在 iPhone 上是正中間,在 27 吋螢幕上也是正中間。關卡檔不需要知道自己會被顯示成多大。
這條規則有沒有守住,一行指令就能驗:grep -rn 'innerWidth\|innerHeight\|devicePixelRatio' src/。
【實測】整個 src/ 只有 4 行命中,分佈在 2 個檔案:main.js:107-108 讀 window.innerWidth/innerHeight 算縮放,createApplication.js:10,17 讀 devicePixelRatio 設渲染解析度。其餘 44 個檔案完全不知道瀏覽器視窗的存在。
這不是自律的成果,是結構的成果:因為縮放只發生在最外層,內層根本拿不到那些數字,也就不會想用。
Math.min 決定的事「保持比例縮放」講白了就是取兩個方向縮放比的較小值,剩下的空間留黑邊。src/core/viewport.js 全檔 49 行,核心是這幾行:
const scale = Math.min(viewportWidth / worldWidth, viewportHeight / worldHeight)
const displayWidth = worldWidth * scale
const displayHeight = worldHeight * scale
return {
worldWidth,
worldHeight,
scale,
displayWidth,
displayHeight,
offsetX: (viewportWidth - displayWidth) / 2,
offsetY: (viewportHeight - displayHeight) / 2
}
算完之後只做一件事:把結果寫進 canvas 的 CSS(applyCanvasViewport,:41-45),用 style.width / style.height 加一個 transform: translate(...)。渲染器那邊的內部尺寸從頭到尾都是 750 × 1334,沒有動過。
這裡要誠實標一件事。PRD 的〈基本假設〉寫的顯示策略是「保持比例縮放,桌機置中,手機盡量填滿可視區域」——兩種行為,但實作只有一種。computeContainViewport 對桌機和手機用的是同一個 Math.min,兩軸都置中。「手機盡量填滿」這句話沒有對應的程式碼。不是被推翻,是從來沒有被實作,而規格文件也沒有被改回來。
而 letterbox 會留下一個伏筆:canvas 的 CSS 尺寸不等於邏輯尺寸,畫面左上角也不在視窗左上角。 玩家手指按下去的 clientX / clientY 是視窗座標,要變成遊戲座標得穿過這層縮放與位移。這件事 Day 12 專篇處理,今天先說結論:這段轉換最後是 src/utils/geometry.js 裡的 15 行純函式,並且有自己的單元測試。
resolution 不是越高越好src/core/createApplication.js 全檔 25 行,是這個專案唯一碰到 devicePixelRatio 的地方:
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
}
三件事值得指出來。
第一,width / height 餵的是常數,不是視窗尺寸。渲染器的內部畫布永遠是 750 × 1334。
第二,MAX_RENDERER_RESOLUTION = 2(src/config/game.js:7)。直接把 devicePixelRatio 照單全收是很自然的寫法,但高 DPI 手機會回報 3 甚至 4。把它換算成實際要畫的像素:
| resolution | 實際緩衝區 | 像素數 |
|---|---|---|
| 1 | 750 × 1334 | 100 萬 |
| 2 | 1,500 × 2,668 | 400 萬 |
| 3 | 2,250 × 4,002 | 900 萬 |
從 2 提到 3,像素數變成 2.25 倍,而在一支手掌大的螢幕上,肉眼分不分得出來是另一回事。夾在 2 是取捨,不是最佳化。
我要在這裡守住合約的第五條:這個專案沒有量過 resolution 2 與 3 的實際 FPS 差異,所以上面那張表只有算術,沒有效能數字。 「記憶體會直線上升」是像素數的直接推論,不是實測。
第三,devicePixelRatio 是參數預設值,不是函式裡直接讀的全域變數。這一行的差別是這個函式可以在測試裡被餵任意值。整個工程底座裡,這種「把環境相依變成參數」的寫法出現在最外層,往內就沒有了。
src/ 現在長這樣(實測 @ commit 5aa3705,2026-08-07 12:35):
src/
├─ core/ 11 檔 1,219 行 引擎層:迴圈、場景管理、物理、資源、存檔
├─ systems/ 8 檔 1,466 行 遊戲系統:畫線、蜂群、碰撞、倒數、音訊
├─ levels/ 10 檔 1,058 行 關卡資料與洞穴地形產生器
├─ scenes/ 4 檔 941 行 畫面:Boot、關卡選單、遊戲、素材牆
├─ config/ 5 檔 423 行 常數:世界與色票、素材與音訊清單、碰撞層、路徑
├─ ui/ 4 檔 421 行 PixiJS 畫的按鈕、星星列、結果面板、debug HUD
├─ utils/ 2 檔 270 行 純函式:幾何、seeded random
├─ entities/ 1 檔 77 行 Bee
└─ main.js 1 檔 206 行 組裝與路由
指令是 find src/<dir> -name '*.js' | wc -l 與 find src/<dir> -name '*.js' -exec cat {} + | wc -l。九列加總正好是 46 檔、6,081 行。
這些目錄不是寫著寫著冒出來的。08-06 03:36 的 1dd9971(工程底座那個 commit)進 repo 時,src/ 底下有六個只放著 .gitkeep 的空目錄:core/、entities/、levels/、scenes/、systems/、utils/。那時候一個遊戲功能都還沒有。
先宣告的好處不是整齊,是每個新檔案在被寫出來之前,就有一個必須被回答的問題:它屬於哪一層。 這個問題如果等到有二十個檔案才問,答案會變成「照現在的樣子分」。
當然也有沒守住的地方,一併攤開:
| 項目 | PRD 的規劃 | 實際 |
|---|---|---|
entities/ |
Bee、Dog、Hive、Platform 四個檔 |
只有 Bee.js,其餘三個以資料 + 系統的形式存在 |
ui/ |
沒有這個目錄 | 4 檔 421 行,14:58 才出現 |
| 存檔 | systems/SaveSystem.js + utils/storage.js |
兩個路徑都不存在,實際是 core/ProgressStore.js(Day 25) |
目錄名稱守住了,檔案配置沒有。 這個落差本身是健康的——它代表結構是骨架不是模具。
utils/ 的兩個檔案,零 import
這是我這個專案裡最喜歡、也最心虛的一件事。
src/utils/ 只有兩個檔:geometry.js(208 行)、seededRandom.js(62 行)。兩個檔案的 import 區塊都是空的——grep -n '^import' src/utils/*.js【實測】零命中。
這兩個檔案不認得 PixiJS、不認得 Matter.js、不認得這是一款遊戲。geometry.js 匯出 11 個函式(距離、折線長度、RDP 抽稀、重採樣、節點上限、禁畫區判定、視窗座標轉遊戲座標⋯⋯),全部是進去數字、出來數字。
回報一下它換到什麼:36 個單元測試檔裡,有 8 個 引用到 src/utils/【實測:grep -rln '/utils/' tests/unit/*.test.js】。其中 geometry.test.js、seededRandom.test.js 是一對一的專屬測試,另外 6 個(決定性、線體穩定度、關卡可解性、勝負判定、場景路由、資源循環)是間接依賴——它們在測別的東西,但那些東西建立在這 270 行上。零 import 的模組才有機會被這麼多測試共用,因為它不會把整個引擎拖進測試環境。
心虛的部分在這裡:這條「規則」不存在。
AGENTS.md 全檔 69 行,沒有任何一句話規定 utils/ 不准 import 東西。eslint.config.js 全檔只有兩個 rule entry,管的是「不准 import 前端框架」「不准硬寫 /assets/ 路徑」「不准用 Math.random」,沒有一條管目錄之間的依賴方向。CI 也不會因為有人在 geometry.js 頂端加一行 import { Application } from 'pixi.js' 而變紅。
所以正確的說法是:結果是這樣,但沒有任何檢查在維持它。 現在成立,是因為只有我一個人在寫,而我記得。這個伏筆 Day 29 收——那篇要談的正是「哪些規則可以被機器執行、哪些不行」,而 utils/ 是「應該被執行卻沒有」的樣本。
座標系這件事我寫進合約的分量小得不成比例。AGENTS.md 的〈Engineering Rules〉底下就一行(:54):
Game logic uses the 750 x 1334 internal coordinate system from
PRD.md.
CLAUDE.md 多寫了一句為什麼(:32):「場景程式不要出現裝置像素或 window.innerWidth 推導出來的座標,否則手機與桌機會對不齊。」
一句話的規則,撐住了 44 個檔案,我認為原因不是那句話寫得好,是順序。讀取裝置像素的那 4 行,全部落在 08-06 04:08 的 314a7b4(渲染底座)裡;那個 commit 只有座標系、縮放、迴圈、場景管理,一行 gameplay 都沒有。等到後面開始大量產出遊戲程式碼時,最外層已經把螢幕包起來了,沒有東西需要違反這條規則。
反過來說,這條規則現在也沒有靜態檢查器守著。唯一會抓到違反的是 e2e 的一條斷言(tests/e2e/smoke.spec.js:59-60 檢查 canvas 上的 data-world-width="750"、data-world-height="1334"),而 e2e 不在 CI 的 verify job 裡。Day 8 會把「合約說了 vs 真的擋得下」這件事整個攤開。
座標系是一次性、全程無法反悔的決定。它應該在第一天就變成兩行常數,而不是等第一個裝置適配 bug 出現才被迫定義。
三件今天就能做的事:
grep -rn 'innerWidth\|devicePixelRatio' src/ 自己驗一次,命中數應該是個位數。devicePixelRatio 要夾上限。 照單全收在高 DPI 手機上是 2 到 3 倍的像素成本。夾多少沒有標準答案,但別讓它是無上限的。.gitkeep 佔位。 空目錄的成本是零,而它會逼每個新檔案在誕生前回答「你屬於哪一層」。明天 Day 6 開始,連續四天都是 AI 專篇。第一篇要處理的問題很具體:AI 每一次對話都是全新的,你上一輪講過的規則,這一輪它不知道。這個專案的答案是 repo 根目錄兩份合約檔——AGENTS.md 69 行寫完一次就再也沒動過,CLAUDE.md 139 行改了十次。明天要講的是為什麼這兩份文件長成完全不同的樣子,以及裡面哪幾條是真的能被執行的。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑與指令自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理