Go · Server-Side · HD-2D

从 Spring Boot 到 Go
亲手写一个多人 HD2D 服务端

你已经会写 Spring Boot 服务,缺的不是“服务端经验”,而是一套 Go 的并发与工程手法。 这条路线不换项目:从 go mod init 开始,逐章把同一个 hd2d-server 养成 权威服务器——固定 20Hz tick、房间 Actor、AOI 快照、断线重连、SQLite 存档,最后用 Go 写的 bot 在本机验证多人联机与压测。

13 个阶段 72 个可勾选任务 只做服务端 标准库 + gorilla/websocket 4 个里程碑
13学习阶段,同一个项目逐章长大
72可勾选任务,勾选即记录进度
1可运行的多人服务端(bot 验收)

路线一览

一条主线:同一个 hd2d-server 从 Hello 走到可联机。橙色节点是里程碑,必须前置任务全绿才建议继续。

00环境与心智迁移
01Go 语言核心
02并发模型
03net/http
04WebSocket
M1网络骨架可连
05tick 与房间
06HD2D 世界
07战斗判定
M2走位与战斗
08同步与 AOI
09持久化
10测试与观测
M3质量达标
11部署与扩容
12毕业验收
M4可交付
怎么用这张页面:每章先读“为什么”,再照着代码块写进对应文件,最后逐条勾选验收任务。 自测题折叠着答案,先自己想再看。进度存在浏览器本地,刷新不丢。

先完成一次心智迁移

同一件事,Spring Boot 与 Go 的默认答案不同。表里右列就是本项目实际会写的做法。

Spring Boot 里你是这么做Go 里改成这么做
@SpringBootApplication 启动容器main() 里手工装配依赖,没有容器、没有包扫描
Bean + @Autowired构造函数 / 参数显式注入;接口在调用方定义(Go 的“鸭子类型”)
application.yml + @ConfigurationProperties环境变量 + config.Load(),默认值写在代码里
@RestController + @RequestMappinghttp.ServeMux + "GET /healthz" 方法路由(Go 1.22+)
Filter / HandlerInterceptorfunc(http.Handler) http.Handler 中间件,手动串起来
thread pool / ExecutorServicegoroutine + channel + context;不要每个请求开线程池
synchronized / ReentrantLock首选“单 goroutine 拥有状态”(Actor),锁只是下策
JPA / @Transactionaldatabase/sql + 显式 BeginTx,SQL 自己写
HikariCP 连接池sql.DB 自带连接池;SQLite 建议 SetMaxOpenConns(1)
Jackson ObjectMapperencoding/json + struct tag
Logback + MDClog/slog 结构化日志(key-value,不加 MDC)
Fat jar + JVM 启动编译成单个静态二进制,FROM scratch 级别镜像
JUnit + Mockitotesting + table-driven + httptest,接口用假实现代替 mock
Spring WebSocket / STOMPgorilla/websocket,连接、心跳、读写泵全部自己管
最重要的三条差异:① 没有容器,依赖顺序由你决定;② 并发原语是 goroutine/channel,不是线程池;③ 错误是返回值,不是异常。 后面每一章都会反复用到这三条。

终点长什么样

先看清目标,再往回拆。下面是毕业时的目录、运行链路和协议。

目录结构

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

运行时链路

HTTP 入口GET /healthz · GET /metrics · GET /ws(升级)
↓ 升级成功,连接入表
netws.ClientreadPump 读帧 → 解析 → 投递命令;writePump 消费 Send
↓ 命令进 channel(不直接改世界)
game.Room(唯一写者)tick goroutine 独占全部实体状态
↓ 每 50ms step 一次(20Hz)
step:drain 命令 → 移动 → AI → 战斗 → 重生
↓ 每 2 tick(10Hz)
AOI 可见集合 → 构造快照 → 过滤事件每个玩家只看到自己 3×3 格
↓ 非阻塞投递,队列满即判慢客户端
Client.Send chan → writePump → WebSocketping 54s / pong 60s / 单写者
整页最重要的一句话:房间状态只有一个写者——tick goroutine。网络协程只能“投递命令”,不能直接改实体。 这条规则决定后面 90% 的并发 bug 不会发生。

协议 v1(JSON 文本帧)

方向t字段语义
C → Slogintoken?首次为空;带 token 则恢复存档 / 重连
C → Sjoinroom进入房间(教学版固定 lobby)
C → Sinputseq, dt, mx, my移动意图;dt 上限 100ms,seq 必须递增
C → Scastcast, target技能 id + 目标实体 id,服务端做全部校验
S → Cwelcomepid, token, tickRate握手成功,客户端保存 token 用于重连
S → Csnapshottick, entities[], events[]AOI 内的实体与事件,10Hz
S → Cerrorcode, msg非法帧 / 未登录 / 房间不存在

