iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Claude AI

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

Day 11:塔防戰鬥核心:波次生成器與敵人 AI 邏輯,Go 的 goroutine 如何模擬戰場

  • 分享至 

  • xImage
  •  

claude_11

系列:奇幻塔防開發實錄:用 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 的火砲看不到霧翼。
  • 傷害套 Day 8 的 game.Damage;splash 打目標周圍全部;slow 設定 slowUntil 與 slowFactor。
  • 建造用「佇列」:玩家事先排好順序,金幣夠就蓋,不夠就等賞金。這模擬的是「有計畫的玩家」,不是即時反應。
  • 刻意省略投射物飛行時間(命中即結算)。這讓模擬器比真實戰鬥略寬鬆——弩塔在前端會有箭矢飛行、可能追不上疾風掠奪者,這裡不會。看數據時要記得這點;Day 20 修前端碰撞 bug 時會回頭比對。

二、波次生成器與 tick

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 會真的因為這個坑吃一次虧,先立規矩比事後除錯划算。

claude_11_diagram_01

tryBuild() 每 tick 都會看一眼建造佇列的第一項:塔位空著就要求 Level == 1,塔位有塔就要求同種塔且剛好升一級,金幣夠就扣錢執行、不夠就 return 等下一 tick,格式不合法的項目直接丟掉。這個「等得起」的語意很重要——它模擬的是玩家看到金幣夠了就升級,而不是開局就全部蓋好。

Run 的外層很單純:每波先 schedule,然後「還有沒出的怪,或場上還有活的」就一直 tryBuild → spawn → step,生命歸零立刻回傳;全部波次清完則勝利,波與波之間留 2 秒喘息。隨機數只用在傷害的 min–max 區間,rand.New(rand.NewSource(seed)) 保證同 seed 同結果——TestRunIsDeterministic 就是跑兩次比對整個 Result。

三、goroutine:worker pool 平行跑幾百場

// 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)。

claude_11_diagram_02

主 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 秒。

四、第一次跑:v0 數值全滅

$ 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 看數據時忘了。

今日產出

  • [x] backend/internal/sim/{sim,sim_test}.go(TestPathAt、TestRunIsDeterministic)
  • [x] backend/cmd/sim/{main,presets}.go、make sim LEVEL=ashfield
  • [x] data/levels/ashfield.json v0.1(只改 interval / startGold / 第五波數量)

明日預告

Day 12:worktree 初體驗:同時開發「戰鬥系統」與「劇情系統」的平行分身術(含實測數據)。


上一篇
劇情引擎後端化:用 Go 設計分支劇情狀態機與存檔系統
下一篇
Day 12:worktree 初體驗:同時開發「戰鬥系統」與「劇情系統」的平行分身術(含實測數據)
系列文
奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲 共 14 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言