
系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code
今日進度:internal/sim無畫面戰鬥模擬器 +cmd/sim,第一次跑就把我的數值打臉
Day 10 做完劇情狀態機,今天做戰鬥。先講清楚一件事:戰鬥畫面是在前端 Canvas 跑的(Day 15 起),那後端做戰鬥幹嘛?兩個理由。一、Day 23 要調平衡,總不能每改一個數字就親手打五波;二、Day 12 的「戰鬥結果驗證」需要知道「這關合理的通關結果長什麼樣」,否則後端只能相信前端說的任何數字。
所以今天做的是無畫面模擬器:同一份 data/、同一套規則,用 goroutine 平行跑幾百場,統計通關率、剩餘生命與金幣。也先講清楚 goroutine 用在哪:不是遊戲 loop 本身——一場戰鬥是嚴格循序的 tick,平行化它只會製造 race;goroutine 用在「同時跑很多場」,這才是 Go 擅長的形狀。
dt = 1/30 秒;敵人沿路徑以 progress(已走距離)前進,path.at(progress) 依累計線段長度取座標——Day 15 前端的 Path.positionAt 必須是同一個算法,否則兩邊對「敵人現在在哪」的答案不一樣。first:射程內 progress 最大者,也就是離終點最近的那隻;hitsFlying=false 的火砲看不到霧翼。game.Damage;splash 打目標周圍全部;slow 設定 slowUntil 與 slowFactor。Prompt(給 Claude Code)
「在internal/sim實作無畫面模擬器。Config{Name, Build []Placement},Placement{Slot, Tower, Level}是建造佇列。Run(cat, level, cfg, seed) Result:依Spawn.Delay/Interval排出每波的出怪時刻表,敵人沿Paths前進、漏掉扣Lives,塔用 first 策略開火,套用game.Damage、splash、slow。同 seed 結果必須可重現。」
波次生成器只是把 data/levels/*.json 的宣告式描述展開成一張時刻表:
func schedule(wave game.Wave, start float64) []spawnEvent {
var ev []spawnEvent
for _, s := range wave.Spawns {
for i := 0; i < s.Count; i++ {
ev = append(ev, spawnEvent{at: start + s.Delay + float64(i)*s.Interval, enemy: s.Enemy, path: s.Path})
}
}
sort.SliceStable(ev, func(i, j int) bool { return ev[i].at < ev[j].at })
return ev
}
step() 每 tick 做兩件事,而且順序固定:spawn → 敵人前進(含漏怪扣命)→ 塔開火 → 投射物 → cull → checkEnd。第一,敵人前進:progress += speed * slowFactor * dt,減速效果只看 slowUntil 有沒有過期,走到路徑盡頭就標記死亡並扣 Lives——塔要打的永遠是「敵人移動之後」的位置。第二,每座塔倒數冷卻,歸零就 pickTarget(射程內 progress 最大、且塔打得到的那隻),有目標就 hit 並重設冷卻;splash > 0 的火砲則以目標座標為圓心,對半徑內所有活著的敵人各 hit 一次。hit 裡先在 Damage[0]–Damage[1] 之間抽一個數,套 game.Damage 後扣血,血量歸零就發賞金。
這個順序不是模擬器自己發明的——Day 15 的前端引擎用的是同一份固定順序,兩邊對「這一 tick 誰先動」的答案必須一致,模擬出來的數據才能拿來對照真實畫面。另外一條今天先寫死的規則:塔的迭代必須依 slot index 由小到大,不能對塔的集合用 Go 的 range map——map 迭代順序是隨機的,同一個 seed 會因為開火順序不同而打出不同結果。現在寫下來是因為 Day 23 會真的因為這個坑吃一次虧,先立規矩比事後除錯划算。

tryBuild() 每 tick 都會看一眼建造佇列的第一項:塔位空著就要求 Level == 1,塔位有塔就要求同種塔且剛好升一級,金幣夠就扣錢執行、不夠就 return 等下一 tick,格式不合法的項目直接丟掉。這個「等得起」的語意很重要——它模擬的是玩家看到金幣夠了就升級,而不是開局就全部蓋好。
Run 的外層很單純:每波先 schedule,然後「還有沒出的怪,或場上還有活的」就一直 tryBuild → spawn → step,生命歸零立刻回傳;全部波次清完則勝利,波與波之間留 2 秒喘息。隨機數只用在傷害的 min–max 區間,rand.New(rand.NewSource(seed)) 保證同 seed 同結果——TestRunIsDeterministic 就是跑兩次比對整個 Result。
// RunAll 用 worker pool 平行跑「每組配置 × runs 場」,再彙整成 Summary。
func RunAll(cat *game.Catalog, lv game.Level, cfgs []Config, runs, workers int) []Summary {
type job struct{ cfg int; seed int64 }
jobs := make(chan job)
results := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
results <- Run(cat, lv, cfgs[j.cfg], j.seed)
}
}()
}
go func() {
for ci := range cfgs {
for r := 0; r < runs; r++ {
jobs <- job{cfg: ci, seed: int64(r)}
}
}
close(jobs)
}()
go func() { wg.Wait(); close(results) }()
agg := make([]Summary, len(cfgs))
// ... for r := range results:累加 WinRate / AvgLives / AvgGold / AvgWaves,最後除以場數
return agg
}
三種 goroutine 各司其職:N 個 worker 從 jobs 拿工作、一個 producer 塞完 job 後 close(jobs)、一個等 wg.Wait() 後 close(results)。

主 goroutine 只負責 for r := range results 彙整,全程沒有任何鎖——這是 Go 的老話「用通訊共享記憶體」最乾淨的示範。Catalog 與 Level 是唯讀的,多個 worker 同時讀沒有問題;每場的可變狀態全在 Run 內部的 world 裡。seed 用 runs 的索引,所以 -runs 200 永遠是同樣的 200 場,改數值前後可以公平比較。
為什麼是 30 Hz 而不是前端的 60 Hz?模擬器不需要畫面平滑,只需要「敵人不會在一個 tick 內跨過整個射程」——最快的疾風掠奪者 95 px/s 在 1/30 秒只走 3 px,遠小於任何射程,30 Hz 綽綽有餘且速度快一倍。這也給了 Day 20 一個對照組:前端如果出現「高速敵人穿過去不命中」,先來這裡確認同樣的數值在 30 Hz 下命中正常,就能把嫌疑縮小到前端的投射物邏輯。
cmd/sim/main.go 的 flags:-data(預設 ../data)、-level、-runs(200)、-workers(runtime.NumCPU())、-configs(逗號分隔的預設配置名)。Day 6 的 make sim LEVEL=ashfield 包的就是這一行。三組預設建造順序在 cmd/sim/presets.go:bolt-rush(全弩塔鋪滿再升級)、mixed(弩塔開局+霜語塔減速+秘法塔打硬皮)、splash(早蓋火砲清群體)。200 場 × 3 組在 16 核心上跑 0.4 秒。
$ make sim LEVEL=ashfield
level=ashfield runs=200 workers=16
config win% avg lives avg gold avg waves
bolt-rush 0.0 0.0 25.2 2.80
mixed 0.0 0.0 52.1 2.00
splash 0.0 0.0 72.1 2.00
三種配置通關率 0%。我請 Claude 加了 Config.Trace 回呼,印出每波結束的生命與金幣(SIM_TRACE=1 go test ./internal/sim -run Trace -v):
| 波次 | v0 剩餘生命 | v0 金幣 |
|---|---|---|
| 1 | 18 | 46 |
| 2 | 11 | 24 |
| 3 | 4 | 72 |
| 4 | 陣亡 | — |
第一波就漏 2 隻。原因不難算:灰燼步兵每 1.2 秒一隻、60 血,等於每秒送 50 點血量進場;三座一級弩塔各約 12.5 DPS,加起來 37.5,本來就追不上。這是 Day 3 我拍腦袋寫的數字,模擬器只花 0.4 秒就證明它不能玩——而且是在畫面做出來之前。
我做了一個只動關卡檔的 v0.1 補丁(塔與敵人的數值留給 Day 23 整體調校):ashfield.json 的出怪 interval 全部拉長約兩倍(1.2→2.5、1.0→2.2…)、startGold 220→260、第五波疾風掠奪者 8→6:
config win% avg lives avg gold avg waves
bolt-rush 100.0 5.0 55.4 5.00
mixed 100.0 4.0 89.0 5.00
splash 0.0 0.0 59.8 3.06
三個洞察,全部留給 Day 23:一、火砲早蓋必死——125 金幣讓開局只剩兩座塔,第三波石殼巨獸一到就崩;二、能贏的兩組平均都只剩 4–5 命,第五波是明顯的難度斷崖;三、mixed 剩的金幣比 bolt-rush 多 34,代表秘法塔對護甲 0.5 的石殼巨獸效率確實比弩塔好,Day 3 的「魔法無視護甲」設計是有感的。順帶一提,第 6 關 boss 依路線修正——variants 裡餘燼之路 boss 血量寫成 2000、燃盡之路護甲寫成 0.5——也只是在關卡 JSON 上動手腳,模擬器一行不用改。
今天最大的收穫不是 goroutine,是「數值設計必須有回饋迴路」。沒有模擬器,我會在 Day 15 做完畫面才發現第一關打不過,然後在「是引擎 bug 還是數值問題」之間浪費一天。goroutine 的用法也很典型:worker pool 三種角色、channel 關閉順序對了就不需要鎖,唯讀資料共享、可變狀態各自私有。模擬器省略投射物是刻意的簡化,我把它寫在 package 註解第一行,免得 Day 23 看數據時忘了。
backend/internal/sim/{sim,sim_test}.go(TestPathAt、TestRunIsDeterministic)backend/cmd/sim/{main,presets}.go、make sim LEVEL=ashfield
data/levels/ashfield.json v0.1(只改 interval / startGold / 第五波數量)Day 12:worktree 初體驗:同時開發「戰鬥系統」與「劇情系統」的平行分身術(含實測數據)。