硬约束:单帧上限 4KB;input 频率上限 30Hz;未知 t 返回 error;服务端永远不相信客户端坐标与时间。

技术选型(全页统一,不另选)

项选择原因
Go 版本1.22+方法路由、log/slog、signal.NotifyContext 都是 1.21/1.22 起稳定
WebSocketgithub.com/gorilla/websocket v1.5.3生态最稳、API 直白,适合看清“读写泵”到底在做什么
持久化modernc.org/sqlite纯 Go、免 cgo,单二进制可直接跑
tick / 快照20Hz / 10Hz动作游戏常见下限;先保证稳定,再谈提速
AOI每格 16 tile,兴趣域 3×3实现简单、可解释;后续换四叉树只替换一个函数
ID / tokencrypto/rand不引 uuid 依赖,32 字节 hex 足够
指标手写 /metrics不引 Prometheus 客户端,先理解指标怎么来
00

Stage 00 · 环境与心智迁移把 JVM 那条路换成 Go 这条路

目标:装好 Go、建好模块、跑通 build / run / test 三件套,并接受“没有容器、没有 JVM”的新世界。

go.modcmd/hello/main.go

这一章不写游戏代码,只把工具链跑顺。后面所有章节都假设你随时能执行 go run ./cmd/...; 工具链不熟,后面每一步都会卡在“这到底怎么跑起来”。

必须建立的四点认知

  • 没有 JVM:编译产物是目标平台的机器码,单文件可拷走;跨平台要交叉编译(GOOS/GOARCH)。
  • 没有容器:没有 Bean 生命周期、没有包扫描、没有 AOP 代理。依赖谁,就在 main() 里 new 出来传进去。
  • 模块即项目:go.mod 声明模块路径与依赖版本,相当于 pom.xml;但依赖是“精确到 commit/版本”的,不需要 mvn install 本地仓库那一套。
  • 目录即包:同一目录下所有 .go 文件属于同一个包;首字母大写才是导出符号,等价于 Java 的 public。

装好工具链

终端(PowerShell)
# 方式一:winget
winget install --id GoLang.Go

# 方式二:从 https://go.dev/dl/ 下载 MSI 安装包

# 验证(应 ≥ 1.22)
go version
go env GOPATH GOMODCACHE

初始化模块与第一个程序

go.mod(由 go mod init 生成)
module hd2d

go 1.22
cmd/hello/main.go
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 1.11 起使用模块(module)机制:依赖按版本下载到统一的模块缓存(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”。
01

Stage 01 · 语言核心结构体、接口、错误:把 Java 的类模型换掉

目标:能写出实体、组件、接口和返回 error 的方法,并理解 Go 的值语义与组合式“继承”。

internal/learn/basics.gointernal/game/entity.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。

写实体与组件

internal/game/entity.go
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
}

接口:在“使用方”定义,而不是实现方

internal/game/store.go
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

internal/learn/basics.go
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

internal/learn/basics.go
// 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 的“继承 + 方法覆写”?
不实现。用组合(嵌入字段/接口)拼能力;需要多态时,把差异抽成接口方法,调用方依赖接口。游戏里常见做法是“实体 + 组件”,而不是 Player extends Actor。
Q3:为什么 `ReadyAt map[string]uint64` 读取时不初始化也安全?
Go 规范规定读 nil map 返回 value 类型的零值,所以 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 那样按包声明。
02

Stage 02 · 并发模型goroutine 不是线程池,channel 才是接口

目标:用 goroutine + channel + context 写并发,能判断什么时候该加锁、什么时候根本不该共享状态。

internal/learn/concurrency.go

游戏服务端天生并发:成千上万个 WebSocket 连接、每个连接一个读协程、一个写协程、每个房间一个 tick 协程。 这一章决定你后面会不会写出“偶发卡死、偶发数据竞争”的服务。

知识点

  • goroutine 很便宜但不免费:初始栈几 KB,可开上万;但每个都必须能退出,否则就是泄漏。
  • channel 是通信,不是共享内存:无缓冲=同步交接;有缓冲=队列。不要用 channel 模拟锁。
  • 关闭规则:只由发送方关闭;关闭后仍可读出剩余值,读尽后得到零值 + false;向已关闭 channel 发送会 panic。
  • select 多路等待:配合 ctx.Done() 是标准的“可取消等待”。
  • context 只做三件事:传递取消、截止时间、请求域值;不要拿它传业务参数。
  • sync 包:Mutex/RWMutex/WaitGroup/Once;但本项目的世界状态不用锁。
  • -race:数据竞争只能靠工具抓,go test -race ./... 必须是习惯。

