18.3 进入玩法卷
18.1 把卷一的十八个零件串了一遍,18.2 把它们跑出来的数字汇总成一张表。两节都在往回看:我们造了什么、它有多快。这一节掉个头往前看——卷一交出去的这些东西,到了卷二《单服架构与玩法系统》手里,会长成什么样。
先说清楚卷一和卷二的关系。卷一从头到尾没有写过一行「玩法」:没有怪物、没有伤害、没有背包、没有公会。它只做了一件事——把一条长连接从「网线插上」到「一条消息被正确裁决」之间的所有机制造出来,并证明它们在两万连接下不塌。卷二要做的,是在这套机制之上盖房子:实体怎么活、场景怎么切、AOI 怎么算、战斗怎么裁、掉落怎么发、任务聊天组队公会排行榜邮件交易匹配房间怎么写。
有人会问:玩法不是独立的吗,为什么非得先有卷一那套东西?答案在第 1 章就埋下了——MMO 服务端是有状态的、长连接的、每帧都在变的。一个玩法每次生效,背后都是一条被正确切出来的帧、一次正确的路由、一个被正确推进的定时器、一次被去重过的请求、一次服务端说了算的裁决。卷一不是玩法的前置阅读,它是玩法能不能跑起来的前提。 一个没处理粘包的战斗服,会在玩家放 AOE 时把两条技能帧粘成一条、伤害翻倍;一个没有幂等窗口的掉落系统,会在客户端重发时把同一件装备发两次。这些坑卷一都替你踩过了。
这一节做三件事:给出一张「卷一的零件 → 卷二哪一章用它做什么」的交接映射表;讲清卷二不写什么(跨服归卷三、运营归卷四);最后把整张交接契约编码成可测试的 Go 类型,让「这些零件真的被用上了」变成一条能失败的断言,而不是一句口号。
卷一交出的七个零件,到了卷二各归何处
卷一造了很多东西,但真正会被玩法反复消费的,是下面七个:
| 零件 | 卷一出处 | 一句话 |
|---|---|---|
| 会话表 | 第 7 章 | 连接 ↔ 账号的双向索引,玩法的「这个玩家在不在线、在哪条连接上」全问它 |
| 路由表 | 第 8 章 | 命令号 → 处理函数的分发,玩法的每一种操作都是注册进来的一条路由 |
| Actor 邮箱 | 第 10 章 | 消息驱动的执行单元,把并发收进一个串行消费的邮箱,换来免锁的状态 |
| 定时器 | 第 9 章 | 时间轮,承载冷却、到期、周期结算这类海量短周期定时 |
| 幂等窗口 | 第 11 章 | 至少一次投递下的去重,保证「重发一次」不会变成「发两次」 |
| 同步模型 | 第 12–14 章 | 快照 / 增量 / 兴趣过滤,决定「谁在什么时候收到谁的状态」 |
| 权威校验 | 第 15 章 | 服务端说了算的合法性裁决,客户端只能上报意图 |
这七个零件不是并列摆着的,它们有依赖:路由表把消息喂给 Actor 邮箱,邮箱里的实体靠定时器推进,所有对外可见的变更都要过权威校验,再经同步模型下发,而幂等窗口守在入口保证重发不重复。卷二把这些依赖原样继承下来,只是把「实体」从卷一的 Player 换成了真正的玩法对象。
下面这张表就是交接契约:左列是卷一的零件,右列是卷二哪一章会用它、拿去做什么。
| 卷一的零件(出处) | 卷二消费章节 → 用途 |
|---|---|
| 会话表(第 7 章) | 第 1 章 分层与状态归属:在线态挂会话上,玩法逻辑用 sessionID 定位玩家实体 第 2 章 实体运行时:连接断开时摘除运行时实体,保留其落库状态 第 8 章 聊天:私聊按会话定位接收者,离线则转邮件 第 14 章 匹配与房间:房间成员靠会话判在线,掉线即退出队列 |
| 路由表(第 8 章) | 第 2 章 实体运行时:每种玩法命令注册一个 handler,复用卷一的 cmd→handler 分发 第 5 章 战斗管线:攻击 / 施法命令走同一张路由表进管线 第 7 章 任务系统:任务事件(击杀 / 拾取 / 到达)注册成路由的回调 |
| Actor 邮箱(第 10 章) | 第 3 章 场景实例:一个场景实例 = 一个 actor,玩法消息串行进邮箱,免锁 第 2 章 实体运行时:每个实体一个邮箱,状态只被自己的消息推进 第 10 章 公会:公会是一个 actor,成员操作在公会邮箱里串行 第 14 章 匹配与房间:房间是一个 actor,匹配成功后成员消息进房间邮箱 |
| 定时器(第 9 章) | 第 5 章 战斗管线:技能冷却与 buff 到期挂时间轮,按 tick 推进 第 6 章 掉落与背包:掉落物过期回收走时间轮,不占场景主循环 第 11 章 排行榜:周期结算与刷新用时间轮的周期任务 第 12 章 邮件:邮件过期与附件退回挂定时器 |
| 幂等窗口(第 11 章) | 第 6 章 掉落与背包:防重复拾取,同一掉落物只入一次背包 第 9 章 组队:防重复邀请 / 重复入队请求 第 13 章 交易:防重复扣款,同一笔交易只结算一次 |
| 同步模型(第 12–14 章) | 第 4 章 AOI 与兴趣管理:AOI 是兴趣过滤的具体实现,决定谁收到谁的快照 第 5 章 战斗管线:战斗结果按快照 / 增量下发给兴趣范围内的玩家 |
| 权威校验(第 15 章) | 第 5 章 战斗管线:伤害裁决在服务端算,客户端只上报意图 第 6 章 掉落与背包:拾取合法性(距离 / 归属 / 冷却)在服务端校验 |
从表里能读出三件事。
第一,卷二几乎没有「新造机制」。它造的全是「内容」——实体、场景、战斗规则、掉落表、任务链——而支撑这些内容的机制,卷一已经造完了。这解释了为什么卷二可以写得快:地基不用重打。
第二,一个零件往往服务多个玩法。定时器同时被战斗冷却、掉落回收、排行榜结算、邮件过期四条线用;幂等窗口同时守在拾取、组队、交易三个入口。这也是为什么卷一要把它们写成通用机制而不是塞进某个玩法里——通用的东西才复用得起。
第三,依赖是有方向的。玩法依赖零件,零件不依赖玩法。所以卷二写战斗管线时,改的是「怎么用路由表和定时器」,而不是去动路由表本身。任何一次「为了玩法去改卷一零件」的冲动,都该先怀疑是不是卷一的抽象没做对。
这些玩法在站内已经有一批「架构方案」文章了。卷二和它们的分工,跟卷一与站内内容的分工一模一样:别人讲方案与取舍,卷二写实现与真跑。
| 玩法系统 | 站内既有「方案」文章 | 卷二只补 |
|---|---|---|
| AOI | /aoi-system-design-for-large-game-worlds/
· /game-server-entity-interest-subscription-architecture/ | 接进卷一的同步模型真跑一遍 |
| 战斗 | /game-server-combat-command-pipeline-architecture/ | 把管线挂在路由表与定时器上 |
| 掉落 | /game-server-loot-table-service-architecture/ | 用幂等窗口防重复发奖 |
| 公会 | /game-server-guild-system-backend-design/ | 把公会写成一个 actor |
| 匹配 | /game-server-match-lobby-lifecycle-architecture/ | 房间复用会话表与邮箱 |
卷二不写什么:跨服归卷三,运营归卷四
一个系统最容易失控的方式,是边界模糊。卷二也会明确写下它不碰的四类东西,并把它们交给后两卷:
| 卷二不写 | 为什么 | 归谁 |
|---|---|---|
| 跨服传送、跨服战斗 | 一旦跨进程,卷一「单进程内串行 actor」的假设就不再成立,需要分布式一致性与消息总线 | 卷三《分布式与跨服》 |
| 分服、合服、分库分表 | 属于数据分片与迁移,是部署形态问题,不是单服玩法问题 | 卷三 |
| 压测、承载数字、容量规划 | 玩法写完之后才谈得上压,压的是整卷而非单个系统 | 卷四《运维与运营》 |
| 热更新、灰度、线上运营活动 | 属于发布与运营流程,与玩法逻辑正交 | 卷四 |
| 监控告警、故障演练 | 属于可观测性与稳定性工程 | 卷四 |
这条边界的意义在于:卷二里的每一个玩法,都默认跑在「一个进程、一个世界」里。 一旦你发现某个玩法需求逼着你跨进程(比如「两个服的人要一起打世界 BOSS」),那说明它已经不属于卷二,而是卷三的问题。这不是回避,是让每一卷都能独立跑通、独立验证——卷二跑通的前提,正是「不跨服」。
顺带把卷一的边界也复述一句:卷一不写玩法(归卷二)、不写分布式(归卷三)、不写运维(归卷四)。三卷的边界拼起来,正好是一本完整 MMO 服务端的开发路径:先把网络与并发内核写稳,再把单服玩法写满,然后才谈得上跨服与运营。
可运行产物:把交接契约编码成 Go
上面两张表是给人看的。但「这些零件真的会被用上」「卷二真的不越界」这两件事,如果只靠自觉,写到一半就会漂。所以本节把它们编码成可测试的 Go 类型:零件、卷二章节、消费关系、越界清单,全部进结构体,用 go test 断言。
下面这段是 L1,go test ./internal/handoff/ 可以直接跑:
// file: internal/handoff/handoff.go
// tier: L1
// verify: go test ./internal/handoff/
// Package handoff encodes the contract between volume one (《网络与并发内核》)
// and volume two (《单服架构与玩法系统》): which reusable part each volume-two
// chapter consumes, and which topics volume two deliberately leaves to volumes
// three and four. Section 18.3's tables are for humans; this file is the
// machine-readable version, so the handoff is a thing a test can fail on
// instead of a promise nobody re-reads.
package handoff
// Part is a reusable piece of the network/concurrency core that volume one
// hands over.
type Part struct {
// Name is the part, e.g. "会话表".
Name string
// From is where volume one built it, e.g. "第 7 章".
From string
}
// Use says how one volume-two chapter consumes a part.
type Use struct {
// Chapter must name a chapter in VolumeTwoChapters.
Chapter string
// How is the one-line "what it does with it".
How string
}
// Row is one line of the handoff table: a part and every chapter that uses it.
type Row struct {
Part Part
Uses []Use
}
// VolumeTwoChapters is volume two's table of contents. Every Use.Chapter must
// appear here; a Use naming a chapter that does not exist is a dangling
// reference — the "I'll wire it up later" promise that rots.
var VolumeTwoChapters = []string{
"第 1 章 分层与状态归属",
"第 2 章 实体运行时",
"第 3 章 场景实例",
"第 4 章 AOI 与兴趣管理",
"第 5 章 战斗管线",
"第 6 章 掉落与背包",
"第 7 章 任务系统",
"第 8 章 聊天",
"第 9 章 组队",
"第 10 章 公会",
"第 11 章 排行榜",
"第 12 章 邮件",
"第 13 章 交易",
"第 14 章 匹配与房间",
}
// Table is the handoff table, in code. Order follows volume one's chapters.
var Table = []Row{
{
Part: Part{Name: "会话表", From: "第 7 章"},
Uses: []Use{
{Chapter: "第 1 章 分层与状态归属", How: "在线态挂会话上,玩法逻辑用 sessionID 定位玩家实体"},
{Chapter: "第 2 章 实体运行时", How: "连接断开时摘除运行时实体,但保留其落库状态"},
{Chapter: "第 8 章 聊天", How: "私聊按会话定位接收者,离线则转邮件"},
{Chapter: "第 14 章 匹配与房间", How: "房间成员靠会话表判在线,掉线即退出队列"},
},
},
{
Part: Part{Name: "路由表", From: "第 8 章"},
Uses: []Use{
{Chapter: "第 2 章 实体运行时", How: "每种玩法命令注册一个 handler,复用卷一的 cmd→handler 分发"},
{Chapter: "第 5 章 战斗管线", How: "攻击 / 施法命令走同一张路由表进管线"},
{Chapter: "第 7 章 任务系统", How: "任务事件(击杀 / 拾取 / 到达)注册成路由的回调"},
},
},
{
Part: Part{Name: "Actor 邮箱", From: "第 10 章"},
Uses: []Use{
{Chapter: "第 3 章 场景实例", How: "一个场景实例 = 一个 actor,玩法消息串行进邮箱,免锁"},
{Chapter: "第 2 章 实体运行时", How: "每个实体一个邮箱,状态只被自己的消息推进"},
{Chapter: "第 10 章 公会", How: "公会是一个 actor,成员操作在公会邮箱里串行"},
{Chapter: "第 14 章 匹配与房间", How: "房间是一个 actor,匹配成功后成员消息进房间邮箱"},
},
},
{
Part: Part{Name: "定时器", From: "第 9 章"},
Uses: []Use{
{Chapter: "第 5 章 战斗管线", How: "技能冷却与 buff 到期挂时间轮,按 tick 推进"},
{Chapter: "第 6 章 掉落与背包", How: "掉落物过期回收走时间轮,不占场景主循环"},
{Chapter: "第 11 章 排行榜", How: "周期结算与刷新用时间轮的周期任务"},
{Chapter: "第 12 章 邮件", How: "邮件过期与附件退回挂定时器"},
},
},
{
Part: Part{Name: "幂等窗口", From: "第 11 章"},
Uses: []Use{
{Chapter: "第 6 章 掉落与背包", How: "防重复拾取:同一掉落物只入一次背包"},
{Chapter: "第 9 章 组队", How: "防重复邀请 / 重复入队请求"},
{Chapter: "第 13 章 交易", How: "防重复扣款:同一笔交易只结算一次"},
},
},
{
Part: Part{Name: "同步模型", From: "第 12–14 章"},
Uses: []Use{
{Chapter: "第 4 章 AOI 与兴趣管理", How: "AOI 是兴趣过滤的具体实现,决定谁收到谁的快照"},
{Chapter: "第 5 章 战斗管线", How: "战斗结果按快照 / 增量下发给兴趣范围内的玩家"},
},
},
{
Part: Part{Name: "权威校验", From: "第 15 章"},
Uses: []Use{
{Chapter: "第 5 章 战斗管线", How: "伤害裁决在服务端算,客户端只上报意图"},
{Chapter: "第 6 章 掉落与背包", How: "拾取合法性(距离 / 归属 / 冷却)在服务端校验"},
},
},
}
// Volume identifies which book owns a topic.
type Volume int
const (
// Vol1 is 《网络与并发内核》 (this book).
Vol1 Volume = 1
// Vol2 is 《单服架构与玩法系统》.
Vol2 Volume = 2
// Vol3 is the distributed / cross-server volume.
Vol3 Volume = 3
// Vol4 is the operations / live-ops volume.
Vol4 Volume = 4
)
// OutOfScope is what volume two deliberately does NOT cover, and which volume
// does. This is the "don't wander into the next book" guard: a topic that
// shows up in volume two's outline but belongs here is a scope leak.
var OutOfScope = []struct {
Topic string
Owner Volume
}{
{"跨服传送", Vol3},
{"分布式一致性与分服", Vol3},
{"分库分表与合服迁移", Vol3},
{"压测与承载数字", Vol4},
{"线上运营与热更新", Vol4},
{"监控告警与故障演练", Vol4},
}
// Consumes returns the chapters of volume two that consume the named part.
func Consumes(part string) []string {
for _, r := range Table {
if r.Part.Name == part {
out := make([]string, 0, len(r.Uses))
for _, u := range r.Uses {
out = append(out, u.Chapter)
}
return out
}
}
return nil
}
配套的 internal/handoff/handoff_test.go 断言六件事:每个零件至少有一个消费章节、每条消费关系都指向真实存在的卷二章节、零件名不重复、卷二每一章都至少消费了一个卷一零件(没有「凭空出现」的章节)、越界清单里的每个话题都属于卷三或卷四、以及 Consumes 的查表结果正确。本机实跑结果:
=== RUN TestEveryPartHasAtLeastOneUse
--- PASS: TestEveryPartHasAtLeastOneUse (0.00s)
=== RUN TestEveryUsePointsAtARealChapter
--- PASS: TestEveryUsePointsAtARealChapter (0.00s)
=== RUN TestPartNamesAreUnique
--- PASS: TestPartNamesAreUnique (0.00s)
=== RUN TestEveryVolumeTwoChapterConsumesSomething
--- PASS: TestEveryVolumeTwoChapterConsumesSomething (0.00s)
=== RUN TestOutOfScopeBelongsToOtherVolumes
--- PASS: TestOutOfScopeBelongsToOtherVolumes (0.00s)
=== RUN TestConsumes
--- PASS: TestConsumes (0.00s)
PASS
ok aegis/internal/handoff 0.263s
其中 TestEveryVolumeTwoChapterConsumesSomething 最值得说:它要求卷二的每一章都消费至少一个卷一零件。这条不变量在防什么?防的是「卷二偷偷冒出一个不需要任何地基的新机制」。如果有一天卷二加了一章「跨服排行榜」,这个测试会立刻失败——因为跨服排行榜依赖的是分布式一致性,不是卷一的任何零件,它就该被赶去卷三。把边界写成会失败的断言,比写进文档管用。
本节小结
这一节是卷一的出口,也是卷二的入口:
- 交出七个零件:会话表、路由表、Actor 邮箱、定时器、幂等窗口、同步模型、权威校验。它们是有依赖的通用机制,卷二把它们原样继承。
- 给出交接映射表:每个零件在卷二哪一章、被拿去做什么。卷二几乎不新造机制,只造内容,所以它能写得快。
- 划清边界:跨服与分布式归卷三,压测与运营归卷四;卷二默认跑在「一个进程、一个世界」里。
- 把契约编码成 Go:零件、章节、消费关系、越界清单进结构体,
go test保证「地基真的被用上了」「边界真的没被越过」。
卷一到这里就结束了。接下来请翻开卷二《单服架构与玩法系统》——那里会用这一卷造好的七个零件,把《晨曦大陆》的玩法一个接一个装起来。你在这里踩过的每一个坑(粘包、乱序、重发、瞬移),到了卷二都会以「玩法 bug」的形式再出现一次;区别只是这一次,你已经知道该往哪一层去看。
阅读导航:上一节:18.2 承载数字汇总 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。