
系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code / Claude Cowork / Day 11 的make sim(加-csv)/ 自訂指令/sim
今日進度:data/從 v0.1 調到 v0.2,三種建造策略在灰原邊境都能通關;順手修掉模擬器一個「同 seed 不同結果」的 bug
Day 22 之後前後端是一體的了,但遊戲還不好玩。Day 11 的 v0.1 只做了一件事:把第一關從「0% 通關」救到「能通關」,代價是通關時平均只剩 4–5 命,而且火砲流 0%。六關裡只有第一關被認真看過。
今天不寫功能,只調數字。工具是 Day 11 的模擬器加一個 -csv 輸出,再讓 Claude Cowork 把曲線畫出來。開始前先講一個原則:數值調整必須是「改一個數字、跑 200 場、看曲線」的迴圈,憑手感改十個數字再打一場,永遠不知道是哪個數字起了作用。

機器給數字,人做決定——這個迴圈今天跑了五輪。
-csv,以及第一個意外cmd/sim/main.go 加一個 flag:每波結束時記一列 level,config,seed,wave,lives,gold,陣亡的那一波也記(lives=0),曲線才看得到斷崖在哪。它刻意不用 Day 11 的 worker pool,逐場循序跑——Trace 回呼要拿到 seed,而且 csv.Writer 不是 goroutine-safe:
for _, c := range cfgs {
for seed := 0; seed < runs; seed++ {
cfg := c
cfg.Trace = func(wave, lives, gold int) { row(c.Name, seed, wave, lives, gold) }
res := sim.Run(cat, lv, cfg, iㄨㄜnt64(seed))
if !res.Won {
row(c.Name, seed, res.WavesCleared+1, 0, res.GoldLeft)
}
}
}
三組 × 200 場一關 1.7 秒,不值得為它加鎖。
意外在跑第二次的時候出現:兩份 CSV 應該一模一樣(seed 固定),diff 卻有 23 場不同。我把 diff 貼給 Claude Code,它的第一個問題是「w.towers 是 map 嗎?」——是。Go 的 map 迭代順序刻意隨機,兩座塔搶同一個目標時誰先開火、誰浪費一發都會變。Day 10 才踩過同一種雷(Route 取最大值),Day 11 的 TestRunIsDeterministic 沒抓到是因為測試場景只有一座塔。修法是改成依 slot 排序後迭代;順便把測試改成三座塔。這本來就該是 Day 11 定案的規則——塔一律依 slot index 迭代,不是 map 給什麼順序就用什麼順序。沒有這個修正,接下來所有「前後對照」的數字都是雜訊。
Prompt(給 Claude Cowork,指定
docs/balance/資料夾)
「讀sim-v01-*.csv(六關、三組配置、每波剩餘生命與金幣)。每關畫一張『波次 × 平均剩餘生命』折線圖,三組配置三條線;另外列出每關『掉最多生命的波次』與『陣亡時平均剩餘金幣』。輸出curve-v01.png與report-v01.md,不要給我建議數值,只要事實。」
「不要給我建議數值」是刻意的:我要它當分析師,不是設計師。灰原邊境的表(200 場平均;修正迭代順序後重跑,所以與 Day 11 的 5.0/4.0 命有零點幾的出入):
| 配置 | 第 1 波 | 第 2 波 | 第 3 波 | 第 4 波 | 第 5 波 | 結果 |
|---|---|---|---|---|---|---|
| bolt-rush | 20.0 | 19.8 | 16.8 | 10.8 | 5.1 | 100% |
| mixed | 20.0 | 18.1 | 15.1 | 9.1 | 3.9 | 99.5% |
| splash | 20.0 | 13.0 | 10.0 | 4.0 | 0(死於第 4 波) | 0% |
曲線圖把兩件事看得很清楚:三條線在第 3→4 波、第 4→5 波各掉一截,是兩個斷崖不是一個;splash 那條線從第 2 波就開始掉,代表它的問題在開局,不在後期。這種形狀的判讀我用表格要看一分鐘,看圖只要一眼——Cowork 在這裡做的是「把 3,000 列 CSV 變成一張圖」這種我做得到但懶得做的事,而懶得做的事就會一直不做。
報告裡最有價值的是我沒要求的交叉比對:splash 陣亡時平均還有 62 金幣沒花。回頭看 Day 11 的建造佇列——火砲二級要 200 金,佇列卡在那裡等錢,第 4 波 10 隻疾風掠奪者就衝過去了。火砲流 0% 有一半不是火砲弱,是「玩家」太固執。這個發現讓我把 preset 也當成待調的對象。
另一個資料來源是 Day 13 的戰鬥稽核 item(SK = BATTLE#…,Query GSI1 WHERE GSI1PK = "DAY#<台北日期>" 就能撈出當天全部場次):上線 7 天累積 23 場真人紀錄(我自己與兩位朋友),樣本太小不能當依據,但它們在 v0.1 通關時剩餘生命的中位數 5,跟模擬器 mixed 的 3.9 在同一個量級。這至少證明模擬器沒有跟真人脫節——Day 17 用同配置對過一次,這是第二次。
每一項都是「改、跑、看」一輪;五輪、每輪不到兩秒模擬加十分鐘看數據。
| 項目 | v0.1 → v0.2 | 理由 |
|---|---|---|
石殼巨獸 armor |
0.5 → 0.45 | 物理塔對它太無力,第 3 波起每隻要 8 發弩箭 |
火砲 cost(L1/L2/L3) |
125 / 200 / 300 → 110 / 180 / 260 | 早蓋火砲不該等於自殺;L3 幾乎沒人升得起 |
火砲 L1 cooldown |
2.2 → 2.0 | 早蓋火砲的射速追不上前期怪 |
火砲 splash(L1/L2/L3) |
40 / 48 / 56 → 48 / 56 / 64 | 整條階梯往上推一格——只改 L1 會讓 L1 splash 撞上原本的 L2 |
霜語塔 L1 cost |
90 → 80 | 減速塔要在第 2 波前蓋得起 |
秘法塔 L1、L2 cooldown |
1.5 / 1.4 → 1.3 / 1.2 | mixed 的 DPS 明顯輸 bolt-rush,違反「魔法打硬皮」的設計 |
| 灰原邊境第 4 波疾風掠奪者 | 10 → 8 隻 | 第 4 波是每組都掉 5–6 命的斷崖 |
| splash preset | 火砲只升到 L2、弩塔先升 | 不再卡在等 300 金 |
石殼巨獸的 armor 一動,frontend/src/engine/damage.test.ts 那條期望值也要跟著改:takeDamage(30, 'physical') 從 30 × 0.5 = 15 變成 30 × 0.55 = 16.5 → round → 17。動資料就會動測試,這是 v0.2 當天真的發生的事,不是事後補寫。
有一件事我刻意沒動:塔的 damage 區間。傷害是 Day 3 資源三角的核心,四種塔的 DPS 比例一動,六關全部要重跑;改 cost 與 cooldown 影響的是「什麼時候買得起、多久打一次」,範圍小得多,而且玩家感受得到的是「這關能不能過」而不是「弩箭傷害 10 還是 11」。調數值的順序應該是先動關卡、再動價格與射速、最後才碰傷害。
一個反直覺的中間結果:我試著把第 5 波的石殼巨獸提前登場(delay 24 → 14),想讓高潮更緊湊,bolt-rush 從 13.2 命掉到 5.6。原因是巨獸與疾風掠奪者同時到場,弩塔用 first 策略全去打走最遠的巨獸,掠奪者全漏。這種交互作用手算不出來,只有模擬器看得到;我改回 24。
v0.2 的結果,同樣 200 場:
| 配置 | 通關率 | 平均剩餘生命 | 平均剩餘金幣 | 第 4 → 5 波掉血 |
|---|---|---|---|---|
| bolt-rush | 100%(5.1 → 13.3 命) | 13.3 | 108 | 0.4 |
| mixed | 100%(3.9 → 11.2 命) | 11.2 | 51 | 1.8 |
| splash | 0% → 99.5% | 2.5 | 129 | 6.6 |
mixed 追到 bolt-rush 附近、splash 從不可能變成「打得過但很緊」——這是刻意的:火砲的主場是第 5 關黑岩隘口那種長路徑、大群體的地圖,在單路徑的第一關本來就不該是最佳解。資源三角的意思是沒有一種塔在每一關都對。
六關全部跑過一輪後的 mixed 通關率:灰原邊境 100%、霧河渡口 96.5%、斷橋古道 88.0%、棄守修道院 79.5%、黑岩隘口 91.0%、薪火王城 73.5%(餘燼之路 boss 血量 2000 時 86%)。斷橋古道是 splash 的 0% 關——霧翼是飛行單位,火砲打不到,這次我不調,它就是設計。
/sim:把迴圈變成一個指令今天跑了三十幾次 cd backend && make sim,每次都要改 flag、開 CSV、算平均。所以做了 .claude/commands/sim.md——跟 Day 6 的 /ultracode 一樣,/sim 是我自己寫的 slash command,不是 Claude Code 內建功能:
---
description: 跑六關 × 三組配置的模擬並輸出 CSV,摘要難度斷崖,提出最多三項數值調整建議
argument-hint: [level id 或 all]
allowed-tools: Bash(make sim:*), Bash(go run ./cmd/sim:*), Read, Write
---
對 $ARGUMENTS(預設 all)逐關執行 `go run ./cmd/sim -level <id> -runs 200 -csv docs/balance/sim-<id>.csv`。
讀每份 CSV,輸出一張表:關卡 × 配置 × 通關率 × 掉最多生命的波次 × 陣亡時剩餘金幣。
標出「任一配置單波掉 ≥ 5 命」的斷崖。最多提出三項調整建議,每項附預期影響與理由。
**不要修改 `data/` 底下任何檔案**;建議由我決定後手動套用。
最後一行是這個指令的靈魂。數值是設計,設計要有人負責。「最多三項」也是刻意的:Claude 一次提十項建議時,我會全部套用然後不知道是哪一項起了作用——這正是前言說的手感式調法,只是換成 AI 的手感。三項以內,我才有力氣一項一項驗。
跑一次 /sim all 約 12 秒模擬加 40 秒讀 CSV,輸出一張六關的表與最多三條建議。第一次跑它就指出斷橋古道的 splash 0%,並「建議」把霧翼改成非飛行——這正是我不想要的那種建議,也是為什麼決定權要留在人手上。
順手把 data/VERSION 填上 v0.2;API 啟動 log 補印 dataVersion=v0.2,這樣「今天是 v0.2」不是我嘴上說說,是一行能查證的 log。
今天最大的收穫不是 v0.2 的數字,是三個關於「怎麼調數值」的方法:一、迴圈要短到不會偷懶(1.7 秒一輪);二、分析與決策分開——Cowork 給事實、Claude Code 給建議、我做決定;三、玩家模型(preset)也是數值的一部分,0% 不一定是塔的錯。還有一個老教訓再犯一次:map 迭代順序。Day 10 學過的東西,Day 11 換個地方又寫出來,測試太弱才沒抓到。
明天把眼睛從遊戲移到伺服器:上線之後它出事了,誰會知道?
cmd/sim/main.go 加 -csv;internal/sim/sim.go 修正塔的迭代順序、TestRunIsDeterministic 改三座塔data/towers.json、enemies.json、levels/ashfield.json v0.2;cmd/sim/presets.go 修正 splash 順序docs/balance/{sim-*.csv,curve-v01.png,curve-v02.png,report-v01.md}(Cowork 產出).claude/commands/sim.md;data/VERSION 填上 v0.2、啟動 log 補 dataVersion
Day 24:監控告警上線:CloudWatch 與雲端預警系統,讓伺服器不再無聲陣亡。