最小 worker pool

internal/learn/concurrency.go
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
}

可取消等待

internal/learn/concurrency.go
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)
}

锁只保护“必须共享”的东西

internal/learn/concurrency.go
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 接收,分别会怎样?
发送:panic(send on closed channel)。接收:立即返回缓冲区剩余值;读空后返回零值和 false,不会阻塞。因此“关闭”是广播“不会再有新值”的信号,适合用 for v := range ch 收尾。
Q2:为什么 `FanOut` 里关闭 out 的协程要单独起一个?
因为关闭必须发生在所有发送者结束后,而发送者有多个 worker。让 wg.Wait() 在第三个协程里等待,等全部 worker 退出后再 close(out),接收方才能安全地 range 到结束。若某个 worker 自己 close,会与其他 worker 的发送竞争并 panic。
Q3:goroutine 泄漏最常见的两个原因?
① 在无缓冲/满缓冲 channel 上永久阻塞,且没有 context/超时退出路径;② 循环里启动了协程但函数返回后没人负责收尾(例如读循环没有 defer 关闭连接)。排查: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() 一路传到底,取消信号永远传不到下游。
03

Stage 03 · net/http没有 @RestController,路由和中间件自己串

目标:用标准库搭出一个能启动、能记日志、能优雅退出的 HTTP 服务,作为 WebSocket 的宿主。

cmd/server/main.gointernal/config/config.go

游戏服务端不是“只有长连接”:健康检查、指标、后台管理、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 + 默认值

internal/config/config.go
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 入口 + 中间件 + 优雅退出

cmd/server/main.go
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", ...)` 差别在哪?
Go 1.22 起模式支持 HTTP 方法和路径变量。带方法后,非 GET 请求会得到 405;路径部分还支持 {id} 并用 r.PathValue("id") 取值。老写法所有方法都进同一 handler,需要自己判断 r.Method。
Q2:为什么中间件签名是 `func(http.Handler) http.Handler` 而不是“拦截器对象”?
Go 里 Handler 是接口也是函数,包装一个 Handler 返回另一个 Handler 就是最自然的组合方式。没有容器帮你扫描注册,顺序由你显式书写;越靠外的中间件越先执行。
Q3:`srv.Shutdown` 和直接 `srv.Close` 有什么区别?
Shutdown 先停止接收新连接,再等待在途请求结束(受传入 ctx 的超时约束);Close 立即关闭所有连接,可能中断正在写回的响应。优雅重启用 Shutdown。

常见坑

  • 忘记 defer stop(),信号监听未释放;单测里也会泄漏。
  • 在 handler 里直接 w.WriteHeader 之后又写 Header,Header 已冻结、改不动。
  • 把 ListenAndServe 放在主协程且不做错误处理,端口占用时进程静默退出。
  • 用 http.DefaultServeMux 注册路由,第三方库偷偷注册 /debug 端点,生产暴露。
04

Stage 04 · WebSocket一个读泵、一个写泵、一条心跳线

目标:把 HTTP 请求升级成长连接,做到“单读单写”、心跳保活、慢客户端可踢,并定下协议帧。

internal/protocol/protocol.gointernal/netws/client.gointernal/netws/server.go

实时联机不靠 HTTP 轮询。WebSocket 是长连接:一端随时可能发数据,另一端也可能断。 这一章把“连接生命周期”管住,后面 tick、房间、AOI 才能安心地假设“连接是可靠的”。

知识点

  • gorilla/websocket 的一条铁律:一个连接同一时刻只允许一个写者、一个读者;所以拆成 readPump 与 writePump 两个协程。
  • 写操作只能来自 writePump:业务代码只往 Send channel 投递,不直接 conn.Write。
  • 心跳:服务端定时发 ping,客户端回 pong;SetPongHandler 里刷新读 deadline,超时即断开。
  • 背压:Send 是有限缓冲;满了说明客户端消费不动,直接判“慢客户端”踢掉,绝不阻塞 tick。
  • 帧约束:SetReadLimit(4096) 防大帧打爆内存;未知类型返回 error 帧而不是 panic。
  • Origin 校验:教学环境可放开,生产必须按白名单校验,防跨站劫持。

协议帧:只有一个 struct

internal/protocol/protocol.go
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) }

连接:读写泵 + 心跳

