iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Claude AI

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

Day 08:建造要塞:Go 後端專案架構設計,用 Claude Code 生成 Clean Architecture 骨架

  • 分享至 

  • xImage
  •  

claude_08

系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code(Plan mode)
今日進度backend/ 骨架落地,GET /healthzGET /api/v1/gamedata 可以 curl 到

前言

Day 7 的週記做了一個實驗:同一份設計稿三種餵法,附意圖說明的版本有 91% 可以直接用。那是前端的故事;今天起進入第二週,鏡頭轉向後端。

Day 5 的 ADR 已經定調:Go 負責劇情狀態機、存檔與戰鬥結果驗證,戰鬥本身在前端 Canvas 跑。後端的核心價值不是效能,而是「規則要對、邊界要清楚」,正是 Clean Architecture 擅長的事。今天的目標很單純:讓 backend/ 的分層骨架長出來,把 Day 3 定案的 data/*.json 讀進記憶體,透過 HTTP 吐出去。聽起來很無聊,但這一天決定了接下來三週每一個檔案該放哪裡。

一、分層與依賴方向:先畫線,再蓋牆

Clean Architecture 講了很多圈圈,我只抓一句:依賴只能往內指

claude_08_diagram_01

  • domain/gamedomain/storydomain/save:純型別與規則,除了 uuid 不 import 任何外部套件。這一層可以在沒有資料庫、沒有 HTTP 的情況下測試。
  • usecase/*:把 domain 串成一個「使用情境」,例如「玩家回報戰鬥結果 → 驗證 → 推進劇情 → 存檔」。它知道流程,不知道 JSON 長什麼樣。
  • adapter/httpapiadapter/dynamo:翻譯層。把 HTTP 請求翻成 usecase 的參數、把 DynamoDB 的 AttributeValue map 翻成 domain 型別。
  • cmd/api/main.go:唯一知道「所有東西怎麼接起來」的地方,也是唯一可以 new 具體實作的地方。

判斷一個檔案該放哪一層,我用一個土法:把它單獨拿出來,需要什麼才能編譯? 只需要 Go 標準庫的是 domain;需要 domain 的是 usecase;需要 net/httpaws-sdk-go-v2 的是 adapter;需要以上全部的只能是 cmd。圖上「沒有出現的線」才是重點,它們藏在每個 node 的 import 白名單裡:domain 不得認識任何外層、兩個 adapter 互不相識、usecase 不得碰 HTTP 或 SDK。違反就是 code review 不過。

為什麼一個 30 天的專案要這麼講究?因為 Day 10 的存檔倉儲先用記憶體版頂著,Day 13 才長出 DynamoDB 版,兩者必須能互換,全看今天這條線畫得直不直。我把這段規則寫進 backend/CLAUDE.md,Claude 生成任何檔案前都會讀到它。

二、Plan mode:先要計畫,不要程式碼

claude 開在 backend/,按 Shift+Tab 切到 Plan mode。這個模式下 Claude 只讀檔、只列計畫——對「骨架」這種一步錯步步錯的工作特別重要。

Prompt(給 Claude Code)
「讀根目錄 CLAUDE.mddocs/gdd.mddata/ 的三個 JSON。依 Clean Architecture 為 backend/ 規劃骨架:module 名 emberhold/api,HTTP 用 chi,遊戲資料從環境變數 GAMEDATA_DIR 讀取(不要 go:embed,因為 data/ 在 repo 根目錄、跨不出 Go module)。先列出檔案清單、每個檔案的責任、package 間的依賴方向。不要寫任何程式碼。」

Claude 回了一份 14 個檔案的清單,附上每個 package 的 import 白名單。清單本身沒有驚喜——domain/game 兩個檔、httpapi 三個檔(router、respond、handlers)、platform 兩個檔、cmd/api 一個檔,加上各層的 doc.go——但它主動標出一件事:Catalog.Levels 不該序列化進 /gamedata,並在型別上用 json:"-" 排除。這正是我要的「先想清楚再動手」。我改兩處才放行:

  1. 它想讓 GET /gamedata 一次回傳塔、敵人和所有關卡。我要求關卡拆成 GET /levels/{id}——六關的路徑與波次加起來比塔和敵人大十倍,前端只要當前那一關。
  2. 它預設用標準庫 log。我要求改用 log/slog 的 JSON handler。理由是這支服務最後跑在 Lambda 上,stdout 印出去的每一行就直接是一筆 log event。前提是 Terraform 那邊的 logging_config.log_format 要設 Text,否則 Lambda 會再包一層自己的 JSON,把 level 欄位蓋掉——那是 Day 24 的坑,今天先埋線。

值得一記:GAMEDATA_DIR 那句話如果沒寫,Claude 第一反應百分之百是 //go:embed。embed 在單一 module 專案裡確實是最佳解,只是我們是 monorepo。把專案結構的「為什麼」寫進 prompt,比事後改 14 個檔案便宜太多。

三、核心程式碼

型別與 data/*.json 一比一對應,JSON tag 用 camelCase,這樣同一份 JSON 前端 TypeScript 也直接吃:

type Enemy struct {
	ID          string  `json:"id"`
	Name        string  `json:"name"`
	HP          int     `json:"hp"`
	Speed       float64 `json:"speed"` // 邏輯像素/秒
	Armor       float64 `json:"armor"`
	MagicResist float64 `json:"magicResist"`
	Bounty      int     `json:"bounty"`
	Lives       int     `json:"lives"`
	Flying      bool    `json:"flying"`
	Boss        bool    `json:"boss,omitempty"`
}

// Catalog 是整份遊戲資料在記憶體中的唯一副本。
type Catalog struct {
	Towers  map[string]Tower `json:"towers"`
	Enemies map[string]Enemy `json:"enemies"`
	Levels  map[string]Level `json:"-"`
}

// Damage 套用傷害公式:physical 吃 Armor,magic 吃 MagicResist。
func Damage(raw float64, dt DamageType, e Enemy) float64 {
	if dt == Magic {
		return raw * (1 - e.MagicResist)
	}
	return raw * (1 - e.Armor)
}

Boss 那行是 Claude 漏掉、我補上的:Day 3 的 ashlord"boss": true,少一個欄位就會在第六關靜靜地掉資料。Damage 放在 domain 是刻意的:Day 11 的模擬器與 Day 12 的驗證用同一個函數,前端 Day 17 的 TypeScript 版也要抄這五行。傷害公式只能有一份。

Load(dir string) (*Catalog, error)towers.jsonenemies.json,再 filepath.Globlevels/*.json 逐一解析,最後交叉檢查。解析錯誤一律包成 fmt.Errorf("parse %s: %w", path, err),最外層再包一次 fmt.Errorf("load towers: %w", err)——雲端 log 裡只有這行字,不帶路徑就不知道是哪個檔。三項交叉檢查是我額外要求的:每個波次引用的敵人 id 必須存在於 Enemies、每個 Spawn.Path 不可超出該關的 Paths 數量、每關至少一條路徑。資料是程式的一部分,載入時就要驗,Day 10 的劇情圖與 Day 25 的 storylint 都會再照這個原則來一次。

NewRouter(d Deps) http.Handler 收一個 Deps{Catalog *game.Catalog},掛四個 chi 內建 middleware(RequestIDRealIPRecoverer、10 秒 Timeout),然後只有三條路由。Deps 會隨著每一天長大——Day 12 加 Story usecase、Day 27 加 JWTSecret——但 NewRouter 的簽名從今天起不變,main.go 永遠只呼叫它一次。

claude_08_diagram_02

respond.go 提供 writeJSONwriteError,錯誤格式固定為 {"error":{"code","message"}}——這是前端唯一會認的形狀,從今天起不再變。

四、main.go 的最後一段:一份 binary 兩種模式

Day 6 寫進 CLAUDE.md 的那條規則今天要兌現。main 前半在組裝(讀環境變數、建 logger、game.Load()、組 router),最後一段是兩條路:

if platform.IsLambda() { // AWS_LAMBDA_FUNCTION_NAME 有值
	logger.Info("starting lambda adapter")
	lambda.Start(httpadapter.New(h).ProxyWithContext)
	return
}
runHTTPServer(logger, cfg.Port, h) // 起 net/http,signal.Notify 接 SIGTERM

New 不是 NewV2:API Gateway REST API 送的是 payload 1.0,餵給 v2 adapter 會解出空的 path 與 method,每條路由都掉 404——cmd/api/lambda_handler_test.go 用真實 v1 事件釘住它,並附一條反向護欄 TestV2AdapterCannotRouteV1Event

Lambda 那條路不起 http.Server、也不接 SIGTERM——執行環境的生命週期由 runtime 管;本機那條路才需要優雅關機,給它 5 秒的 Shutdown 上限。關鍵在兩條路拿到的 h 是同一個 handler:任何「Lambda 專用路由」都不允許存在。這條規則寫死在 NewRouter 的註解裡,Day 21 對帳本機與線上差異時省掉整整一類 bug。

五、跑起來

cd backend && go mod tidy && GAMEDATA_DIR=../data go run ./cmd/api
# {"time":"...","level":"INFO","msg":"listening","port":"8080"}
curl -s localhost:8080/api/v1/levels/nope
# {"error":{"code":"LEVEL_NOT_FOUND","message":"unknown level"}}

go build ./... && go vet ./... 乾淨,gofmt -l . 沒有輸出。啟動 log 只有 listening 一行,看不出資料載進來沒有,所以我把 ashfield.json 第一波的 "enemy": "ashwalker" 故意改成 "emberfly" 再跑一次。程式在監聽之前就死了,印出一行 ERROR:load levels: level "ashfield" wave 0 spawn 0: unknown enemy "emberfly",然後 exit 1。這才是我今天最滿意的輸出:錯誤講得出關卡、波次、第幾隻,而且服務根本起不來。

兩個提醒。第一,platform/config.go 的每個環境變數都有預設值(PORT=8080GAMEDATA_DIR=../data),在 backend/ 底下什麼都不設也能跑;Day 13 之後才需要 SAVES_TABLE,不設就用記憶體版。第二,今天的依賴只有 chiuuidaws-sdk-go-v2aws-lambda-gogolang-jwt 要到 Day 10、13、16、27 才進來,不要現在就全裝,保持每天的 diff 小而可讀。

小結

今天沒有任何「聰明」的程式碼,全是邊界工作:型別對齊資料、依賴方向對齊分層、錯誤格式對齊前端、入口對齊兩種執行環境。Plan mode 的價值在於我能在 Claude 動手之前修正它的兩個預設,而不是事後改 14 個檔案。骨架這件事,AI 負責快,人負責對。

今日產出

  • [x] domain/game/{types,loader}.gohttpapi/{router,respond,handlers_core}.go
  • [x] platform/{config,logger}.gocmd/api/main.go(雙模式入口)
  • [x] backend/CLAUDE.md 補上依賴方向規則與 import 白名單

明日預告

Day 09:Terraform 開疆:沒有 VPC 的雲端疆域(DynamoDB、S3、IAM 最小權限)。


上一篇
Day 07:【週記】Day 1-6 回顧:Claude Design 產出的原型能直接餵給 Claude Code 嗎?
系列文
奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言