iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Claude AI

奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲系列 第 17 篇

Day 17:塔的升級樹:用 Pinia 管理遊戲狀態與資源經濟系統

  • 分享至 

  • xImage
  •  

claude_17

系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code
今日進度:四種塔可建、可升三級、可賣;投射物會飛、傷害公式吃護甲與魔抗;金幣、生命、薪火三角在 Pinia 裡流動

前言

Day 16 把管線接通後,戰場終於能回來長塔。今天處理的是「錢」——塔防樂趣有一半來自經濟:何時升級、何時賣塔換位置、要不要留錢等下一波的石殼巨獸。經濟不該住在引擎裡。

昨天的邊界原則今天要再往前推一步:引擎管物理,store 管經濟。Game.placeTower() 不看金幣,它只管塔位有沒有空;扣錢的是 Pinia 的 buildTower()。這樣引擎的測試不用準備金幣,store 的測試也不用跑 Canvas,兩邊都可以在 node 裡各自驗證。

一、塔:三級數值與賣價

data/towers.json 每種塔有 levels[0..2],Tower.level 是索引,升級即 level++ 並把該級 cost 累加進 spent。賣塔退回累計花費的 60%——來自 Day 3 GDD:讓「拆掉重蓋」有成本但不懲罰嘗試。試過 50%:玩家不敢動蓋錯位置的塔;75%:「先蓋便宜塔擋前兩波再全賣掉換火砲」變成唯一解。

// frontend/src/engine/entities/Tower.ts(節錄)
get stats(): TowerLevelDef { return this.def.levels[this.level] }
get upgradeCost(): number | null { return this.canUpgrade ? this.def.levels[this.level + 1].cost : null }
get sellValue(): number { return Math.floor(this.spent * SELL_RATIO) }
upgrade(): void {
  if (!this.canUpgrade) return
  this.level++
  this.spent += this.stats.cost
}

sellValue 用 Math.floor 而不是四捨五入:退款寧可少給也不能多給,spent = 340 退 204,測試會把這個值釘住。Claude Code 在這裡提醒我 0.6 在 IEEE 754 下不是精確值,建議改寫成 spent * 6 / 10。我沒有照單全收,先把 spent 從 0 跑到 20000 比對兩種寫法:零筆差異。這種提醒的正確反應不是照做也不是忽略,是花三十秒把它變成一個能回答的問題。

rollDamage(rng = Math.random) 在 [lo, hi] 之間取整數,把亂數當參數注入,測試時塞 () => 0.5 就能得到可重現的結果。這是 Claude Code 主動建議的,理由寫得很清楚:「Day 11 的 Go 模擬器也是這樣做的,前後端的傷害測試才能對照」。它讀過 backend/CLAUDE.md,知道另一邊的慣例——這是 monorepo 加上專案記憶最直接的回報。

二、目標選擇與投射物

目標策略先做 first(progress 最大者優先)。Day 15 把位置存成 progress 的好處在這裡兌現:不用算到終點的距離,比一個數字就好。飛行單位(mistwing)要看塔的 hitsFlying,火砲打不到它——這是 Day 3 資源三角裡「沒有一種塔能解決所有問題」的設計落點。

pickTarget(tower, enemies, strategy) 是一個迴圈:跳過死掉的、打不到的飛行單位、射程外的,剩下用 progress(或 strongest 時 hp)比大小。純函式不碰狀態,Day 26 換空間網格查詢時,只需換掉「遍歷 enemies」那層,比較邏輯不動。

Prompt(給 Claude Code)
「依 types.ts 與 towers.json 實作 CombatSystem:塔冷卻歸零就對 pickTarget 的目標發射投射物;命中結算 physical/magic、splash、slow;投射物直線飛、命中半徑 10px、飛出畫布作廢。傷害亂數用注入的 rng。」

Claude 第一版把 splash 做成「離落點越遠傷害越低」的線性衰減,合理但 Day 3 GDD 沒寫衰減,我要求改成全額——平衡(Day 23)要以 GDD 為準,不讓實作偷偷加規則。

投射物是「像箭」的決定:發射當下瞄準目標位置,之後直線飛,命中半徑 10px 容錯,飛出畫布作廢。傷害在 Enemy.takeDamage:physical 吃 armor、magic 吃 magicResist,四捨五入後至少 1 點;火砲 splash 對落點半徑內所有敵人同樣傷害;霜語塔命中時 applySlow() 把 slowFactor/slowUntil 記在敵人身上,currentSpeed(now) 比的是引擎模擬時間。

// frontend/src/engine/systems/CombatSystem.ts(Day 17 第一版,節錄)
export const HIT_RADIUS = 10
for (const p of this.projectiles) {
  if (!p.target.alive) { p.alive = false; continue }   // 目標死了就作廢
  p.x += p.vx * dt
  p.y += p.vy * dt
  if (Math.hypot(p.target.x - p.x, p.target.y - p.y) <= HIT_RADIUS) { this.hit(p, enemies, now); p.alive = false }
  else if (p.x < 0 || p.x > LOGICAL_WIDTH || p.y < 0 || p.y > LOGICAL_HEIGHT) p.alive = false
}

先記住這段。灰燼步兵打得順,但第二波出現怪事:疾風掠奪者身邊常有弩箭「擦過去」。今天先歸咎它太快,Day 20 會回來算帳。

三、Pinia:資源三角住在這裡