internal/netws/client.go
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
		}
	}
}

升级入口

internal/netws/server.go
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)
}
阶段验收先只做一个“回显 + welcome”:连接成功后启动 go c.WritePump(),再 c.SendMessage(...) 发 welcome,最后在 c.ReadPump(...) 里接收 input 并回显。 真正的房间接线放在第 05 章。

验收任务

自测

Q1:为什么不能让 tick 协程直接调用 `conn.WriteMessage`?
① gorilla 不允许并发写,会和 writePump 抢连接;② 网络写可能阻塞(慢客户端、内核缓冲满),一阻塞就把整个房间的 tick 拖死。正确做法是投递到 Send channel,由 writePump 承担阻塞与超时。
Q2:ping/pong 到底解决了什么问题?
解决“连接看起来还在、实际已死”的半开连接。服务端定时 ping;对端必须回 pong,pong handler 刷新读 deadline。超过 60 秒没有任何读事件,就认为连接失效并关闭,释放房间里的会话与协程。
Q3:`Send` 缓冲设成 64,满了就踢人,会不会误杀正常玩家?
64 帧按 10Hz 快照约等于 6 秒的积压。正常网络下写泵能很快清空;连续积压 6 秒说明对端已无法消费,继续保留只会拖累服务端内存与 tick。更好的是“可配置 + 指标 + 分级惩罚”,但毕业项目先用硬阈值。

常见坑

  • 在多个地方 conn.WriteJSON(包括日志、错误提示)→ 并发写触发 panic 或帧错乱。
  • 忘记设置读 deadline → 死连接永远占着 goroutine,玩家数越跑越“虚高”。
  • 升级成功后还去写 HTTP 响应 → 连接已被接管,写不出去或破坏协议。
  • 把 CheckOrigin 直接写 true 就上线 → 任意站点可劫持连接(教学代码必须标注风险)。
05

Stage 05 · tick 与房间房间是一个 Actor:状态只归 tick 协程所有

目标:写出 20Hz 固定步长循环、命令队列、超时告警和优雅停机,让“网络”与“世界”彻底解耦。

internal/game/world.gointernal/game/room.go

游戏世界不能“来一个请求就改一次状态”。固定步长让移动、冷却、AI、同步都有一致的时间基准; 单协程拥有状态则让所有并发问题变成“怎么把命令安全送进来”,而不是“怎么加锁”。

知识点

  • 固定步长:time.NewTicker(50ms),每 tick 用固定 dt 推进,不用真实时间差做物理(避免抖动与作弊)。
  • Actor 模型:实体 map 只允许 tick 协程读写;外部通过 cmds/join/leave 三个 channel 通信。
  • 非阻塞入口:网络协程用 select ... default 投递;队列满就丢弃并计数,绝不阻塞读泵。
  • 每 tick 预算:50ms 周期里业务应控制在 5ms 内;超了就 Warn(第 10 章再加指标)。
  • 停机顺序:ctx 取消 → tick 循环退出 → 落盘 → 房间 goroutine 结束 → 主进程退出。

房间 Actor

internal/game/room.go
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
		}
	}
}

命令入口:只投递,不改状态

internal/game/room.go
// 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))
	}
}

世界与装配

internal/game/world.go
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()
}

先补齐实体外壳(后续章节替换占位实现)

internal/game/entity.go(追加)
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()                                      {}

加入房间:本章就要能真加入

internal/game/room.go(新增 addPlayer)
// 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 一下?
固定步长下,一个 tick 内到达的命令都应在同一帧内被消费;逐条延时会让输入延迟不可控。一次性 drain 把“输入延迟”限制在 0–50ms,并且处理顺序与到达顺序一致。
Q2:`World.mu` 为什么只保护 rooms 表?
因为房间表会被 HTTP handler(Join 时查房间)和启动协程并发访问,所以需要锁;但每个房间的实体状态只归它自己的 tick 协程,不需要也不应该用这把锁保护。分清“共享的注册表”和“独占的状态”是 Actor 设计的关键。
Q3:`Join` 为什么要用带缓冲的 channel + 等待结果,而不是直接改 players?
直接改就是跨协程写状态,必须加锁,并可能和 tick 中的遍历冲突(并发 map 写会 fatal)。通过 channel 把“加入”变成一条命令,让 tick 协程在安全时机修改 map,并回传分配到的实体 ID。

