从 Spring Boot 到 Go
亲手写一个多人 HD2D 服务端
你已经会写 Spring Boot 服务,缺的不是“服务端经验”,而是一套 Go 的并发与工程手法。
这条路线不换项目:从 go mod init 开始,逐章把同一个 hd2d-server 养成
权威服务器——固定 20Hz tick、房间 Actor、AOI 快照、断线重连、SQLite 存档,最后用 Go 写的 bot
在本机验证多人联机与压测。
路线一览
一条主线:同一个 hd2d-server 从 Hello 走到可联机。橙色节点是里程碑,必须前置任务全绿才建议继续。
先完成一次心智迁移
同一件事,Spring Boot 与 Go 的默认答案不同。表里右列就是本项目实际会写的做法。
| Spring Boot 里你是这么做 | Go 里改成这么做 |
|---|---|
| @SpringBootApplication 启动容器 | main() 里手工装配依赖,没有容器、没有包扫描 |
| Bean + @Autowired | 构造函数 / 参数显式注入;接口在调用方定义(Go 的“鸭子类型”) |
| application.yml + @ConfigurationProperties | 环境变量 + config.Load(),默认值写在代码里 |
| @RestController + @RequestMapping | http.ServeMux + "GET /healthz" 方法路由(Go 1.22+) |
| Filter / HandlerInterceptor | func(http.Handler) http.Handler 中间件,手动串起来 |
| thread pool / ExecutorService | goroutine + channel + context;不要每个请求开线程池 |
| synchronized / ReentrantLock | 首选“单 goroutine 拥有状态”(Actor),锁只是下策 |
| JPA / @Transactional | database/sql + 显式 BeginTx,SQL 自己写 |
| HikariCP 连接池 | sql.DB 自带连接池;SQLite 建议 SetMaxOpenConns(1) |
| Jackson ObjectMapper | encoding/json + struct tag |
| Logback + MDC | log/slog 结构化日志(key-value,不加 MDC) |
| Fat jar + JVM 启动 | 编译成单个静态二进制,FROM scratch 级别镜像 |
| JUnit + Mockito | testing + table-driven + httptest,接口用假实现代替 mock |
| Spring WebSocket / STOMP | gorilla/websocket,连接、心跳、读写泵全部自己管 |
终点长什么样
先看清目标,再往回拆。下面是毕业时的目录、运行链路和协议。
目录结构
hd2d-server/ ├── go.mod ├── cmd/ │ ├── server/main.go # 装配:config → ws → world → persist │ └── bot/main.go # 联机验证 / 压测客户端 ├── internal/ │ ├── config/config.go # env → Config │ ├── protocol/protocol.go # 帧结构 + JSON 编解码 │ ├── netws/ # 升级、Client、读写泵 │ ├── game/ │ │ ├── world.go # 房间注册表 │ │ ├── room.go # Actor:tick + 命令队列 │ │ ├── entity.go # 实体与组件 │ │ ├── map.go # 三层地图 + 碰撞 + 台阶 │ │ ├── path.go # A* 寻路 │ │ ├── combat.go # 技能 / 冷却 / LOS / 伤害 │ │ ├── aoi.go # 九宫格可见集合 │ │ └── snapshot.go # 快照与事件过滤 │ ├── persist/store.go # SQLite 存档 │ └── observability/ # slog + /metrics ├── testdata/maps/lobby.json ├── Dockerfile └── README.md
运行时链路
协议 v1(JSON 文本帧)
| 方向 | t | 字段 | 语义 |
|---|---|---|---|
| C → S | login | token? | 首次为空;带 token 则恢复存档 / 重连 |
| C → S | join | room | 进入房间(教学版固定 lobby) |
| C → S | input | seq, dt, mx, my | 移动意图;dt 上限 100ms,seq 必须递增 |
| C → S | cast | cast, target | 技能 id + 目标实体 id,服务端做全部校验 |
| S → C | welcome | pid, token, tickRate | 握手成功,客户端保存 token 用于重连 |
| S → C | snapshot | tick, entities[], events[] | AOI 内的实体与事件,10Hz |
| S → C | error | code, msg | 非法帧 / 未登录 / 房间不存在 |
硬约束:单帧上限 4KB;input 频率上限 30Hz;未知 t 返回 error;服务端永远不相信客户端坐标与时间。
技术选型(全页统一,不另选)
| 项 | 选择 | 原因 |
|---|---|---|
| Go 版本 | 1.22+ | 方法路由、log/slog、signal.NotifyContext 都是 1.21/1.22 起稳定 |
| WebSocket | github.com/gorilla/websocket v1.5.3 | 生态最稳、API 直白,适合看清“读写泵”到底在做什么 |
| 持久化 | modernc.org/sqlite | 纯 Go、免 cgo,单二进制可直接跑 |
| tick / 快照 | 20Hz / 10Hz | 动作游戏常见下限;先保证稳定,再谈提速 |
| AOI | 每格 16 tile,兴趣域 3×3 | 实现简单、可解释;后续换四叉树只替换一个函数 |
| ID / token | crypto/rand | 不引 uuid 依赖,32 字节 hex 足够 |
| 指标 | 手写 /metrics | 不引 Prometheus 客户端,先理解指标怎么来 |
Stage 00 · 环境与心智迁移把 JVM 那条路换成 Go 这条路
目标:装好 Go、建好模块、跑通 build / run / test 三件套,并接受“没有容器、没有 JVM”的新世界。
这一章不写游戏代码,只把工具链跑顺。后面所有章节都假设你随时能执行 go run ./cmd/...;
工具链不熟,后面每一步都会卡在“这到底怎么跑起来”。
必须建立的四点认知
- 没有 JVM:编译产物是目标平台的机器码,单文件可拷走;跨平台要交叉编译(
GOOS/GOARCH)。 - 没有容器:没有 Bean 生命周期、没有包扫描、没有 AOP 代理。依赖谁,就在
main()里 new 出来传进去。 - 模块即项目:
go.mod声明模块路径与依赖版本,相当于pom.xml;但依赖是“精确到 commit/版本”的,不需要mvn install本地仓库那一套。 - 目录即包:同一目录下所有
.go文件属于同一个包;首字母大写才是导出符号,等价于 Java 的public。
装好工具链
# 方式一:winget
winget install --id GoLang.Go
# 方式二:从 https://go.dev/dl/ 下载 MSI 安装包
# 验证(应 ≥ 1.22)
go version
go env GOPATH GOMODCACHE
初始化模块与第一个程序
module hd2d
go 1.22
package main
import "fmt"
func main() {
fmt.Println("hd2d server: booting")
}
go run ./cmd/hello # 编译到临时目录并执行
go build -o bin/hello ./cmd/hello # 产出可执行文件
go test ./... # 跑全部测试(此刻还没有)
# 交叉编译一个 Linux 版本(部署章节会用到)
$env:GOOS="linux"; $env:GOARCH="amd64"; go build -o bin/hello-linux ./cmd/hello
验收任务
自测
Q1:为什么 Go 项目不需要像 Maven 那样先 install 依赖到本地仓库?
go env GOMODCACHE),构建时按 go.mod 解析。没有“本地仓库安装”这一步,也不需要 pom.xml 那种传递依赖调解——go mod tidy 会把直接/间接依赖都算清。Q2:`package main` 与 `package game` 有什么本质区别?
package main 是“可执行程序入口”,必须有且只有一个 func main();package game 是可被 import 的库包。一个目录一个包,包名不必等于目录名,但导入路径按目录。常见坑
- 把多个
package main放在同一目录 → 编译报重复 main;每个可执行入口要单独一个目录(cmd/server、cmd/bot)。 - 用
go run main.go养成习惯 → 多文件项目会漏编译;统一用go run ./cmd/server这种“包路径”写法。 - 环境变量
GOOS设在当前会话里忘了清 → 后面本机运行报 “exec format error”。
Stage 01 · 语言核心结构体、接口、错误:把 Java 的类模型换掉
目标:能写出实体、组件、接口和返回 error 的方法,并理解 Go 的值语义与组合式“继承”。
游戏服务端 80% 的代码是“数据 + 规则”。Go 没有 class、没有继承、没有异常,但有 struct、方法、接口和 error。
这一章写出来的 Entity / HealthComp 会一直用到毕业。
知识点
- 零值可用:
int=0、string=""、bool=false、指针/map/slice 为 nil;设计类型时优先让零值有意义。 - 多返回值 + error:没有
throws;错误是普通值,调用方必须显式判断。 - 值语义:struct 赋值/传参是拷贝;要修改原对象用指针接收者
func (p *Player)。 - 接口是行为集合:
io.Writer那种“谁实现谁满足”,没有implements关键字,也不需要接口注册。 - 组合代替继承:嵌入字段(
struct{ HealthComp })把能力“拼”进来,而不是写父类。 - slice / map 是引用语义:slice 共享底层数组;map 写入前必须
make,读 nil map 安全、写 nil map panic。
写实体与组件
package game
import "fmt"
type EntityID string
type Vec2 struct {
X, Y float64
}
type MoveComp struct {
Speed float64 // tile / 秒
}
type HealthComp struct {
HP, MaxHP int
}
type CombatComp struct {
ReadyAt map[string]uint64 // skillID → 可再次释放的 tick
}
type Entity struct {
ID EntityID
Kind string // "player" | "npc"
Pos Vec2
Z int8
Dir int
Move MoveComp
Health HealthComp
Combat CombatComp
}
func (e *Entity) Alive() bool { return e.Health.HP > 0 }
func (e *Entity) Damage(n int) error {
if n <= 0 {
return fmt.Errorf("damage must be positive, got %d", n)
}
if !e.Alive() {
return fmt.Errorf("entity %s is already down", e.ID)
}
e.Health.HP -= n
if e.Health.HP < 0 {
e.Health.HP = 0
}
return nil
}
接口:在“使用方”定义,而不是实现方
package game
import "context"
type Save struct {
Token string
MapID string
X, Y float64
Z int8
HP int
Updated int64
}
// SaveStore 由 game 包定义;persist 包实现它。
// 这样 game 不依赖数据库,测试可以塞一个内存假实现。
type SaveStore interface {
Load(ctx context.Context, token string) (Save, bool, error)
Upsert(ctx context.Context, s Save) error
}
Java 里接口常放在“被实现模块”里;Go 更常见的做法是接口跟着使用方走:谁需要这个能力,谁定义最小接口。
这里的 SaveStore 只有两个方法,测试时实现一个内存版就够了,不需要 Mockito。
两个高频陷阱:slice 与 map
package learn
// 错误:append 后的新切片没有返回给调用方
func AddBroken(xs []int) { xs = append(xs, 99) }
// 正确:返回新切片
func AddOK(xs []int) []int { return append(xs, 99) }
// 错误:nil map 写入会 panic
func MeterBroken() {
var cooldown map[string]uint64
cooldown["slash"] = 10 // panic: assignment to entry in nil map
}
// 正确:显式初始化
func MeterOK() map[string]uint64 {
return map[string]uint64{"slash": 10}
}
读 nil map 返回零值、写 nil map 直接 panic,这条差异会在战斗冷却里反复出现:
p.Combat.ReadyAt[skillID] 这样读是安全的(得到 0),但赋值前必须保证 map 已经 make。
defer / panic / recover
// defer 在函数返回前执行,常用于解锁、关文件、计耗时。
// 注意:defer 的参数在注册时就求值。
func SafeRun(fn func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered from panic: %v", r)
}
}()
fn()
return nil
}
验收任务
自测
Q1:`func (e Entity) Damage(n int)` 与 `func (e *Entity) Damage(n int)` 有什么区别?
e.Health.HP 不会影响调用方;指针接收者改的是原对象。修改状态的方法一律用指针接收者,避免“看起来扣血了但没扣”。Q2:Go 里如何实现 Java 的“继承 + 方法覆写”?
Q3:为什么 `ReadyAt map[string]uint64` 读取时不初始化也安全?
p.Combat.ReadyAt["slash"] 得到 0,表示“从未使用”。但写操作必须 map 非 nil,因此创建玩家时 ReadyAt: map[string]uint64{} 要一并初始化。常见坑
- 以为 slice 是“纯值类型”:
a := b后改a[0]会影响b(共享底层数组)。 - 循环里
defer f.Close():函数结束才执行,量大时句柄耗尽;应包一层函数或在循环内显式关闭。 - 用
fmt.Errorf包装错误却不加%w,上层无法errors.Is/As判断。 - 结构体字段小写导致别的包访问不到——exported 规则看首字母大小写,而不是像 Java 那样按包声明。
Stage 02 · 并发模型goroutine 不是线程池,channel 才是接口
目标:用 goroutine + channel + context 写并发,能判断什么时候该加锁、什么时候根本不该共享状态。
游戏服务端天生并发:成千上万个 WebSocket 连接、每个连接一个读协程、一个写协程、每个房间一个 tick 协程。 这一章决定你后面会不会写出“偶发卡死、偶发数据竞争”的服务。
知识点
- goroutine 很便宜但不免费:初始栈几 KB,可开上万;但每个都必须能退出,否则就是泄漏。
- channel 是通信,不是共享内存:无缓冲=同步交接;有缓冲=队列。不要用 channel 模拟锁。
- 关闭规则:只由发送方关闭;关闭后仍可读出剩余值,读尽后得到零值 + false;向已关闭 channel 发送会 panic。
- select 多路等待:配合
ctx.Done()是标准的“可取消等待”。 - context 只做三件事:传递取消、截止时间、请求域值;不要拿它传业务参数。
- sync 包:
Mutex/RWMutex/WaitGroup/Once;但本项目的世界状态不用锁。 - -race:数据竞争只能靠工具抓,
go test -race ./...必须是习惯。
最小 worker pool
package learn
import (
"context"
"sync"
"time"
)
// FanOut 启动 workers 个协程消费 jobs,结果写入返回的 channel。
// 两个关键点:所有协程都能被 ctx 取消;输出 channel 由唯一的收尾协程关闭。
func FanOut(ctx context.Context, jobs <-chan int, workers int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
select {
case out <- j * j:
case <-ctx.Done():
return
}
}
}
}()
}
go func() {
wg.Wait()
close(out) // 只有发送方能关;这里只有一个收尾协程,安全
}()
return out
}
可取消等待
func WaitMsg(ctx context.Context, ch <-chan string) (string, error) {
select {
case s, ok := <-ch:
if !ok {
return "", nil
}
return s, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
// 超时只是带 deadline 的 context
func WaitWithTimeout(ch <-chan string, d time.Duration) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel()
return WaitMsg(ctx, ch)
}
锁只保护“必须共享”的东西
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() int {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
return c.n
}
注意:这个锁只保护一个计数器。到第 05 章你会发现,房间里的几万个实体不需要锁——因为只有 tick 协程碰它们, 网络协程通过 channel 投递命令。锁不是默认答案,“谁拥有状态”才是。
验收任务
自测
Q1:向已关闭的 channel 发送、从已关闭的 channel 接收,分别会怎样?
false,不会阻塞。因此“关闭”是广播“不会再有新值”的信号,适合用 for v := range ch 收尾。Q2:为什么 `FanOut` 里关闭 out 的协程要单独起一个?
wg.Wait() 在第三个协程里等待,等全部 worker 退出后再 close(out),接收方才能安全地 range 到结束。若某个 worker 自己 close,会与其他 worker 的发送竞争并 panic。Q3:goroutine 泄漏最常见的两个原因?
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 看堆栈。常见坑
- 用无缓冲 channel 做“回调”,发送方和接收方同时等对方 → 死锁,运行时报
all goroutines are asleep。 - 用 channel 传递大 struct(每个 tick 几百个实体)→ 拷贝开销大;传 ID 或指针,并明确所有权。
- 在多个 goroutine 里读写同一个 map → 运行时直接 fatal(concurrent map writes),
-race也能抓到。 - 把
context.Background()一路传到底,取消信号永远传不到下游。
Stage 03 · net/http没有 @RestController,路由和中间件自己串
目标:用标准库搭出一个能启动、能记日志、能优雅退出的 HTTP 服务,作为 WebSocket 的宿主。
游戏服务端不是“只有长连接”:健康检查、指标、后台管理、WebSocket 升级入口都走 HTTP。 这一章把 Spring MVC 的那套能力,用标准库重写一遍。
知识点
- Go 1.22 方法路由:
mux.HandleFunc("GET /healthz", ...),模式里带方法和路径参数{id}。 - Handler 就是函数:
func(w http.ResponseWriter, r *http.Request);中间件是func(http.Handler) http.Handler。 - JSON:
json.NewDecoder(r.Body).Decode(&in)+ struct tag;出参先写 Header 再 WriteHeader。 - 配置:环境变量读取 + 代码内默认值;不需要
application.yml和配置中心客户端。 - 优雅退出:
signal.NotifyContext接 SIGINT/SIGTERM,srv.Shutdown(ctx)等在途请求结束。 - 限流入口:
http.MaxBytesReader限制请求体;WebSocket 另有 ReadLimit。
配置:env + 默认值
package config
import "os"
type Config struct {
Addr string
DBPath string
}
func Load() Config {
return Config{
Addr: getenv("HD2D_ADDR", ":8080"),
DBPath: getenv("HD2D_DB", "hd2d.db"),
}
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
HTTP 入口 + 中间件 + 优雅退出
package main
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"hd2d/internal/config"
)
type errorBody struct {
Code string `json:"code"`
Msg string `json:"msg"`
}
func main() {
cfg := config.Load()
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("POST /api/echo", func(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
}
body := http.MaxBytesReader(w, r.Body, 4096)
if err := json.NewDecoder(body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody{Code: "bad_json", Msg: err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"hello": in.Name})
})
srv := &http.Server{
Addr: cfg.Addr,
Handler: withLogging(log, mux),
ReadHeaderTimeout: 5 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
log.Info("http listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("listen failed", "err", err)
stop()
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Error("shutdown", "err", err)
}
log.Info("bye")
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func withLogging(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Info("http", "method", r.Method, "path", r.URL.Path,
"cost_ms", time.Since(start).Milliseconds())
})
}
验收任务
自测
Q1:`mux.HandleFunc("GET /healthz", ...)` 与老写法 `mux.HandleFunc("/healthz", ...)` 差别在哪?
{id} 并用 r.PathValue("id") 取值。老写法所有方法都进同一 handler,需要自己判断 r.Method。Q2:为什么中间件签名是 `func(http.Handler) http.Handler` 而不是“拦截器对象”?
Q3:`srv.Shutdown` 和直接 `srv.Close` 有什么区别?
Shutdown 先停止接收新连接,再等待在途请求结束(受传入 ctx 的超时约束);Close 立即关闭所有连接,可能中断正在写回的响应。优雅重启用 Shutdown。常见坑
- 忘记
defer stop(),信号监听未释放;单测里也会泄漏。 - 在 handler 里直接
w.WriteHeader之后又写 Header,Header 已冻结、改不动。 - 把
ListenAndServe放在主协程且不做错误处理,端口占用时进程静默退出。 - 用
http.DefaultServeMux注册路由,第三方库偷偷注册/debug端点,生产暴露。
Stage 04 · WebSocket一个读泵、一个写泵、一条心跳线
目标:把 HTTP 请求升级成长连接,做到“单读单写”、心跳保活、慢客户端可踢,并定下协议帧。
实时联机不靠 HTTP 轮询。WebSocket 是长连接:一端随时可能发数据,另一端也可能断。 这一章把“连接生命周期”管住,后面 tick、房间、AOI 才能安心地假设“连接是可靠的”。
知识点
- gorilla/websocket 的一条铁律:一个连接同一时刻只允许一个写者、一个读者;所以拆成 readPump 与 writePump 两个协程。
- 写操作只能来自 writePump:业务代码只往
Sendchannel 投递,不直接conn.Write。 - 心跳:服务端定时发 ping,客户端回 pong;
SetPongHandler里刷新读 deadline,超时即断开。 - 背压:
Send是有限缓冲;满了说明客户端消费不动,直接判“慢客户端”踢掉,绝不阻塞 tick。 - 帧约束:
SetReadLimit(4096)防大帧打爆内存;未知类型返回 error 帧而不是 panic。 - Origin 校验:教学环境可放开,生产必须按白名单校验,防跨站劫持。
协议帧:只有一个 struct
package protocol
import (
"encoding/json"
"errors"
)
type ClientMsg struct {
T string `json:"t"`
Seq int64 `json:"seq,omitempty"`
DT int `json:"dt,omitempty"`
MX float64 `json:"mx,omitempty"`
MY float64 `json:"my,omitempty"`
Cast string `json:"cast,omitempty"`
Target string `json:"target,omitempty"`
}
type EntityDTO struct {
ID string `json:"id"`
Kind string `json:"kind"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z int `json:"z"`
HP int `json:"hp"`
Dir int `json:"dir"`
}
type EventDTO struct {
Type string `json:"type"`
Src string `json:"src,omitempty"`
Dst string `json:"dst,omitempty"`
Amount int `json:"amount,omitempty"`
X float64 `json:"x,omitempty"`
Y float64 `json:"y,omitempty"`
}
type ServerMsg struct {
T string `json:"t"`
PID string `json:"pid,omitempty"`
Token string `json:"token,omitempty"`
TickRate int `json:"tickRate,omitempty"`
Tick uint64 `json:"tick,omitempty"`
Entities []EntityDTO `json:"entities,omitempty"`
Events []EventDTO `json:"events,omitempty"`
Code string `json:"code,omitempty"`
Msg string `json:"msg,omitempty"`
}
var ErrEmptyType = errors.New("empty message type")
func DecodeClient(b []byte) (ClientMsg, error) {
var m ClientMsg
if err := json.Unmarshal(b, &m); err != nil {
return ClientMsg{}, err
}
if m.T == "" {
return ClientMsg{}, ErrEmptyType
}
return m, nil
}
func Encode(v any) ([]byte, error) { return json.Marshal(v) }
连接:读写泵 + 心跳
package netws
import (
"sync"
"time"
"github.com/gorilla/websocket"
"hd2d/internal/protocol"
)
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = 54 * time.Second
readLimit = 4096
)
type Client struct {
conn *websocket.Conn
send chan []byte
id string
closed chan struct{}
closeOnce sync.Once
}
func NewClient(conn *websocket.Conn, id string) *Client {
return &Client{
conn: conn,
send: make(chan []byte, 64), // 缓冲 64 帧,够 3 秒左右
id: id,
closed: make(chan struct{}),
}
}
func (c *Client) ID() string { return c.id }
// SendMessage 非阻塞:满了返回 false,调用方据此判定慢客户端。
func (c *Client) SendMessage(b []byte) bool {
select {
case c.send <- b:
return true
case <-c.closed:
return false
default:
return false
}
}
func (c *Client) Close() {
c.closeOnce.Do(func() {
close(c.closed)
_ = c.conn.Close()
})
}
// ReadPump 是唯一的读者;onMsg 只做解析后的分发,不能在这里写连接。
func (c *Client) ReadPump(onMsg func(protocol.ClientMsg) error) {
defer c.Close()
c.conn.SetReadLimit(readLimit)
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
return c.conn.SetReadDeadline(time.Now().Add(pongWait))
})
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
return // 断开、超时、协议错误都会走到这里
}
msg, err := protocol.DecodeClient(data)
if err != nil {
out, _ := protocol.Encode(protocol.ServerMsg{T: "error", Code: "bad_frame", Msg: err.Error()})
c.SendMessage(out)
continue
}
if err := onMsg(msg); err != nil {
return
}
}
}
// WritePump 是唯一的写者;业务永远不直接写连接。
func (c *Client) WritePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.Close()
}()
for {
select {
case msg, ok := <-c.send:
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ticker.C:
if err := c.conn.WriteControl(websocket.PingMessage, nil,
time.Now().Add(writeWait)); err != nil {
return
}
case <-c.closed:
return
}
}
}
升级入口
package netws
import (
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
// 教学环境放开;生产必须按 Origin 白名单校验
return true
},
}
func ServeWS(w http.ResponseWriter, r *http.Request, log *slog.Logger, onConn func(*Client)) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Warn("ws upgrade failed", "err", err)
return
}
c := NewClient(conn, newID())
onConn(c)
}
func newID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "anon"
}
return hex.EncodeToString(b)
}
go c.WritePump(),再
c.SendMessage(...) 发 welcome,最后在 c.ReadPump(...) 里接收 input 并回显。
真正的房间接线放在第 05 章。
验收任务
自测
Q1:为什么不能让 tick 协程直接调用 `conn.WriteMessage`?
Q2:ping/pong 到底解决了什么问题?
Q3:`Send` 缓冲设成 64,满了就踢人,会不会误杀正常玩家?
常见坑
- 在多个地方
conn.WriteJSON(包括日志、错误提示)→ 并发写触发 panic 或帧错乱。 - 忘记设置读 deadline → 死连接永远占着 goroutine,玩家数越跑越“虚高”。
- 升级成功后还去写 HTTP 响应 → 连接已被接管,写不出去或破坏协议。
- 把
CheckOrigin直接写true就上线 → 任意站点可劫持连接(教学代码必须标注风险)。
Stage 05 · tick 与房间房间是一个 Actor:状态只归 tick 协程所有
目标:写出 20Hz 固定步长循环、命令队列、超时告警和优雅停机,让“网络”与“世界”彻底解耦。
游戏世界不能“来一个请求就改一次状态”。固定步长让移动、冷却、AI、同步都有一致的时间基准; 单协程拥有状态则让所有并发问题变成“怎么把命令安全送进来”,而不是“怎么加锁”。
知识点
- 固定步长:
time.NewTicker(50ms),每 tick 用固定 dt 推进,不用真实时间差做物理(避免抖动与作弊)。 - Actor 模型:实体 map 只允许 tick 协程读写;外部通过
cmds/join/leave三个 channel 通信。 - 非阻塞入口:网络协程用
select ... default投递;队列满就丢弃并计数,绝不阻塞读泵。 - 每 tick 预算:50ms 周期里业务应控制在 5ms 内;超了就 Warn(第 10 章再加指标)。
- 停机顺序:ctx 取消 → tick 循环退出 → 落盘 → 房间 goroutine 结束 → 主进程退出。
房间 Actor
package game
import (
"context"
"log/slog"
"time"
"hd2d/internal/protocol"
)
const (
TickRate = 20
TickInterval = time.Second / TickRate
TickBudget = 5 * time.Millisecond
SnapshotEvery = 2 // 每 2 tick 发一次快照 = 10Hz
)
// Session 由网络层实现;game 只依赖这个最小接口,避免反向依赖 netws。
type Session interface {
SendMessage([]byte) bool
Close()
}
type Command struct {
Player EntityID
Msg protocol.ClientMsg
}
type joinReq struct {
Token string
Sess Session
Done chan joinResult
}
type joinResult struct {
ID EntityID
Err error
}
type leaveReq struct{ ID EntityID }
type Room struct {
ID string
Map *TileMap
Store SaveStore
Log *slog.Logger
tick uint64
players map[EntityID]*Player
npcs map[EntityID]*NPC
tokens map[string]*Player
events []Event
dirty map[EntityID]bool
cmds chan Command
join chan joinReq
leave chan leaveReq
}
func NewRoom(id string, tm *TileMap, store SaveStore, log *slog.Logger) *Room {
return &Room{
ID: id,
Map: tm,
Store: store,
Log: log,
players: map[EntityID]*Player{},
npcs: map[EntityID]*NPC{},
tokens: map[string]*Player{},
dirty: map[EntityID]bool{},
cmds: make(chan Command, 256),
join: make(chan joinReq, 16),
leave: make(chan leaveReq, 16),
}
}
// Run 是房间唯一的写者。除了这个 goroutine,谁都不准碰 players。
func (r *Room) Run(ctx context.Context) {
t := time.NewTicker(TickInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
// 用全新的超时 context 落盘;原 ctx 已取消,不能再用。
saveCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
r.persistDirty(saveCtx)
cancel()
return
case <-t.C:
start := time.Now()
r.step()
if cost := time.Since(start); cost > TickBudget {
r.Log.Warn("tick over budget", "room", r.ID,
"cost_ms", cost.Milliseconds(), "tick", r.tick)
}
}
}
}
func (r *Room) step() {
r.drain()
r.tick++
for _, p := range r.players {
r.stepPlayer(p)
}
for _, n := range r.npcs {
r.stepNPC(n)
}
if r.tick%SnapshotEvery == 0 {
r.broadcast()
r.events = r.events[:0]
}
}
// drain 一次性取空入口队列;channel 关闭是 tick 协程的私事。
func (r *Room) drain() {
for {
select {
case c := <-r.cmds:
r.applyCommand(c)
case req := <-r.join:
r.addPlayer(req)
case req := <-r.leave:
r.markOffline(req.ID)
default:
return
}
}
}
命令入口:只投递,不改状态
// Submit 由读泵调用;队列满直接返回 false,由调用方决定踢人还是丢弃。
func (r *Room) Submit(id EntityID, m protocol.ClientMsg) bool {
select {
case r.cmds <- Command{Player: id, Msg: m}:
return true
default:
return false
}
}
// Join 阻塞到 tick 协程处理完(最多一个 tick,约 50ms)。
func (r *Room) Join(ctx context.Context, token string, sess Session) (EntityID, error) {
done := make(chan joinResult, 1)
req := joinReq{Token: token, Sess: sess, Done: done}
select {
case r.join <- req:
case <-ctx.Done():
return "", ctx.Err()
}
select {
case out := <-done:
return out.ID, out.Err
case <-ctx.Done():
return "", ctx.Err()
}
}
func (r *Room) Leave(id EntityID) {
select {
case r.leave <- leaveReq{ID: id}:
default: // 队列满时,连接关闭本身已会让读泵退出
}
}
func (r *Room) applyCommand(c Command) {
p := r.players[c.Player]
if p == nil || !p.Online() {
return
}
switch c.Msg.T {
case "input":
if c.Msg.Seq <= p.LastSeq {
return // 重放或乱序,直接丢
}
p.LastSeq = c.Msg.Seq
dt := c.Msg.DT
if dt < 0 {
dt = 0
}
if dt > 100 {
dt = 100 // 上限 100ms,防瞬移
}
r.move(p, c.Msg.MX, c.Msg.MY, float64(dt))
case "cast":
r.cast(p, c.Msg.Cast, EntityID(c.Msg.Target))
}
}
世界与装配
package game
import (
"context"
"log/slog"
"sync"
)
type World struct {
mu sync.RWMutex // 只保护 rooms 这张表,不保护世界状态
rooms map[string]*Room
Store SaveStore
Log *slog.Logger
}
func NewWorld(store SaveStore, log *slog.Logger) *World {
return &World{rooms: map[string]*Room{}, Store: store, Log: log}
}
func (w *World) AddRoom(r *Room) {
w.mu.Lock()
defer w.mu.Unlock()
w.rooms[r.ID] = r
}
func (w *World) Room(id string) (*Room, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
r, ok := w.rooms[id]
return r, ok
}
func (w *World) Run(ctx context.Context) {
w.mu.RLock()
rooms := make([]*Room, 0, len(w.rooms))
for _, r := range w.rooms {
rooms = append(rooms, r)
}
w.mu.RUnlock()
var wg sync.WaitGroup
for _, r := range rooms {
wg.Add(1)
go func(r *Room) {
defer wg.Done()
r.Run(ctx)
}(r)
}
<-ctx.Done()
wg.Wait()
}
先补齐实体外壳(后续章节替换占位实现)
type Point struct{ X, Y int }
type Player struct {
*Entity
Token string
Session Session
LastSeq int64
OfflineAt time.Time
}
func (p *Player) Online() bool { return p.Session != nil && p.OfflineAt.IsZero() }
type NPC struct {
*Entity
Path []Point
Wait int
}
type Event struct {
Type string
Src EntityID
Dst EntityID
Amount int
}
// 以下方法在第 06–08 章逐个替换成真实实现;本章先留空,
// 保证骨架能编译、两个客户端能连上、能看到 welcome。
func (r *Room) move(p *Player, mx, my float64, dtMS float64) {}
func (r *Room) stepPlayer(p *Player) {}
func (r *Room) stepNPC(n *NPC) {}
func (r *Room) cast(p *Player, skillID string, target EntityID) {}
func (r *Room) markOffline(id EntityID) {}
func (r *Room) persistDirty(ctx context.Context) {}
func (r *Room) broadcast() {}
加入房间:本章就要能真加入
// shortID 只取 token 前 8 位做可读 ID;客户端传来的 token 长度不可信。
func shortID(token string) string {
if len(token) > 8 {
return token[:8]
}
return token
}
// randomToken 首次连接时由服务端发放,客户端保存后用于重连。
func randomToken() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "fallback-token"
}
return hex.EncodeToString(b)
}
func (r *Room) addPlayer(req joinReq) {
token := req.Token
if token == "" {
token = randomToken()
}
sp := r.Map.Spawn()
p := &Player{
Entity: &Entity{
ID: EntityID("p-" + shortID(token)),
Kind: "player",
Pos: Vec2{X: sp.X, Y: sp.Y},
Z: r.Map.HeightAt(int(sp.X), int(sp.Y)),
Move: MoveComp{Speed: 4.0},
Health: HealthComp{HP: 100, MaxHP: 100},
Combat: CombatComp{ReadyAt: map[string]uint64{}},
},
Token: token,
Session: req.Sess,
}
r.players[p.ID] = p
r.tokens[token] = p
r.dirty[p.ID] = true
// welcome 必须由 tick 协程发(这里就是),保证与实体状态同一时序。
out, _ := protocol.Encode(protocol.ServerMsg{
T: "welcome", PID: string(p.ID), Token: token, TickRate: TickRate,
})
req.Sess.SendMessage(out)
req.Done <- joinResult{ID: p.ID}
}
math、crypto/rand、encoding/hex 等)用 goimports 自动补齐。
验收任务
自测
Q1:为什么 `drain` 要在 `step` 开头一次性清空所有命令,而不是处理一条就 sleep 一下?
Q2:`World.mu` 为什么只保护 rooms 表?
Q3:`Join` 为什么要用带缓冲的 channel + 等待结果,而不是直接改 players?
常见坑
- 用
time.Sleep(50ms)循环 → 每帧漂移累积,实际 tick 率越来越低;用 Ticker。 - 在 tick 里做网络写、数据库写、日志大对象序列化 → 单帧超预算,玩家体感卡顿。
- 用无缓冲 channel 投递命令 → 读泵阻塞,连接直接卡死;必须有缓冲 + 默认分支。
- 停机时直接
os.Exit→ 存档丢失;必须让 tick 协程看到 ctx 取消并落盘。
Stage 06 · HD2D 世界三层地图、碰撞、台阶与寻路
目标:加载一张带高度层的格网地图,服务端合法地移动玩家、拦住穿墙、限制台阶,并让 NPC 自己巡逻。
HD2D 的服务端不需要真 3D 物理,但必须有“高度”这个概念:能不能走上去、视线是否被高台挡住、技能能不能越过矮墙。 这一章用三层格网把这些问题全部变成确定性的整数判断。
地图用三层字符串表示
| 层 | 字符 | 含义 |
|---|---|---|
| ground | g / d / w | 草地 / 泥地 / 水面;只影响表现,服务端不关心 |
| block | # / . | # 不可通行;墙、柱子、水面都可以标成阻塞 |
| height | 0–3 | 该格高度层;相邻格高度差 ≤1 才能上下,超过就是悬崖 |
三层等长:每层 h 行、每行 w 个字符。用字符串而不是二维数组,是为了让生成器与人工排查都直观。
生成一张 64×48 的 lobby 地图
package main
import (
"encoding/json"
"os"
"strings"
)
type mapFile struct {
W int `json:"w"`
H int `json:"h"`
Ground []string `json:"ground"`
Block []string `json:"block"`
Height []string `json:"height"`
}
func main() {
const w, h = 64, 48
m := mapFile{W: w, H: h}
for y := 0; y < h; y++ {
var g, b, z strings.Builder
for x := 0; x < w; x++ {
// 地面花纹:4×4 棋盘交替草地 / 泥地
if (x/4+y/4)%2 == 0 {
g.WriteByte('g')
} else {
g.WriteByte('d')
}
// 碰撞:四周围墙 + 两根柱子 + 一段横墙
blocked := x == 0 || y == 0 || x == w-1 || y == h-1
if (x >= 20 && x <= 21 && y >= 8 && y <= 20) ||
(y >= 30 && y <= 31 && x >= 10 && x <= 30) {
blocked = true
}
// 出生点 7×7 保持空旷
if x >= w/2-3 && x <= w/2+3 && y >= h/2-3 && y <= h/2+3 {
blocked = false
}
if blocked {
b.WriteByte('#')
} else {
b.WriteByte('.')
}
// 高度:右下角一级台地,台地中央再高一层
ht := byte('0')
if x >= 44 && x <= 52 && y >= 28 && y <= 34 {
ht = '1'
}
if x >= 47 && x <= 49 && y >= 30 && y <= 32 {
ht = '2'
}
z.WriteByte(ht)
}
m.Ground = append(m.Ground, g.String())
m.Block = append(m.Block, b.String())
m.Height = append(m.Height, z.String())
}
// go run ./cmd/mapgen → 生成 testdata/maps/lobby.json
if err := os.MkdirAll("testdata/maps", 0o755); err != nil {
panic(err)
}
out, err := json.MarshalIndent(m, "", " ")
if err != nil {
panic(err)
}
if err := os.WriteFile("testdata/maps/lobby.json", out, 0o644); err != nil {
panic(err)
}
}
加载与判定
package game
import (
"encoding/json"
"fmt"
"math"
"os"
)
type TileMap struct {
W, H int
Ground []byte // 'g' / 'd' / 'w'
Block []bool
Height []int8
}
type mapFile struct {
W int `json:"w"`
H int `json:"h"`
Ground []string `json:"ground"`
Block []string `json:"block"`
Height []string `json:"height"`
}
func LoadMap(path string) (*TileMap, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var f mapFile
if err := json.Unmarshal(raw, &f); err != nil {
return nil, err
}
if f.W <= 0 || f.H <= 0 ||
len(f.Ground) != f.H || len(f.Block) != f.H || len(f.Height) != f.H {
return nil, fmt.Errorf("bad map %s: layer height mismatch", path)
}
m := &TileMap{
W: f.W, H: f.H,
Ground: make([]byte, f.W*f.H),
Block: make([]bool, f.W*f.H),
Height: make([]int8, f.W*f.H),
}
for y := 0; y < f.H; y++ {
if len(f.Ground[y]) != f.W || len(f.Block[y]) != f.W || len(f.Height[y]) != f.W {
return nil, fmt.Errorf("bad map %s: row %d width mismatch", path, y)
}
for x := 0; x < f.W; x++ {
i := m.idx(x, y)
m.Ground[i] = f.Ground[y][x]
m.Block[i] = f.Block[y][x] == '#'
m.Height[i] = int8(f.Height[y][x] - '0')
}
}
return m, nil
}
func (m *TileMap) idx(x, y int) int { return y*m.W + x }
func (m *TileMap) InBounds(x, y int) bool { return x >= 0 && y >= 0 && x < m.W && y < m.H }
func (m *TileMap) HeightAt(x, y int) int8 {
if !m.InBounds(x, y) {
return 0
}
return m.Height[m.idx(x, y)]
}
func (m *TileMap) Blocked(x, y int) bool {
if !m.InBounds(x, y) {
return true // 越界视为墙,省掉所有调用点的边界判断
}
return m.Block[m.idx(x, y)]
}
func (m *TileMap) Spawn() Vec2 {
return Vec2{X: float64(m.W) / 2, Y: float64(m.H) / 2}
}
// CanStand 判定“连续坐标 (x,y) 是否可站立”:目标格不阻塞,且与当前高度差 ≤1。
func (m *TileMap) CanStand(x, y float64, curZ int8) bool {
tx, ty := int(math.Floor(x)), int(math.Floor(y))
if m.Blocked(tx, ty) {
return false
}
d := m.HeightAt(tx, ty) - curZ
return d >= -1 && d <= 1
}
func absInt(n int) int {
if n < 0 {
return -n
}
return n
}
A* 寻路(NPC 用)
package game
import "container/heap"
type node struct {
p Point
g int
f int
}
type nodeHeap []*node
func (h nodeHeap) Len() int { return len(h) }
func (h nodeHeap) Less(i, j int) bool { return h[i].f < h[j].f }
func (h nodeHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *nodeHeap) Push(x any) { *h = append(*h, x.(*node)) }
func (h *nodeHeap) Pop() any {
old := *h
n := old[len(old)-1]
*h = old[:len(old)-1]
return n
}
// FindPath 4 邻接 A*;高度差超过 1 视为不可走(与 CanStand 口径一致)。
func (m *TileMap) FindPath(start, goal Point) []Point {
if m.Blocked(goal.X, goal.Y) {
return nil
}
open := &nodeHeap{}
heap.Init(open)
heap.Push(open, &node{p: start, f: absInt(start.X-goal.X) + absInt(start.Y-goal.Y)})
came := map[Point]Point{}
g := map[Point]int{start: 0}
for open.Len() > 0 {
cur := heap.Pop(open).(*node)
if cur.p == goal {
return rebuildPath(came, start, goal)
}
if cur.g > g[cur.p] {
continue // 旧条目,跳过
}
for _, d := range [4]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
np := Point{cur.p.X + d.X, cur.p.Y + d.Y}
if !m.InBounds(np.X, np.Y) || m.Blocked(np.X, np.Y) {
continue
}
if dz := m.HeightAt(np.X, np.Y) - m.HeightAt(cur.p.X, cur.p.Y); dz > 1 || dz < -1 {
continue
}
ng := cur.g + 1
if old, ok := g[np]; ok && ng >= old {
continue
}
g[np] = ng
came[np] = cur.p
heap.Push(open, &node{p: np, g: ng,
f: ng + absInt(np.X-goal.X) + absInt(np.Y-goal.Y)})
}
}
return nil
}
func rebuildPath(came map[Point]Point, start, goal Point) []Point {
var rev []Point
for p := goal; p != start; p = came[p] {
rev = append(rev, p)
}
for i, j := 0, len(rev)-1; i < j; i, j = i+1, j-1 {
rev[i], rev[j] = rev[j], rev[i]
}
return rev
}
移动、NPC 与“先全量广播”
// 轴分离移动:X 被墙挡住时仍可沿 Y 滑动,手感更接近真实游戏。
func (r *Room) move(p *Player, mx, my float64, dtMS float64) {
n := math.Hypot(mx, my)
if n == 0 {
return
}
if n > 1 {
mx, my = mx/n, my/n
}
step := p.Move.Speed * dtMS / 1000
dx, dy := mx*step, my*step
if dx != 0 && r.Map.CanStand(p.Pos.X+dx, p.Pos.Y, p.Z) {
p.Pos.X += dx
}
if dy != 0 && r.Map.CanStand(p.Pos.X, p.Pos.Y+dy, p.Z) {
p.Pos.Y += dy
}
if dx == 0 && dy == 0 {
return
}
p.Z = r.Map.HeightAt(int(p.Pos.X), int(p.Pos.Y))
p.Dir = dirOf(dx, dy)
r.dirty[p.ID] = true
}
func dirOf(dx, dy float64) int {
idx := int(math.Round(math.Atan2(dy, dx) / (math.Pi / 4)))
if idx < 0 {
idx += 8
}
return idx % 8
}
func (r *Room) stepPlayer(p *Player) {
if !p.Online() {
return
}
// 每 2 秒回 1 点血;持续效果都应该放在 tick 里,而不是请求里。
if r.tick%40 == 0 && p.Health.HP < p.Health.MaxHP {
p.Health.HP++
}
}
// NPC 巡逻:路径走完后等 2 秒,再按 tick 轮到下一个方向重新寻路。
func (r *Room) stepNPC(n *NPC) {
if n.Health.HP <= 0 {
return
}
if n.Wait > 0 {
n.Wait--
return
}
if len(n.Path) == 0 {
n.Path = r.Map.FindPath(Point{int(n.Pos.X), int(n.Pos.Y)}, r.patrolGoal(n))
if len(n.Path) == 0 {
n.Wait = 60
return
}
}
next := n.Path[0]
tx, ty := float64(next.X)+0.5, float64(next.Y)+0.5
dx, dy := tx-n.Pos.X, ty-n.Pos.Y
d := math.Hypot(dx, dy)
step := n.Move.Speed * float64(TickInterval.Milliseconds()) / 1000
if d <= step {
n.Pos = Vec2{X: tx, Y: ty}
n.Z = r.Map.HeightAt(next.X, next.Y)
n.Path = n.Path[1:]
if len(n.Path) == 0 {
n.Wait = 120
}
return
}
n.Pos.X += dx / d * step
n.Pos.Y += dy / d * step
n.Z = r.Map.HeightAt(int(n.Pos.X), int(n.Pos.Y))
}
func (r *Room) patrolGoal(n *NPC) Point {
cx, cy := int(n.Pos.X), int(n.Pos.Y)
switch int(r.tick/120) % 4 {
case 0:
return Point{cx + 6, cy}
case 1:
return Point{cx, cy + 6}
case 2:
return Point{cx - 6, cy}
default:
return Point{cx, cy - 6}
}
}
// 第 06 章先做“全量广播”,第 08 章再换成 AOI + 事件过滤。
func (r *Room) broadcast() {
for _, p := range r.players {
if p.Session == nil {
continue
}
entities := make([]protocol.EntityDTO, 0, len(r.players)+len(r.npcs))
for _, pl := range r.players {
if pl.Session != nil {
entities = append(entities, toDTO(pl.Entity))
}
}
for _, n := range r.npcs {
entities = append(entities, toDTO(n.Entity))
}
data, err := protocol.Encode(protocol.ServerMsg{
T: "snapshot", Tick: r.tick, Entities: entities,
})
if err != nil {
continue
}
if !p.Session.SendMessage(data) {
p.Session.Close()
p.Session = nil
p.OfflineAt = time.Now()
}
}
}
func toDTO(e *Entity) protocol.EntityDTO {
return protocol.EntityDTO{
ID: string(e.ID), Kind: e.Kind,
X: e.Pos.X, Y: e.Pos.Y, Z: int(e.Z),
HP: e.Health.HP, Dir: e.Dir,
}
}
验收任务
自测
Q1:为什么 `Blocked` 对越界坐标返回 true,而不是让调用方先判断?
Q2:为什么移动要分 X、Y 两次判定,而不是一次判 `(nx, ny)`?
Q3:A* 里的高度差判定为什么用“相邻格差 ≤1”,而不是“目标格高度 ≤ 当前高度+1”?
CanStand完全一致,否则会出现“寻路能走、移动被拦”的错位。常见坑
- 视线/寻路/移动三处对“高度差”的口径不一致 → 寻路绕路但走不动,或技能穿墙命中。
- 用浮点坐标直接做
[]bool下标(m.Block[int(x)])→ 负数或越界 panic;统一走Floor+InBounds。 - 把地面装饰(草/水)当碰撞依据 → 表现与规则耦合,后续换图集就全错;碰撞只认 block 层。
- NPC 在 tick 里同步寻路且地图很大 → 单帧超预算;大图要把寻路放到独立协程或用流场。
Stage 07 · 战斗判定射程、冷却、视线、伤害:全部服务端说了算
目标:实现一套“客户端只能表达意图、服务端做全部校验”的技能系统,并让结果可复现。
联机游戏的第一原则是权威服务器:客户端说“我想放技能”,不能直接说“我打中了他、扣了 18 血”。 这一章把射程、冷却、视线、伤害、死亡全部收进 tick 协程。
技能是配置,不是散落的 if
package game
import "math"
type Skill struct {
ID string
Range float64 // 格
HitRadius float64 // 目标体积补偿
Damage int
CooldownTicks uint64
LOS bool // 是否要求视线
}
var skillBook = map[string]Skill{
"slash": {ID: "slash", Range: 1.8, HitRadius: 0.6, Damage: 12, CooldownTicks: 20},
"bolt": {ID: "bolt", Range: 6.0, HitRadius: 0.8, Damage: 18, CooldownTicks: 40, LOS: true},
}
func (r *Room) cast(p *Player, skillID string, targetID EntityID) {
sk, ok := skillBook[skillID]
if !ok {
return
}
if r.tick < p.Combat.ReadyAt[skillID] { // 读 nil map 安全,得到 0
return
}
tgt := r.entity(targetID)
if tgt == nil || tgt.ID == p.ID || tgt.Health.HP <= 0 {
return
}
if math.Hypot(tgt.Pos.X-p.Pos.X, tgt.Pos.Y-p.Pos.Y) > sk.Range+sk.HitRadius {
return
}
if sk.LOS && !r.Map.HasLineOfSight(p.Pos, tgt.Pos, p.Z, tgt.Z) {
return
}
p.Combat.ReadyAt[skillID] = r.tick + sk.CooldownTicks
// 固定种子的确定性随机:同样的 tick 序列 → 同样的伤害
dmg := sk.Damage + r.rng.intn(3) - 1
if dmg < 1 {
dmg = 1
}
_ = tgt.Damage(dmg)
r.events = append(r.events, Event{Type: "hit", Src: p.ID, Dst: tgt.ID, Amount: dmg})
if tgt.Health.HP == 0 {
r.events = append(r.events, Event{Type: "down", Src: p.ID, Dst: tgt.ID})
r.respawn(tgt)
}
}
func (r *Room) entity(id EntityID) *Entity {
if p, ok := r.players[id]; ok && p.Session != nil {
return p.Entity
}
if n, ok := r.npcs[id]; ok {
return n.Entity
}
return nil
}
func (r *Room) respawn(e *Entity) {
sp := r.Map.Spawn()
e.Pos = Vec2{X: sp.X, Y: sp.Y}
e.Z = r.Map.HeightAt(int(sp.X), int(sp.Y))
e.Health.HP = e.Health.MaxHP
e.Combat.ReadyAt = map[string]uint64{}
}
注意 ReadyAt 的读写:读 nil map 安全,但写之前必须初始化。玩家/NPC 创建时都要带
Combat: CombatComp{ReadyAt: map[string]uint64{}},否则第一次放技能就 panic。
死亡处理在这里是“立即满血回出生点”,只为让 bot 能连续测试;生产项目应改成 复活倒计时 + 无敌保护,并把 down / respawn 作为两个独立事件。
高度遮挡下的视线判定
// HasLineOfSight 沿 Bresenham 直线检查:中间格不能是墙,也不能高过两端较高点 +1。
func (m *TileMap) HasLineOfSight(a, b Vec2, az, bz int8) bool {
x0, y0 := int(a.X), int(a.Y)
x1, y1 := int(b.X), int(b.Y)
dx := absInt(x1 - x0)
dy := -absInt(y1 - y0)
sx, sy := 1, 1
if x0 > x1 {
sx = -1
}
if y0 > y1 {
sy = -1
}
base := az
if bz > base {
base = bz
}
err := dx + dy
for x0 != x1 || y0 != y1 {
e2 := 2 * err
if e2 >= dy {
err += dy
x0 += sx
}
if e2 <= dx {
err += dx
y0 += sy
}
if m.Blocked(x0, y0) {
return false
}
if m.HeightAt(x0, y0) > base+1 {
return false
}
}
return true
}
// 确定性随机:xorshift64,种子固定即可完整重放。
type rng struct{ s uint64 }
func newRNG(seed uint64) *rng {
if seed == 0 {
seed = 0x9e3779b97f4a7c15
}
return &rng{s: seed}
}
func (z *rng) next() uint64 {
z.s ^= z.s << 13
z.s ^= z.s >> 7
z.s ^= z.s << 17
return z.s
}
func (z *rng) intn(n int) int {
if n <= 0 {
return 0
}
return int(z.next() % uint64(n))
}
type Room struct {
// ... 第 05 章字段保持不变
rng *rng // 新增:房间级确定性随机
}
func NewRoom(id string, tm *TileMap, store SaveStore, log *slog.Logger) *Room {
return &Room{
// ... 第 05 章的初始化保持不变
rng: newRNG(1),
}
}
验收任务
自测
Q1:为什么客户端不能直接发“扣 18 血”的结果?
Q2:为什么伤害随机必须用房间级固定种子,而不是 `rand.Intn`?
Q3:AoE 技能遍历 `map[EntityID]*Entity` 结算伤害,会破坏确定性吗?
常见坑
- 先写冷却再看目标 → 打空也会进冷却,玩家体验与预期不符;先校验再写状态。
- 用客户端上报的时间戳算冷却 → 改本地时间即可无限放技能;冷却只认服务端 tick。
- 把“命中”做成客户端播动画 + 服务端补伤害 → 两端表现不一致;服务端定结果,客户端只做表现。
- 伤害扣成负数 → 后续
Alive()判断、排序、统计全错;扣完必须夹到 0。
Stage 08 · 同步与 AOI只发看得见的:九宫格、事件过滤、重连
目标:把“全量广播”升级成 AOI 快照,补上重连与离线宽限,并理解增量同步的代价与收益。
一个房间 100 人时,全量广播每帧要发 100 份同样的数据;AOI 让每个玩家只收到周围 3×3 格内的实体。 带宽、序列化 CPU、客户端解包量都会随人数增长而恶化,AOI 是最直接的止血点。
AOI:格子 + 可见集合
package game
import (
"sort"
"hd2d/internal/protocol"
)
const (
AOICellSize = 16 // 每格 16×16 tile
AOIViewCells = 1 // 兴趣域 3×3 格
)
func cellOf(p Vec2) (int, int) {
return int(p.X) / AOICellSize, int(p.Y) / AOICellSize
}
func inView(a, b Vec2) bool {
ax, ay := cellOf(a)
bx, by := cellOf(b)
return absInt(ax-bx) <= AOIViewCells && absInt(ay-by) <= AOIViewCells
}
// visibleTo 返回该玩家可见的实体(含自己)。按 ID 排序,保证测试可复现。
func (r *Room) visibleTo(p *Player) []*Entity {
out := make([]*Entity, 0, 32)
out = append(out, p.Entity)
for _, pl := range r.players {
if pl.ID != p.ID && pl.Session != nil && inView(p.Pos, pl.Pos) {
out = append(out, pl.Entity)
}
}
for _, n := range r.npcs {
if inView(p.Pos, n.Pos) {
out = append(out, n.Entity)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (r *Room) eventsFor(p *Player, visible []*Entity) []protocol.EventDTO {
if len(r.events) == 0 {
return nil
}
set := map[EntityID]bool{p.ID: true}
for _, e := range visible {
set[e.ID] = true
}
out := make([]protocol.EventDTO, 0, len(r.events))
for _, ev := range r.events {
if !set[ev.Src] && !set[ev.Dst] {
continue // 不相关的战斗事件不发
}
out = append(out, protocol.EventDTO{
Type: ev.Type, Src: string(ev.Src), Dst: string(ev.Dst), Amount: ev.Amount,
})
}
return out
}
快照:10Hz + AOI + 事件
func (r *Room) broadcast() {
for _, p := range r.players {
if p.Session == nil {
continue
}
visible := r.visibleTo(p)
entities := make([]protocol.EntityDTO, 0, len(visible))
for _, e := range visible {
entities = append(entities, toDTO(e))
}
data, err := protocol.Encode(protocol.ServerMsg{
T: "snapshot",
Tick: r.tick,
Entities: entities,
Events: r.eventsFor(p, visible),
})
if err != nil {
continue
}
if !p.Session.SendMessage(data) {
p.Session.Close()
p.Session = nil
p.OfflineAt = time.Now()
r.dirty[p.ID] = true
}
}
}
10Hz 是 20Hz tick 的一半:逻辑每 50ms 推进一次,但网络只每 100ms 发一次。移动类游戏常见的折中是 “位置插值在客户端做,服务端只保证权威状态”。先保证不炸,再谈提高频率。
加入、重连、离线宽限与落盘
package game
import (
"context"
"time"
)
const OfflineGrace = 30 * time.Second
// addPlayer 替换第 05 章版本:新增“同 token 重连”与“从存档恢复”。
func (r *Room) addPlayer(req joinReq) {
// 1) 房间里还有同 token 的离线玩家 → 直接复用,这就是重连
if req.Token != "" {
if p, ok := r.tokens[req.Token]; ok {
p.Session = req.Sess
p.OfflineAt = time.Time{}
r.dirty[p.ID] = true
req.Sess.SendMessage(welcomeMsg(p, p.Token))
req.Done <- joinResult{ID: p.ID}
r.Log.Info("player reconnected", "id", p.ID)
return
}
}
// 2) 新玩家:生成 token,尝试从存档恢复
token := req.Token
if token == "" {
token = randomToken()
}
sp := r.Map.Spawn()
sv := Save{
Token: token, MapID: r.ID,
X: sp.X, Y: sp.Y, Z: r.Map.HeightAt(int(sp.X), int(sp.Y)), HP: 100,
}
// 读档是同步 IO:教学版简化放在 tick 里;生产应在 Join 之前由网络协程读档,
// 再把 Save 作为 joinReq 的一部分传进来,避免 DB 抖动拖慢整帧。
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if loaded, ok, err := r.Store.Load(ctx, token); err == nil && ok && loaded.MapID == r.ID {
sv = loaded
}
p := &Player{
Entity: &Entity{
ID: EntityID("p-" + shortID(token)),
Kind: "player",
Pos: Vec2{X: sv.X, Y: sv.Y},
Z: sv.Z,
Move: MoveComp{Speed: 4.0},
Health: HealthComp{HP: sv.HP, MaxHP: 100},
Combat: CombatComp{ReadyAt: map[string]uint64{}},
},
Token: token,
Session: req.Sess,
}
r.players[p.ID] = p
r.tokens[token] = p
r.dirty[p.ID] = true
req.Sess.SendMessage(welcomeMsg(p, token))
req.Done <- joinResult{ID: p.ID}
}
// welcomeMsg 统一 welcome 帧格式,新玩家与重连都复用。
func welcomeMsg(p *Player, token string) []byte {
out, _ := protocol.Encode(protocol.ServerMsg{
T: "welcome", PID: string(p.ID), Token: token, TickRate: TickRate,
})
return out
}
func (r *Room) markOffline(id EntityID) {
p, ok := r.players[id]
if !ok || p.Session == nil {
return
}
p.Session = nil
p.OfflineAt = time.Now()
r.dirty[id] = true
}
// 在 step 末尾调用:断线超过宽限期才真正移除并落盘。
func (r *Room) expireOffline() {
now := time.Now()
for id, p := range r.players {
if p.Session != nil || p.OfflineAt.IsZero() || now.Sub(p.OfflineAt) < OfflineGrace {
continue
}
r.persistPlayer(context.Background(), p)
delete(r.players, id)
delete(r.tokens, p.Token)
delete(r.dirty, id)
r.Log.Info("player expired", "id", id)
}
}
func (r *Room) persistPlayer(ctx context.Context, p *Player) {
err := r.Store.Upsert(ctx, Save{
Token: p.Token, MapID: r.ID,
X: p.Pos.X, Y: p.Pos.Y, Z: p.Z, HP: p.Health.HP,
Updated: time.Now().Unix(),
})
if err != nil {
r.Log.Error("save player", "id", p.ID, "err", err)
}
}
func (r *Room) persistDirty(ctx context.Context) {
for id := range r.dirty {
p, ok := r.players[id]
if !ok {
delete(r.dirty, id)
continue
}
r.persistPlayer(ctx, p)
delete(r.dirty, id)
}
}
func (r *Room) step() {
r.drain()
r.tick++
// ... 原有移动 / NPC 逻辑
r.expireOffline()
if r.tick%600 == 0 { // 每 30 秒落一次脏数据
r.persistDirty(context.Background())
}
// ... 原有的快照广播
}
增量同步:先算账,再动手
全量 AOI 快照已经“正确”了,但每个实体每帧都重发。增量快照只需要发“新出现的、变化的、离开的”:
- 新出现:实体进入 AOI → 发完整 DTO。
- 变化的:位置/血量/朝向与上次不同 → 只发变化字段(或整条 DTO,取决于复杂度)。
- 离开的:上次可见、这次不可见 → 发
removed: ["p-xxx"]。
type ServerMsg struct {
// ... 原有字段
Removed []string `json:"removed,omitempty"` // 本次离开 AOI 的实体
}
每个 Session 维护 lastSeen map[string]EntityDTO:先算本次可见集合,再与 lastSeen 做差。
注意这会引入“每连接一份快照缓存”,内存与复杂度上升;先量化全量快照的带宽与 CPU,再决定是否值得。
验收任务
自测
Q1:AOI 用 16×16 格、兴趣域 3×3,在 64×48 地图上大概能看到多少?
Q2:为什么“离开视野”必须显式 `removed`,而不能只靠客户端超时?
Q3:重连为什么可以直接复用房间里的离线 Player,而不是重新创建?
常见坑
- 在 tick 里同步写 SQLite → 磁盘抖动直接造成 tick 超预算;生产应丢给独立持久化协程。
- 重连后没有清理旧 Session → 同一玩家两个连接同时发输入,状态被双写。
- AOI 只按格子判断,忽略实体体积 → 大体积 Boss 半个身子进视野却不可见;需要按半径扩张。
- 快照里带 map 遍历顺序 → 同一状态两次序列化字节不同,测试与回放都不稳定;先排序。
Stage 09 · 持久化纯 Go SQLite:没有 JPA,SQL 自己写
目标:把玩家位置与血量落进 SQLite,重启后能恢复;理解“存档不是每帧写,而是脏了才写”。
游戏服务端的存档频率必须克制:每 tick 写库会把磁盘 IO 变成 tick 的尾延迟。 这一章用“脏标记 + 定时落盘 + 停机落盘”的组合,在正确性与性能之间取一个简单解。
建库:驱动与 schema
package persist
import (
"context"
"database/sql"
_ "modernc.org/sqlite" // 纯 Go 驱动,注册驱动名 "sqlite"
"hd2d/internal/game"
)
type Store struct{ db *sql.DB }
const schema = `
CREATE TABLE IF NOT EXISTS players (
token TEXT PRIMARY KEY,
map_id TEXT NOT NULL,
x REAL NOT NULL,
y REAL NOT NULL,
z INTEGER NOT NULL,
hp INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);`
func Open(path string) (*Store, error) {
dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// SQLite 同一时刻只允许一个写者;教学场景直接串行化,避免 SQLITE_BUSY。
db.SetMaxOpenConns(1)
if _, err := db.Exec(schema); err != nil {
_ = db.Close()
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) Load(ctx context.Context, token string) (game.Save, bool, error) {
var sv game.Save
err := s.db.QueryRowContext(ctx,
`SELECT map_id, x, y, z, hp, updated_at FROM players WHERE token = ?`, token,
).Scan(&sv.MapID, &sv.X, &sv.Y, &sv.Z, &sv.HP, &sv.Updated)
if err == sql.ErrNoRows {
return game.Save{}, false, nil
}
if err != nil {
return game.Save{}, false, err
}
sv.Token = token
return sv, true, nil
}
func (s *Store) Upsert(ctx context.Context, sv game.Save) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO players (token, map_id, x, y, z, hp, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(token) DO UPDATE SET
map_id = excluded.map_id,
x = excluded.x,
y = excluded.y,
z = excluded.z,
hp = excluded.hp,
updated_at = excluded.updated_at`,
sv.Token, sv.MapID, sv.X, sv.Y, sv.Z, sv.HP, sv.Updated)
return err
}
三个细节:驱动名是 sqlite(不是 sqlite3);DSN 用 _pragma 设置 WAL 与 busy_timeout;
SetMaxOpenConns(1) 让所有写串行,避免并发写导致的锁错误。真实高并发场景应换 PostgreSQL。
装配:把 Store 注入 World 与 Room
// 这段装配放在第 03 章 main 的 ctx/stop 创建之后;worldDone 供停机收尾使用。
store, err := persist.Open(cfg.DBPath)
if err != nil {
log.Error("open store", "err", err)
os.Exit(1)
}
defer store.Close()
tm, err := game.LoadMap("testdata/maps/lobby.json")
if err != nil {
log.Error("load map", "err", err)
os.Exit(1)
}
world := game.NewWorld(store, log)
room := game.NewRoom("lobby", tm, store, log)
room.AddNPC(game.NewNPC("npc-1", game.Vec2{X: 30, Y: 20}, 0))
room.AddNPC(game.NewNPC("npc-2", game.Vec2{X: 44, Y: 28}, 1))
world.AddRoom(room)
// worldDone 用于停机时等待所有房间落盘完成
worldDone := make(chan struct{})
go func() {
defer close(worldDone)
world.Run(ctx)
}()
mux.HandleFunc("GET /ws", func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token") // 首次为空;重连带上次 welcome 返回的 token
netws.ServeWS(w, r, log, func(c *netws.Client) {
go c.WritePump()
id, err := room.Join(r.Context(), token, c)
if err != nil {
log.Warn("join failed", "err", err)
c.Close()
return
}
c.ReadPump(func(m protocol.ClientMsg) error {
if !room.Submit(id, m) {
// 队列满:先丢弃并记日志;第 10 章把它换成 InputsDropped.Add(1)
log.Warn("input dropped", "player", id)
}
return nil
})
room.Leave(id)
c.Close()
})
})
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx) // 1. 停止接收新连接,等在途请求结束
<-worldDone // 2. 等所有房间 tick 退出并完成落盘
log.Info("bye") // 3. 最后才退出进程
addPlayer 里(由 tick 协程发出);
第 08 章的重连分支复用同一个 welcomeMsg,所以主入口只需要 Join + ReadPump,不再自己组装 welcome。
func NewNPC(id EntityID, at Vec2, z int8) *NPC {
return &NPC{Entity: &Entity{
ID: id, Kind: "npc",
Pos: at,
Z: z,
Move: MoveComp{Speed: 1.5},
Health: HealthComp{HP: 60, MaxHP: 60},
Combat: CombatComp{ReadyAt: map[string]uint64{}},
}}
}
// AddNPC 只能在世界启动前调用;启动后请通过 join 命令扩展。
func (r *Room) AddNPC(n *NPC) { r.npcs[n.ID] = n }
验收任务
自测
Q1:为什么 `Load` 找不到记录不返回 error?
bool 返回是否存在,调用方据此决定用出生点还是读档。把业务状态与错误混在一起,会让上层被迫解析错误字符串。Q2:为什么存档写库不能放在移动命令里?
Q3:SQLite 的 `SetMaxOpenConns(1)` 会不会成为瓶颈?
常见坑
- 写
sql.Open("sqlite3", ...)→ 驱动未注册,报 unknown driver;modernc 的驱动名是sqlite。 - 在循环里逐条 Upsert 且不包事务 → 每条一次 fsync,慢到怀疑人生;用事务批量提交。
- 只存 token 不存地图 ID → 玩家读档后出现在错误房间/坐标越界。
- 停服时不落盘 → 最后一个 30 秒的进度全丢;
ctx.Done()分支必须调用persistDirty。
Stage 10 · 测试与观测先能证明它对,再谈它能扛多少
目标:用 table-driven 测试、-race、pprof、手写指标和 Go bot,把服务端的正确性与容量变成可量化数字。
游戏服务端的 bug 大多是“偶发”的:并发写、边界高度、顺序依赖。没有 -race、没有 bot、没有指标, 你只能靠玩家反馈猜。这一章把猜测换成证据。
table-driven:一条用例一个场景
package game
import "testing"
func testMap() *TileMap {
m := &TileMap{
W: 8, H: 8,
Ground: make([]byte, 64),
Block: make([]bool, 64),
Height: make([]int8, 64),
}
m.Block[m.idx(2, 1)] = true // 一堵墙
m.Height[m.idx(1, 2)] = 2 // 一格高台
return m
}
func TestCanStand(t *testing.T) {
m := testMap()
cases := []struct {
name string
x, y float64
z int8
want bool
}{
{"平地可站", 0.2, 0.2, 0, true},
{"墙不可站", 2.2, 1.2, 0, false},
{"台阶太高", 1.2, 2.2, 0, false},
{"下台阶允许", 1.2, 2.2, 3, true},
{"越界不可站", 9.0, 9.0, 0, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := m.CanStand(tc.x, tc.y, tc.z); got != tc.want {
t.Fatalf("CanStand(%v,%v,%d)=%v, want %v", tc.x, tc.y, tc.z, got, tc.want)
}
})
}
}
房间级集成测试:不启网络也能验证快照
package game
import (
"context"
"encoding/json"
"log/slog"
"testing"
"time"
"hd2d/internal/protocol"
)
type fakeSession struct{ send chan []byte }
func (f *fakeSession) SendMessage(b []byte) bool {
select {
case f.send <- b:
return true
default:
return false
}
}
func (f *fakeSession) Close() {}
type memStore struct{}
func (memStore) Load(context.Context, string) (Save, bool, error) { return Save{}, false, nil }
func (memStore) Upsert(context.Context, Save) error { return nil }
func TestJoinReceivesSnapshot(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
room := NewRoom("test", testMap(), memStore{}, slog.Default())
go room.Run(ctx)
sess := &fakeSession{send: make(chan []byte, 16)}
id, err := room.Join(ctx, "", sess)
if err != nil || id == "" {
t.Fatalf("join: id=%q err=%v", id, err)
}
// welcome 之后才会有 snapshot,所以要循环读到快照帧为止。
deadline := time.After(300 * time.Millisecond)
for {
select {
case raw := <-sess.send:
var m protocol.ServerMsg
if err := json.Unmarshal(raw, &m); err != nil {
continue
}
if m.T == "snapshot" && len(m.Entities) > 0 {
return
}
case <-deadline:
t.Fatal("no snapshot within 300ms")
}
}
}
写完后必须做一次反向验证:把 CanStand 改成恒真,确认用例变红;再还原。
只报“全绿”不算测试有效——你要能证明“改坏会红”。
-race、pprof 与手写指标
package observability
import (
"fmt"
"net/http"
"sync/atomic"
)
var (
TicksTotal atomic.Uint64
TickOverruns atomic.Uint64
InputsDropped atomic.Uint64
PlayersOnline atomic.Int64
)
func Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
fmt.Fprintf(w, "# TYPE hd2d_ticks_total counter\nhd2d_ticks_total %d\n", TicksTotal.Load())
fmt.Fprintf(w, "# TYPE hd2d_tick_overruns_total counter\nhd2d_tick_overruns_total %d\n", TickOverruns.Load())
fmt.Fprintf(w, "# TYPE hd2d_inputs_dropped_total counter\nhd2d_inputs_dropped_total %d\n", InputsDropped.Load())
fmt.Fprintf(w, "# TYPE hd2d_players_online gauge\nhd2d_players_online %d\n", PlayersOnline.Load())
}
}
mux.HandleFunc("GET /metrics", observability.Handler());
再在 tick 超预算处 TickOverruns.Add(1)、命令队列满处 InputsDropped.Add(1)、玩家加入/移除处增减 PlayersOnline。
import "net/http/pprof"
admin := http.NewServeMux()
admin.HandleFunc("/debug/pprof/", pprof.Index)
admin.HandleFunc("/debug/pprof/profile", pprof.Profile)
admin.Handle("/debug/pprof/heap", pprof.Handler("heap"))
go func() {
if err := http.ListenAndServe("127.0.0.1:6060", admin); err != nil {
log.Warn("pprof server", "err", err)
}
}()
指标要接进 tick:超预算时 TickOverruns.Add(1),命令队列满时 InputsDropped.Add(1),
加入/移除玩家时调整 PlayersOnline。没有这三个数,压测只能看“感觉”。
bot:从“能连”到“能压”
package main
import (
"flag"
"fmt"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
"hd2d/internal/protocol"
)
func main() {
addr := flag.String("addr", "ws://127.0.0.1:8080/ws", "server ws url")
n := flag.Int("n", 2, "number of bots")
flag.Parse()
var wg sync.WaitGroup
for i := 0; i < *n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
runBot(i, *addr)
}(i)
time.Sleep(50 * time.Millisecond) // 错峰连接,避免瞬时握手风暴
}
wg.Wait()
}
func runBot(i int, addr string) {
conn, _, err := websocket.DefaultDialer.Dial(addr, nil)
if err != nil {
log.Printf("bot %d dial: %v", i, err)
return
}
defer conn.Close()
go func() {
snaps := 0
for {
var m protocol.ServerMsg
if err := conn.ReadJSON(&m); err != nil {
return
}
if m.T == "welcome" {
fmt.Printf("bot %d welcome pid=%s tickRate=%d\n", i, m.PID, m.TickRate)
}
if m.T == "snapshot" {
snaps++
if snaps%20 == 0 { // 10Hz 下每 2 秒打印一次,压测时才不会刷爆 stdout
fmt.Printf("bot %d tick=%d entities=%d\n", i, m.Tick, len(m.Entities))
}
}
}
}()
t := time.NewTicker(50 * time.Millisecond)
defer t.Stop()
var seq int64
for range t.C {
seq++
msg := protocol.ClientMsg{T: "input", Seq: seq, DT: 50, MX: float64(i % 3-1), MY: 1}
if err := conn.WriteJSON(msg); err != nil {
return
}
}
}
/metrics 的 tick 超预算次数;② 100 bot 时进程 RSS;
③ bot 收到的快照频率是否稳定在 10Hz。任何一项恶化,先回到 tick 里找同步 IO/大循环。
验收任务
自测
Q1:为什么集成测试用 fakeSession,而不是真起一个 WebSocket 服务?
Q2:`go test -race` 能发现“房间状态被两个协程写”吗?
Q3:tick 超预算应该先看什么?
常见坑
- 测试里
go room.Run(ctx)后不 cancel → 测试结束时协程泄漏,race/计数被污染。 - 用
time.Sleep等快照 → CI 上偶发失败;用 channel + 超时。 - 把 pprof 注册到业务 mux →
/debug/pprof暴露公网,等于把堆栈和内存交给攻击者。 - 压测只看“没崩” → 忽略 tick 超预算与掉线率;这两项才是容量上限的真实信号。
Stage 11 · 部署与扩容一个二进制、一次优雅重启、一条分片路线
目标:把服务端装进容器、验证 SIGTERM 不丢档,并想清楚“一间房扛不住时,怎么拆成多间”。
Go 最爽的一点是部署:没有 JVM、没有 fat jar 解压,编译产物就是一个静态二进制。 但游戏服务端的扩容不是“多开几个实例”这么简单——房间状态、AOI、玩家归属都决定了拆分方式。
多阶段构建
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hd2d ./cmd/server
FROM alpine:3.20
RUN adduser -D -u 10001 app \
&& mkdir -p /app/data \
&& chown -R app /app
WORKDIR /app
USER app
COPY --from=build /out/hd2d /app/hd2d
COPY --from=build /src/testdata /app/testdata
ENV HD2D_ADDR=:8080 \
HD2D_DB=/app/data/hd2d.db
EXPOSE 8080
ENTRYPOINT ["/app/hd2d"]
docker build -t hd2d-server:dev .
docker run --rm --name hd2d-server -p 8080:8080 -v hd2d-data:/app/data hd2d-server:dev
# 优雅重启验证:容器收到 SIGTERM 后应完成落盘再退出
docker stop --time 10 hd2d-server
分片:先按房间/地图拆,再谈网关
package game
import (
"hash/fnv"
)
// ShardOf 用一致性哈希把 mapId 映射到 shard,网关据此决定连接落到哪个实例。
func ShardOf(mapID string, shards int) int {
h := fnv.New32a()
_, _ = h.Write([]byte(mapID))
return int(h.Sum32() % uint32(shards))
}
- 单实例多房间:一个进程跑多个 Room,各自 tick;最简单,受单机 CPU 限制。
- 按地图/房间分片:网关读
ShardOf(mapID),把连接路由到对应实例;跨房交互(私聊、跨服战)走消息总线。 - 无缝扩容的代价:玩家迁移房间要重建 AOI/会话;先做“新玩家按 shard 分配”,不做在线迁移。
- TLS:生产由网关/负载均衡终止 TLS,内部走内网;直连场景用
srv.ListenAndServeTLS(cert, key)+wss://。
反作弊检查清单
- 服务端不信任客户端坐标、伤害、时间戳;只接收“输入意图”。
dt夹在 0–100ms,单帧位移不超过Speed × dt。- 技能冷却、射程、LOS、目标合法性全部服务端算。
seq单调递增,旧包/重放包直接丢。- input 频率限流(例如 30Hz),超限丢弃而不是排队。
- token 走登录帧或 Authorization 头,不要长期放在可被日志记录的 URL;第 09 章用 query 只是教学简化。
- Origin 白名单 + 连接数/单 IP 限制。
- 关键战斗结果写事件日志,支持事后回放核对。
验收任务
自测
Q1:为什么最终镜像用 alpine 而不是 distroless?
Q2:为什么“按房间分片”比“按玩家哈希分片”更适合游戏?
Q3:优雅重启时,为什么只靠 `defer store.Close()` 不够?
persistDirty,再关闭 HTTP/DB 与进程;顺序错了就丢最后一段进度。常见坑
- 容器内用相对路径读地图,工作目录不是项目根 → 启动即失败;用
-map参数或固定/app/testdata。 - 把 SQLite 放在容器可写层 → 重启即丢档;必须挂卷。
- 扩容后每个实例都加载同一张大世界 → 内存翻倍;按地图/房间切分资源。
- 以为“多开进程”就能扩容 → 同一玩家可能被两个实例同时接纳,状态双写。
Stage 12 · 毕业验收四个场景全过,这个服务端才算“能联机”
目标:用可复现的命令跑完双人联机、重连、AOI、压测四个场景,留下基线数据与下一步计划。
到这里代码已经齐了,剩下的不是“再写功能”,而是证明它真的工作。验收场景要能一条条重跑, 否则下次改代码你无法判断是变好还是变坏。
四个必过场景
| 场景 | 操作 | 通过标准 |
|---|---|---|
| 双人联机 | go run ./cmd/server + go run ./cmd/bot -n 2 | 两个 bot 都收到 welcome;快照里能看到对方 ID、坐标随输入变化 |
| 断线重连 | kill 一个 bot,用同一 token 在 30 秒内重连 | 重连后位置/血量保留,下一帧收到完整 AOI 快照,没有重复实体 |
| AOI 边界 | 两个 bot 用相反方向输入,走到相距 > 48 tile | 彼此快照中不再出现对方,且收到 removed / 不再收到该实体 |
| 容量基线 | go run ./cmd/bot -n 100 跑 3 分钟 | tick 超预算为 0(或记录可接受值)、无异常掉线、RSS 稳定不持续上涨 |
验收命令
# 1. 生成地图(首次或改地图后)
go run ./cmd/mapgen
# 2. 质量门禁
go fmt ./...
go vet ./...
go test -race ./...
# 3. 起服务
go run ./cmd/server
# 4. 另开终端:双人
go run ./cmd/bot -n 2
# 5. 容量
go run ./cmd/bot -n 100
# 6. 观测
curl http://127.0.0.1:8080/metrics
README 模板(只写这五段)
- 这是什么:一句话 + 技术栈(Go 1.22 / gorilla-websocket / SQLite)。
- 怎么跑:mapgen → 测试 → server → bot,四条命令。
- 协议:C→S / S→C 帧类型表,指向
internal/protocol。 - 容量基线:机器配置、bot 数、tick 超预算、RSS、快照频率。
- 已知限制:单房间、无跨服、JSON 协议、SQLite 单写者。
下一步往哪走
- 换分布式框架:Pitaya(handler/组件/路由/集群),把本项目的 Room 概念映射过去。
- 协议升级:JSON → protobuf,减少带宽与 CPU;帧同步/状态同步的选择要按玩法定。
- 大世界:AOI 换四叉树/网格分桶,寻路换流场,房间按地图切分。
- 客户端:Godot / Unity / Phaser 任选,用本页的协议帧直接对接;HD2D 表现(2.5D 渲染、光照、景深)在客户端做。
- 运维:指标接 Prometheus/Grafana,日志接 Loki,压测接 k6 或自研 bot 集群。
验收任务
自测
Q1:为什么验收要规定“可复现命令”,而不是“我这边试过可以”?
Q2:AOI 场景为什么要让两个玩家走到相距 > 48 tile?
Q3:100 bot 时“RSS 持续上涨”通常意味着什么?
常见坑
- 只跑“2 个 bot 能动”就宣布完成 → 没有覆盖重连、AOI、容量,线上第一次压力就崩。
- 压测在同一台机器上同时跑 server 与 100 bot → CPU 争抢,数据不可信;至少分两个进程并观察系统负载。
- 指标没接,靠肉眼判断“挺流畅” → 无法量化退化。
- 把已知限制藏着不写 → 接手的人以为能直接上生产。
里程碑验收
每个里程碑都要求前置阶段的任务全部勾选。勾满后卡片会变成“可验收”,再按命令逐条验证。
网络骨架能连上
HTTP 服务能起、`/ws` 能升级、bot 能收到 welcome、Ctrl+C 能干净退出。
验收:go run ./cmd/server + go run ./cmd/bot,日志出现 welcome;服务端无 panic、无 goroutine 泄漏告警。
走位与战斗是服务端说了算
两个 bot 在同一房间移动,撞墙被拦、台阶受限;技能受射程/冷却/视线约束,HP 变化出现在快照里。
验收:两个 bot 斜向撞墙坐标不变;站在高台后 bolt 打不中;连续快速 cast 只有冷却允许的那次生效。
同步、存档与质量达标
AOI 只发可见实体、断线 30 秒内可重连、重启能读档、-race 全绿、pprof 与 /metrics 可用。
验收:相距 >48 tile 看不到对方;kill 进程后重启,token 重连位置恢复;go test -race ./... 全绿;100 bot 3 分钟 tick 超预算为 0。
可交付
镜像可跑、SIGTERM 不丢档、README 五段齐全、四个验收场景全部有可复现记录。
验收:docker run 后 bot 能连;docker stop 后重启存档仍在;README 里的容量数字与实测一致。
附录:排错手册
先按“症状”定位,再用命令取证。不要靠猜。
| 症状 | 最可能的原因 | 动作 |
|---|---|---|
| fatal error: all goroutines are asleep - deadlock! | 无缓冲 channel 双向等待 / 只发不收 / 锁重入 | 看 panic 堆栈定位 channel;给投递加 default 或超时;用 go test -timeout 10s |
| fatal error: concurrent map writes | 多个协程直接写同一个 map(常见于 AI/会话表) | 确认是否绕过了 tick 协程;所有世界状态写入必须回到 Room 命令通道 |
| DATA RACE | 跨协程读写同一字段,且没有同步 | go test -race;读 WARNING: DATA RACE 的两段堆栈,找出共享点 |
| WebSocket 1006 | 握手失败 / 心跳超时 / 代理未升级 / Origin 被拒 | 看服务端 upgrade 日志;确认反代支持 Upgrade;核对 ping/pong 与 read deadline |
| 玩家瞬移 / 穿墙 | dt 未夹紧、客户端权威、服务端用了真实时间差 | dt 上限 100ms;位移上限 Speed × dt;所有移动重新校验 CanStand |
| tick over budget 频繁 | tick 里做 IO / JSON / 大量遍历 / 寻路 | pprof CPU 看热点;把序列化移出 tick 或分批;大图寻路异步化 |
| goroutine 数持续上涨 | 读泵未退出、ticker 未 Stop、连接未 Close | go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine?debug=2,按堆栈聚合 |
| 重连后出现两个自己 / 状态被覆盖 | 旧 Session 未清理、token 映射未复用 | 复用房间内离线 Player;重连前先关闭旧 Session;token 唯一 |
| 存档重启后丢失 | 停机未落盘 / DB 路径在容器可写层 | ctx.Done 分支 persistDirty;DB 挂卷;确认 SIGTERM 到达 |
| bot 连上但收不到快照 | 未 join 房间 / broadcast 未启动 / Send 队列满 | 看 inputs_dropped 与 welcome 日志;确认 tick 在跑、Session 非 nil |
常用命令
资料
- A Tour of Go:go.dev/tour —— 语法与并发基础,半天过一遍。
- Go by Example:gobyexample.com —— 按主题查代码,比文档快。
- Effective Go:go.dev/doc/effective_go —— 命名、接口、并发的官方口径。
- gorilla/websocket 文档:pkg.go.dev/github.com/gorilla/websocket —— 读写泵、Upgrader、控制帧。
- modernc.org/sqlite:pkg.go.dev/modernc.org/sqlite —— DSN 与 pragma 写法。
- 《100 Go Mistakes》:把“看起来能跑”的代码改成“经得起压测”的代码。