stores/game.ts 用 setup 語法。引擎實例是一個普通的 let game,不放進 ref() 也不回傳,UI 看到的塔是 TowerView[] 快照(id、名字、等級、升級費、賣價),每次操作後 sync() 一次。引擎的事件在 bind() 訂閱、unbind() 逐一解除。這個細節今天就踩到了一半:Vite 的 HMR 在我改 BattleView.vue 時重跑 onReady,bind() 被呼叫兩次,殺一隻灰燼步兵金幣 +12 而不是 +6。加了開頭那行 unbind() 之後才正常——HMR 是一種免費的「重複掛載」壓力測試,Day 19 在手機上連打兩關時,同一個保護會再救我一次。

// frontend/src/stores/game.ts(節錄)
let game: Game | null = null   // 非 reactive:Proxy 會拖慢每秒 60 次的引擎迴圈
function bind(g: Game, gd: GameData, emberPoints: number): void {
  unbind()
  game = g; data = gd
  gold.value = g.level.startGold; lives.value = g.level.lives; ember.value = emberPoints
  offs = [
    g.events.on('waveStarted', (p) => { wave.value = p.wave; phase.value = 'wave' }),
    g.events.on('enemyKilled', (p) => (gold.value += p.bounty)),
    g.events.on('enemyLeaked', (p) => (lives.value = Math.max(0, lives.value - p.lives))),
    g.events.on('battleEnded', (p) => (phase.value = p.won ? 'won' : 'lost')),
  ]
}
function buildTower(slotIndex: number, towerId: string): boolean {
  const def = data?.towers[towerId]
  if (!game || !def || gold.value < def.levels[0].cost) return false
  if (!game.placeTower(slotIndex, def)) return false
  gold.value -= def.levels[0].cost
  sync()
  return true
}

claude_17_diagram_01

upgradeTower(id)/sellTower(id) 同模式:store 比錢 → 引擎動手 → sync()。useEmberBreath() 是資源三角的耦合點:薪火來自 Day 4 的劇情選擇(後端算),花掉一點是 game.slowAll(0.5, 3)——全場減速 50%、三秒,只在 phase === 'wave' 時能用。Day 22 接上 API 後,bind() 第三參數會從 story.state.ember 來,今天還是寫死的 0,這是今天欠的第二筆債。

TowerView 做快照而不是把 game.towers 直接丟給 template:template 一讀取,Vue 就會把引擎物件的一部分變成 reactive。快照多一次 map,四個塔位感覺不到;讓引擎被 Proxy 追蹤的成本卻是每幀都在付。

四、TowerMenu:讓玩家知道升級買到什麼

塔防 UI 最常被忽略的細節:升級按鈕旁邊要寫「下一級是什麼」。TowerMenu.vue 用 nextStats 直接讀 levels[level + 1],買不起就灰掉:

<button v-if="tower.upgradeCost !== null" :disabled="!game.canAfford(tower.upgradeCost)" @click="upgrade">
  升級 ({{ tower.upgradeCost }}G)
  <small v-if="nextStats">→ 傷害 {{ nextStats.damage[0] }}–{{ nextStats.damage[1] }},射程 {{ nextStats.range }}</small>
</button>

canAfford 是 store 的 getter,按鈕自己會灰,不用在三個地方重複比金幣——這種「判斷收斂到一處」的小事 Claude Code 生成時就做了。選單點空塔位顯示四種塔與價格,點既有的塔顯示升級與賣出;桌機單擊、手機長按,差異明天再處理。

五、測試與實戰

// frontend/src/engine/damage.test.ts(節錄)
it('physical 吃 armor:石殼巨獸 armor 0.5,30 點只吃 15', () => {
  expect(new Enemy(E.stoneshell, path).takeDamage(30, 'physical')).toBe(15)
})
it('升級累計花費,賣塔退 60%', () => {
  const t = new Tower(T.bolt, 0, 0, 0)
  t.upgrade(); t.upgrade()          // 70 + 110 + 160
  expect(t.spent).toBe(340)
  expect(t.sellValue).toBe(204)
})

5 tests 全過。接著真的打灰原邊境五波(Day 11 平衡後的 v0.1:260 金開局、第五波 6 隻疾風掠奪者),配置照 Day 11 的 mixed(弩塔開局+霜語塔減速+秘法塔打硬皮),通關時剩 5 命、82 金,與 Go 模擬器同配置 200 場的平均(4.0 命、89 金)只差 1 命——前後端的傷害公式對得上,這是今天最重要的驗證,因為之後 Day 23 的平衡調整全部依賴「模擬器說的等於玩家感受到的」。

小結

今天的程式量是第三週最大的一天,但決策只有一個:錢歸 store、物理歸引擎。Claude Code 在此邊界下產出的程式幾乎不用改,因為每個函式職責都小到不會有歧義——這比任何 prompt 技巧都有效。今天唯一需要推翻它的地方(splash 衰減)正是規格沒寫清楚之處:AI 用「合理」填補空白,合理不等於你要的。

兩筆債記在 ~/emberhold-notes/daylog/day17.md:投射物對疾風掠奪者「怪怪的」,以及 bind() 薪火參數還是 0。誠實記債,比假裝今天做完重要。

今日產出

  • [x] engine/entities/{Tower,Projectile}.ts、systems/{TargetingSystem,CombatSystem}.ts
  • [x] Game.ts 加入 placeTower / upgradeTower / removeTower / slowAll
  • [x] stores/game.ts、components/{TowerMenu,Hud}.vue、views/BattleView.vue
  • [x] engine/damage.test.ts(5 tests)

明日預告

Day 18:分支劇情前端呈現:對話框系統與選項介面的 RWD 設計實作——把 Day 7 那個從設計稿生出來的 DialogueBox.vue 接上真正的劇情。


上一篇
Day 16:CI/CD 佈陣:GitHub Actions 串接 Terraform,讓部署變成一鍵出兵
系列文
奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言