
系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code(Plan mode)
今日進度:灰燼步兵沿著「灰原邊境」的路徑走到底,固定時間步 60fps,引擎與 Vue 完全分離
Day 14 的週記做了一個決定:worktree 只留給「檔案集合互不相交、測試跑很久」的任務,其他時候老老實實一條分支。數據很直白——三次平行實驗分別 −38%、−44%、+13%,合計 215 → 152 分鐘;那個 +13% 就是兩個任務都改 router.go 的反例。第三週的主戰場從 Go 要塞轉到瀏覽器,前端這種「改一行、看一眼」的節奏,反而是單一 session 最順手。
今天的目標很克制:不做塔、不做傷害,只要敵人能依照 data/levels/ashfield.json 的 paths 走到終點、扣掉生命、畫出來。聽起來很少,但今天要定下的三件事——引擎與框架的邊界、固定時間步、路徑的座標算法——會一路撐到 Day 26 的效能優化,也是 Day 20 能用純邏輯測試重現 bug 的前提。地基打歪了,上面蓋什麼都會斜。
我沒有直接叫 Claude Code 寫程式,而是先進 Plan mode(Shift+Tab 兩次)要它提骨架:
Prompt(給 Claude Code)
「讀frontend/CLAUDE.md和data/levels/ashfield.json。我要一個與框架無關的 Canvas 塔防引擎放在src/engine/,Vue 只負責掛載與 UI。要求:固定時間步、敵人用『沿路徑已走距離』表示位置、引擎對外只透過型別化事件溝通,不 import Pinia。先給我檔案清單與每個檔案的職責,不要寫程式。」
Claude 回了一份 11 個檔案的清單,附上每個檔案的依賴方向。我改了兩處。它原本把 lives 放在事件 payload 裡,讓 UI 自己累加;我改成引擎自己持有 lives——勝負判定必須在引擎裡,UI 只能鏡射,否則之後做重播或模擬時,會發現「誰輸誰贏」的邏輯散落在畫面層。第二,它的事件用字串 key,我要求改成 GameEventMap 型別化介面:Day 17 把事件接到 store 時,payload 少一個欄位會直接紅字,而不是在戰鬥打到一半時變成 undefined。
定案的十一個檔案,今天只做七個:types、Path、events、Game、Renderer、entities/Enemy、systems/WaveSystem;Tower、Projectile、TargetingSystem、CombatSystem 留給 Day 17。依賴方向只有一個:Game 認識所有人,其他人誰也不認識誰。

為什麼這麼堅持「引擎不認識 Vue」?三個很實際的理由。第一,引擎可以在 vitest 的 node 環境跑,不需要 jsdom 也不需要真的 Canvas,今天的測試就會用到。第二,引擎物件不會被 Vue 的 Proxy 包住——每秒 60 次、每次幾百個物件的讀寫,Proxy 的攔截成本是真的存在的。第三,Day 20 要重現 bug 時,可以寫一個沒有畫面的測試,把 200 隻敵人的戰鬥在 100 毫秒內跑完。
requestAnimationFrame 給的時間差不穩定:桌機 16.7ms、手機掉幀時 50ms、分頁切到背景再回來可能是 30 秒。如果直接拿這個 dt 去乘速度,同一場戰鬥在不同裝置上會有不同結果,測試也無法重現。解法是 accumulator:把不穩定的 dt 累積起來,切成固定的 1/60 秒一步一步推進。
// frontend/src/engine/Game.ts(節錄)
static readonly STEP = 1 / 60
static readonly MAX_DT = 0.1 // 分頁切回時的 dt 上限
private frame = (now: number): void => {
let dt = (now - this.last) / 1000
this.last = now
if (dt > Game.MAX_DT) dt = Game.MAX_DT
this.acc += dt
while (this.acc >= Game.STEP) {
this.step(Game.STEP)
this.acc -= Game.STEP
}
this.renderer?.draw(this)
this.raf = requestAnimationFrame(this.frame)
}
/** 公開給測試用:推進一個固定時間步 */
step(dt: number): void {
this.time += dt
this.waves.update(dt, (e) => this.enemies.push(e)) // spawn
for (const e of this.enemies) {
e.update(dt, this.time) // 敵人前進(含漏怪扣命)
if (e.reachedEnd && e.alive) { /* 扣 lives、emit('enemyLeaked') */ }
}
// 塔開火/投射物:Day 17 CombatSystem 插在這裡
this.enemies = this.enemies.filter((e) => e.alive) // cull
this.checkEnd() // lives<=0 → lost;波次清空 → prep 或 won
}
MAX_DT 是那個 30 秒空檔的保險:沒有它,while 會一口氣跑 1800 步,敵人瞬間全部走到終點、生命歸零,玩家只看到「我切個分頁回來就輸了」。有了它,切回來的那一幀最多只推進 0.1 秒,遊戲等於在背景暫停。這個常數 Day 20 會再被拿出來檢視一次。
今天還沒塔,但順序先定死:敵人移動排在塔開火前,塔打的是這 tick 移動後的位置。step() 刻意設為 public,而且只吃 dt、不碰 performance.now()。引擎內部的 time 是模擬時間,之後霜語塔的減速到期(slowUntil)比的是它,不是牆鐘——這是整個引擎可測試性與可重現性的關鍵。
固定時間步還有一個今天看不到、Day 23 會很感謝的副作用:同樣的輸入會得到同樣的輸出。Day 11 的 Go 模擬器同樣是固定步長推進(它用 1/30 秒,前端 1/60),兩邊在同一個模擬時間點的敵人位置理論上要一致。之後調整數值時,前端的手感與後端的通關率才對得起來;如果前端用不固定的 dt,模擬器算出「第 4 波剛好打得完」,玩家在掉幀的手機上可能就是打不完。