常见坑

  • 用 time.Sleep(50ms) 循环 → 每帧漂移累积,实际 tick 率越来越低;用 Ticker。
  • 在 tick 里做网络写、数据库写、日志大对象序列化 → 单帧超预算,玩家体感卡顿。
  • 用无缓冲 channel 投递命令 → 读泵阻塞,连接直接卡死;必须有缓冲 + 默认分支。
  • 停机时直接 os.Exit → 存档丢失;必须让 tick 协程看到 ctx 取消并落盘。
06

Stage 06 · HD2D 世界三层地图、碰撞、台阶与寻路

目标:加载一张带高度层的格网地图,服务端合法地移动玩家、拦住穿墙、限制台阶,并让 NPC 自己巡逻。

cmd/mapgen/main.gotestdata/maps/lobby.jsoninternal/game/map.gointernal/game/path.go

HD2D 的服务端不需要真 3D 物理,但必须有“高度”这个概念:能不能走上去、视线是否被高台挡住、技能能不能越过矮墙。 这一章用三层格网把这些问题全部变成确定性的整数判断。

HD-2D 是什么:像《八方旅人》那样,2D 像素角色 + 3D 场景/景深/光照的 2.5D 风格。 服务端不负责渲染,只负责规则与状态:格网、高度层、碰撞、视线、AOI、存档;像素画、景深、光照、插值都在客户端。 这一章就是把“高度”这件事变成服务端可判定的整数规则。

地图用三层字符串表示

层字符含义
groundg / d / w草地 / 泥地 / 水面;只影响表现,服务端不关心
block# / .# 不可通行;墙、柱子、水面都可以标成阻塞
height0–3该格高度层;相邻格高度差 ≤1 才能上下,超过就是悬崖

三层等长:每层 h 行、每行 w 个字符。用字符串而不是二维数组,是为了让生成器与人工排查都直观。

生成一张 64×48 的 lobby 地图

cmd/mapgen/main.go
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)
	}
}

加载与判定

internal/game/map.go
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 用)

internal/game/path.go
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 与“先全量广播”

internal/game/room.go(替换第 05 章的空实现)
// 轴分离移动: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”?
用相邻格高度差可以支持连续上坡/下坡(0→1→2),而后者会禁止从 0 直接规划到 2 的路径,即使中间有 1 级台阶。规则要与 CanStand完全一致,否则会出现“寻路能走、移动被拦”的错位。

常见坑

  • 视线/寻路/移动三处对“高度差”的口径不一致 → 寻路绕路但走不动,或技能穿墙命中。
  • 用浮点坐标直接做 []bool 下标(m.Block[int(x)])→ 负数或越界 panic;统一走 Floor + InBounds。
  • 把地面装饰(草/水)当碰撞依据 → 表现与规则耦合,后续换图集就全错;碰撞只认 block 层。
  • NPC 在 tick 里同步寻路且地图很大 → 单帧超预算;大图要把寻路放到独立协程或用流场。
07

Stage 07 · 战斗判定射程、冷却、视线、伤害:全部服务端说了算

目标:实现一套“客户端只能表达意图、服务端做全部校验”的技能系统,并让结果可复现。

internal/game/combat.gointernal/game/room.go(补 rng 字段)

联机游戏的第一原则是权威服务器:客户端说“我想放技能”,不能直接说“我打中了他、扣了 18 血”。 这一章把射程、冷却、视线、伤害、死亡全部收进 tick 协程。

技能是配置,不是散落的 if

internal/game/combat.go
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 作为两个独立事件。

高度遮挡下的视线判定

internal/game/combat.go(续)
// 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))
}
internal/game/room.go(补字段与初始化)
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 血”的结果?
因为客户端可被修改。客户端只能发“我想用 bolt 打 target”,服务端重新算距离、视线、冷却、随机伤害并写状态。这样作弊者最多只能“乱点技能”,无法改数值。
Q2:为什么伤害随机必须用房间级固定种子,而不是 `rand.Intn`?
固定种子让同一串输入产生同一串结果,方便回放、复现 bug、做战斗校验。用全局随机源时,出错后无法重现“那一刻为什么是这个伤害”。注意:要实现完整重放,还必须固定处理顺序——遍历 map 的顺序在 Go 里是随机的。
Q3:AoE 技能遍历 `map[EntityID]*Entity` 结算伤害,会破坏确定性吗?
会。Go 的 map 迭代顺序随机,伤害分配顺序、随机数消耗顺序都会变。解决:把命中目标收集成切片并按 ID 排序后再结算,或者用有序结构。这类“隐式顺序”是回放系统最常见的坑。

