iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0

模組二|工程底座與可驗收的 AI(Day 5–9)

昨天把範圍畫完了。今天進模組二,講開工那一刻第一個真正無法反悔的決定。

不是選 PixiJS,不是選 Vite。是這個遊戲的世界有多大

結論先講:座標系是所有技術決定裡最早、也最難撤回的一個。訂晚了不叫「之後再補」,叫之後每一個座標都要重算一次。 這個專案把它壓在 src/config/game.js 的第一、二行,換到的結果是:整個 src/46 個檔、6,081 行)裡,只有 2 個檔案、4 行程式碼知道螢幕實際有多大。


兩行常數,然後全庫不准再問螢幕

https://ithelp.ithome.com.tw/upload/images/20260810/201834790OrgCZA1VA.png

src/config/game.js 的第 1、2 行是 export const WORLD_WIDTH = 750export 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: 375src/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-108window.innerWidthinnerHeight 算縮放,createApplication.js:10,17devicePixelRatio 設渲染解析度。其餘 44 個檔案完全不知道瀏覽器視窗的存在。

這不是自律的成果,是結構的成果:因為縮放只發生在最外層,內層根本拿不到那些數字,也就不會想用。


letterbox:一個 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 = 2src/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 -lfind 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/ BeeDogHivePlatform 四個檔 只有 Bee.js,其餘三個以資料 + 系統的形式存在
ui/ 沒有這個目錄 4 檔 421 行,14:58 才出現
存檔 systems/SaveSystem.js + utils/storage.js 兩個路徑都不存在,實際是 core/ProgressStore.js(Day 25)

目錄名稱守住了,檔案配置沒有。 這個落差本身是健康的——它代表結構是骨架不是模具。


utils/ 的兩個檔案,零 import

https://ithelp.ithome.com.tw/upload/images/20260810/20183479qqG8Ah74UJ.png

這是我這個專案裡最喜歡、也最心虛的一件事。

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.jsseededRandom.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/ 是「應該被執行卻沒有」的樣本。


交給 AI:這一段我給的是一句話

座標系這件事我寫進合約的分量小得不成比例。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 出現才被迫定義。

三件今天就能做的事:

  1. 先寫下邏輯尺寸,再寫第一個座標。 縮放只准發生在最外層,內層拿不到裝置數字就不會用。用 grep -rn 'innerWidth\|devicePixelRatio' src/ 自己驗一次,命中數應該是個位數。
  2. devicePixelRatio 要夾上限。 照單全收在高 DPI 手機上是 2 到 3 倍的像素成本。夾多少沒有標準答案,但別讓它是無上限的。
  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

參考資料

如果你卡在語法

深入原理


上一篇
Day 4|五關、一條線、一張不做清單:範圍是刪出來的,不是排出來的
下一篇
Day 6|把「不准做的事」寫成合約:AGENTS.md 與 CLAUDE.md
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言