敵人身上不把 x、y 當狀態存,只存 progress(沿路徑已走的距離),座標永遠由 Path.positionAt() 算出來。這個決定有三個好處:多條路徑的關卡(Day 2 設計的霧河渡口)不用改任何邏輯;「誰走最遠」的目標選擇(Day 17)變成比一個數字;減速只需要改「每秒加多少 progress」。
// frontend/src/engine/Path.ts(節錄)
positionAt(progress: number): Point {
let remaining = Math.max(0, progress)
for (let i = 0; i < this.segmentLengths.length; i++) {
const len = this.segmentLengths[i]
if (remaining <= len) { // 落在第 i 段:線性內插
const t = len === 0 ? 0 : remaining / len
const [x1, y1] = this.points[i], [x2, y2] = this.points[i + 1]
return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t]
}
remaining -= len
}
return [...this.points[this.points.length - 1]]
}
段落判斷要用 >=,用 > 會讓轉角那一格掉到下一段。建構子預先算好每段長度與總長 totalLength,reachedEnd 就是 progress >= totalLength。Enemy.update(dt, now) 只有兩行:progress += currentSpeed(now) * dt,再呼叫 positionAt 更新 x, y 給 Renderer 用——注意 x, y 是快取,不是狀態,任何時候都能從 progress 重算回來。
WaveSystem 負責把關卡資料變成敵人:startNextWave() 把 waves[i].spawns 展開成 { at: delay + i * interval, enemy, path } 的排程表、依 at 排序;update() 每步把到期的項目變成 new Enemy() 交給引擎。這三個檔案都在 60 行內,Claude 一次寫對,我只補了一個規則:遇到 enemies.json 裡沒有的敵人 id 要直接 throw。資料驅動的專案,資料錯了要大聲,不要默默跳過。
<!-- frontend/src/components/BattleCanvas.vue(script 節錄) -->
const canvas = ref<HTMLCanvasElement | null>(null)
let game: Game | null = null // 刻意不放進 ref:引擎物件不該被 Proxy 包住
onMounted(() => {
const ctx = canvas.value?.getContext('2d')
if (!ctx) return
game = new Game(props.level, props.data, new Renderer(ctx))
emit('ready', game)
game.start()
})
onUnmounted(() => game?.stop())
元件的職責只有兩件:把 <canvas> 交給引擎、在 unmount 時停掉 requestAnimationFrame。Renderer 用 960×540 的邏輯座標畫路徑(28px 粗的灰藍色 #2B3A4A 線)、塔位(琥珀色 #F5A524 圓圈)和敵人(含血條),配色直接沿用 Day 2 設計稿的 token。RWD 的縮放今天不碰,留給 Day 19。
那句註解是今天唯一的踩雷。第一版我順手把 game 放進 ref(),敵人 40 隻時 Chrome 的 Performance 面板顯示 step() 每幀 3.1ms;改成普通變數之後 0.4ms。差 8 倍,全部是 Proxy 攔截 enemies[i].progress 這類讀寫的成本。Claude Code 生成的第一版沒有犯這個錯,是我自己「習慣性」加上去的。
Path.test.ts 用 ashfield.json 的第一條路徑當 fixture,四個 case:總長等於 1410、positionAt(0) 在起點且超過總長夾在終點、轉角 positionAt(350) 剛好落在 [200, 150]、線段中途 positionAt(275) 內插出 [200, 225]。bun run test → 4 passed,bun run type-check 零錯誤。1410 這個數字是我手算的(200+150+300+300+300+160),測試通過代表 Claude 的段長計算與我的算法一致,也代表 ashfield.json 的路徑點沒有被誰手滑改過。
開瀏覽器,暫時在 console 呼叫 game.startWave():8 隻灰燼步兵以 2.5 秒間隔出生(Day 11 平衡後的 v0.1 數值),每隻以每秒 50px 花 28.2 秒走完 1410px,最後一隻在第 46 秒抵達,生命從 20 掉到 12,phase 回到 prep 等下一波。沒有塔,但戰場活了。
今天寫的程式不多,但每一行都是「之後會被依賴」的那種:step(dt) 讓引擎可以在 node 裡跑、progress 讓目標選擇變成比大小、GameEventMap 讓 UI 與引擎之間的契約有型別。Claude Code 在 Plan mode 提的骨架有八成直接採用,真正需要我判斷的是「狀態放哪裡」——這種邊界問題 AI 會給合理的答案,但不會替你承擔三週後的後果。清單只有 30 行,改起來便宜;程式一旦生出 300 行,就捨不得推翻結構了。
frontend/src/engine/{types,Path,events,Game,Renderer}.ts
frontend/src/engine/entities/Enemy.ts、systems/WaveSystem.ts
frontend/src/components/BattleCanvas.vue
frontend/src/engine/Path.test.ts(4 tests)Day 16:CI/CD 佈陣:GitHub Actions 串接 Terraform,讓部署變成一鍵出兵——前端先暫停一天,把 Lambda、API Gateway、CloudFront 與 OIDC 的管線接起來。