常见坑

  • 先写冷却再看目标 → 打空也会进冷却,玩家体验与预期不符;先校验再写状态。
  • 用客户端上报的时间戳算冷却 → 改本地时间即可无限放技能;冷却只认服务端 tick。
  • 把“命中”做成客户端播动画 + 服务端补伤害 → 两端表现不一致;服务端定结果,客户端只做表现。
  • 伤害扣成负数 → 后续 Alive() 判断、排序、统计全错;扣完必须夹到 0。
08

Stage 08 · 同步与 AOI只发看得见的:九宫格、事件过滤、重连

目标:把“全量广播”升级成 AOI 快照,补上重连与离线宽限,并理解增量同步的代价与收益。

internal/game/aoi.gointernal/game/snapshot.gointernal/game/room.go(重连/落盘)

一个房间 100 人时,全量广播每帧要发 100 份同样的数据;AOI 让每个玩家只收到周围 3×3 格内的实体。 带宽、序列化 CPU、客户端解包量都会随人数增长而恶化,AOI 是最直接的止血点。

AOI:格子 + 可见集合

internal/game/aoi.go
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 + 事件

internal/game/snapshot.go(替换第 06 章的 broadcast)
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 发一次。移动类游戏常见的折中是 “位置插值在客户端做,服务端只保证权威状态”。先保证不炸,再谈提高频率。

加入、重连、离线宽限与落盘

internal/game/lifecycle.go(替换第 05 章的 addPlayer / markOffline / persistDirty 空实现)
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)
	}
}
internal/game/room.go(step 里补两行)
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"]。
protocol/protocol.go(增量字段,可后置实现)
type ServerMsg struct {
	// ... 原有字段
	Removed []string `json:"removed,omitempty"` // 本次离开 AOI 的实体
}

每个 Session 维护 lastSeen map[string]EntityDTO:先算本次可见集合,再与 lastSeen 做差。 注意这会引入“每连接一份快照缓存”,内存与复杂度上升;先量化全量快照的带宽与 CPU,再决定是否值得。

验收任务

自测

Q1:AOI 用 16×16 格、兴趣域 3×3,在 64×48 地图上大概能看到多少?
3×3 个格子,每格 16×16 tile,理论覆盖 48×48 tile 的范围(受地图边界裁剪)。对 64×48 的地图,玩家在中部时几乎能看到大部分地图;想真正体现 AOI 的效果,应该把地图放大到 256×256 或增加人数。
Q2:为什么“离开视野”必须显式 `removed`,而不能只靠客户端超时?
客户端无法区分“实体静止没变”和“实体已离开”,如果只靠超时,会保留幽灵实体、攻击不存在的目标。服务端显式发 removed 是唯一可靠的方式;客户端也可以结合快照序号做兜底。
Q3:重连为什么可以直接复用房间里的离线 Player,而不是重新创建?
实体 ID、位置、血量、冷却、所在房间都还在,复用可以避免“重连后回到出生点 / 冷却被清空 / 其他人看到两个自己”。宽限期就是给这段时间一个上限,超时才真正落盘并移除。

常见坑

  • 在 tick 里同步写 SQLite → 磁盘抖动直接造成 tick 超预算;生产应丢给独立持久化协程。
  • 重连后没有清理旧 Session → 同一玩家两个连接同时发输入,状态被双写。
  • AOI 只按格子判断,忽略实体体积 → 大体积 Boss 半个身子进视野却不可见;需要按半径扩张。
  • 快照里带 map 遍历顺序 → 同一状态两次序列化字节不同,测试与回放都不稳定;先排序。
09

Stage 09 · 持久化纯 Go SQLite:没有 JPA,SQL 自己写

目标:把玩家位置与血量落进 SQLite,重启后能恢复;理解“存档不是每帧写,而是脏了才写”。

internal/persist/store.gocmd/server/main.go(装配)

游戏服务端的存档频率必须克制:每 tick 写库会把磁盘 IO 变成 tick 的尾延迟。 这一章用“脏标记 + 定时落盘 + 停机落盘”的组合,在正确性与性能之间取一个简单解。

建库:驱动与 schema

internal/persist/store.go
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

cmd/server/main.go(替换第 03 章的 mux 装配)
// 这段装配放在第 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()
	})
})
cmd/server/main.go(停机顺序,替换第 03 章的收尾)
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx) // 1. 停止接收新连接,等在途请求结束
<-worldDone                   // 2. 等所有房间 tick 退出并完成落盘
log.Info("bye")               // 3. 最后才退出进程
welcome 的发送位置:第 05 章已把 welcome 放在 addPlayer 里(由 tick 协程发出); 第 08 章的重连分支复用同一个 welcomeMsg,所以主入口只需要 Join + ReadPump,不再自己组装 welcome。
internal/game/entity.go(补 NPC 构造与注册)
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:为什么存档写库不能放在移动命令里?
移动是每 50ms 可能发生的高频事件,写库是毫秒级磁盘操作,而且会阻塞 tick 协程。正确做法是只置脏标记,由低频定时任务或独立持久化协程批量写。
Q3:SQLite 的 `SetMaxOpenConns(1)` 会不会成为瓶颈?
对教学/单机小规模不会;它把并发写变成串行,避免 SQLITE_BUSY。玩家数上千、需要并发写入时应换 PostgreSQL 或把写操作集中到单个持久化协程(批量提交)。

常见坑

  • 写 sql.Open("sqlite3", ...) → 驱动未注册,报 unknown driver;modernc 的驱动名是 sqlite。
  • 在循环里逐条 Upsert 且不包事务 → 每条一次 fsync,慢到怀疑人生;用事务批量提交。
  • 只存 token 不存地图 ID → 玩家读档后出现在错误房间/坐标越界。
  • 停服时不落盘 → 最后一个 30 秒的进度全丢;ctx.Done() 分支必须调用 persistDirty。
10

Stage 10 · 测试与观测先能证明它对,再谈它能扛多少

目标:用 table-driven 测试、-race、pprof、手写指标和 Go bot,把服务端的正确性与容量变成可量化数字。

internal/game/map_test.gointernal/observability/metrics.gocmd/bot/main.go

游戏服务端的 bug 大多是“偶发”的:并发写、边界高度、顺序依赖。没有 -race、没有 bot、没有指标, 你只能靠玩家反馈猜。这一章把猜测换成证据。

table-driven:一条用例一个场景

internal/game/map_test.go
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)
			}
		})
	}
}

房间级集成测试:不启网络也能验证快照

internal/game/room_test.go
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 与手写指标

internal/observability/metrics.go
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())
	}
}
注册指标入口:在 main 的 mux 上加 mux.HandleFunc("GET /metrics", observability.Handler()); 再在 tick 超预算处 TickOverruns.Add(1)、命令队列满处 InputsDropped.Add(1)、玩家加入/移除处增减 PlayersOnline。
cmd/server/main.go(只在 127.0.0.1 暴露 pprof)
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:从“能连”到“能压”

cmd/bot/main.go
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 服务?
房间逻辑与网络传输是两层:fakeSession 直接验证“加入 → tick → 快照”这条业务链,快、稳定、无端口冲突。真正的 WebSocket 握手/心跳另用 httptest + dial 做少量端到端测试,两层各测各的。
Q2:`go test -race` 能发现“房间状态被两个协程写”吗?
能,前提是测试真的并发触发了那条路径。如果你的测试只串行 join,race 检测器看不到。要专门写并发用例(多个 goroutine 同时 Join/Submit),或者用 bot 压测让问题自然暴露。
Q3:tick 超预算应该先看什么?
先看 pprof CPU:是不是每帧都在做 JSON 序列化、排序、A*、数据库写。再拆解单帧耗时:drain、移动、AI、快照各占多少。最常见的元凶是“在 tick 里做 IO”,而不是计算本身。

常见坑

  • 测试里 go room.Run(ctx) 后不 cancel → 测试结束时协程泄漏,race/计数被污染。
  • 用 time.Sleep 等快照 → CI 上偶发失败;用 channel + 超时。
  • 把 pprof 注册到业务 mux → /debug/pprof 暴露公网,等于把堆栈和内存交给攻击者。
  • 压测只看“没崩” → 忽略 tick 超预算与掉线率;这两项才是容量上限的真实信号。
11

Stage 11 · 部署与扩容一个二进制、一次优雅重启、一条分片路线

目标:把服务端装进容器、验证 SIGTERM 不丢档,并想清楚“一间房扛不住时,怎么拆成多间”。

Dockerfiledeploy/

Go 最爽的一点是部署:没有 JVM、没有 fat jar 解压,编译产物就是一个静态二进制。 但游戏服务端的扩容不是“多开几个实例”这么简单——房间状态、AOI、玩家归属都决定了拆分方式。

多阶段构建

Dockerfile
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

分片:先按房间/地图拆,再谈网关

internal/game/shard.go
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?
distroless 更小更安全,但没有 shell 与包管理器,创建可写的 data 目录、初始化权限更麻烦。教学项目用 alpine 换取可操作性;生产可以用 distroless + 在构建阶段准备好目录权限,或用只读根文件系统 + 挂载卷。
Q2:为什么“按房间分片”比“按玩家哈希分片”更适合游戏?
房间内交互密集(AOI、战斗、同步),把同一房间的玩家放在同一实例可以避免跨实例高频通信;按玩家哈希会导致同房间玩家分散,AOI 与战斗都要跨网络。代价是热点房间仍受单实例限制,需要房间内再拆(分区/副本)。
Q3:优雅重启时,为什么只靠 `defer store.Close()` 不够?
Close 只关闭连接,不会把内存里的脏数据写回。必须先让房间 tick 协程看到 ctx 取消并执行 persistDirty,再关闭 HTTP/DB 与进程;顺序错了就丢最后一段进度。

常见坑

  • 容器内用相对路径读地图,工作目录不是项目根 → 启动即失败;用 -map 参数或固定 /app/testdata。
  • 把 SQLite 放在容器可写层 → 重启即丢档;必须挂卷。
  • 扩容后每个实例都加载同一张大世界 → 内存翻倍;按地图/房间切分资源。
  • 以为“多开进程”就能扩容 → 同一玩家可能被两个实例同时接纳,状态双写。
12

Stage 12 · 毕业验收四个场景全过,这个服务端才算“能联机”

目标:用可复现的命令跑完双人联机、重连、AOI、压测四个场景,留下基线数据与下一步计划。

README.md(页面内模板)

到这里代码已经齐了,剩下的不是“再写功能”,而是证明它真的工作。验收场景要能一条条重跑, 否则下次改代码你无法判断是变好还是变坏。

四个必过场景

场景操作通过标准
双人联机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 稳定不持续上涨

验收命令

终端(在 hd2d-server 根目录)
# 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?
兴趣域是 3×3 个 16×16 格 = 48×48 tile。超过这个范围才真正跨越 AOI 边界;在地图中心附近测试时两人很容易仍在同一兴趣域,无法验证过滤逻辑。
Q3:100 bot 时“RSS 持续上涨”通常意味着什么?
大概率是 goroutine/对象泄漏:连接关闭后读泵未退出、ticker 未 Stop、每帧新建大切片且被缓存、事件切片无限增长。用 pprof heap + goroutine 对比两个时间点的快照,找增长最快的分配点。

常见坑

  • 只跑“2 个 bot 能动”就宣布完成 → 没有覆盖重连、AOI、容量,线上第一次压力就崩。
  • 压测在同一台机器上同时跑 server 与 100 bot → CPU 争抢,数据不可信;至少分两个进程并观察系统负载。
  • 指标没接,靠肉眼判断“挺流畅” → 无法量化退化。
  • 把已知限制藏着不写 → 接手的人以为能直接上生产。

里程碑验收

每个里程碑都要求前置阶段的任务全部勾选。勾满后卡片会变成“可验收”,再按命令逐条验证。

M1 · 04 之后

网络骨架能连上

HTTP 服务能起、`/ws` 能升级、bot 能收到 welcome、Ctrl+C 能干净退出。

验收:go run ./cmd/server + go run ./cmd/bot,日志出现 welcome;服务端无 panic、无 goroutine 泄漏告警。

先完成前置阶段的所有任务,再回来验收
M2 · 07 之后

走位与战斗是服务端说了算

两个 bot 在同一房间移动,撞墙被拦、台阶受限;技能受射程/冷却/视线约束,HP 变化出现在快照里。

验收:两个 bot 斜向撞墙坐标不变;站在高台后 bolt 打不中;连续快速 cast 只有冷却允许的那次生效。

先完成前置阶段的所有任务,再回来验收
M3 · 10 之后

同步、存档与质量达标

AOI 只发可见实体、断线 30 秒内可重连、重启能读档、-race 全绿、pprof 与 /metrics 可用。

验收:相距 >48 tile 看不到对方;kill 进程后重启,token 重连位置恢复;go test -race ./... 全绿;100 bot 3 分钟 tick 超预算为 0。

先完成前置阶段的所有任务,再回来验收
M4 · 12 之后

可交付

镜像可跑、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、连接未 Closego 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

常用命令

go test -race ./...
go fmt ./... && go vet ./...
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine?debug=2
go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
curl http://127.0.0.1:8080/metrics

资料

  • 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》:把“看起来能跑”的代码改成“经得起压测”的代码。
最后一句:这张页面给的是路线与可运行的骨架。真正让你学会 Go 的,是把每个阶段的代码亲手敲一遍、 把 race 和 pprof 用起来、把 bot 压到它开始抖为止。到那时,你已经不是“会写 Spring Boot 的人”, 而是“能用 Go 把一群人放进同一个世界的人”。