18.1 把玩法系统接进 aegis
前面十七节各写各的:第 4 章写了 AOI,第 6 章写了战斗管线,第 7 章写了掉落表,第 8 章写了背包,第 10 章写了任务,第 12 章写了队伍。每一节都能 go test 跑绿,但把它们放在一起,它们还是十四个互不相识的包——没有人告诉服务端「收到 CmdSkillCastReq 该找谁」,也没有人告诉世界「一个玩家进世界之后,他的背包和任务进度挂在哪」。
这一节要做的事,就是把玩法系统接进卷一造好的 aegis。它不写新玩法,只做三件装配:把每个玩法域的命令注册进同一张路由表;把世界、战斗、掉落、背包、任务、队伍装进一个「一个进程、一个世界」的 Server;把玩法命令接回卷一的帧协议。装配完的判据不是「代码能编译」,而是端到端能跑通并断言——这一点留给 18.2 的验收脚本。
装配看起来只是「把几个 struct 拼起来」,但真正的难点在边界。玩法命令进的是卷一那张 cmd → handler 表,意味着命令号必须落在卷二约定的 0x04–0x0C 段,不能和卷一的 0x00–0x03 撞车;世界里的每个变更都要经过世界自己的串行上下文,不能有第二个写者;掉落和任务的发奖必须走幂等,否则重发一次请求就发两次奖。装配错了,前面十七节的正确性会一次性塌掉。
分工表:站内架构文章与本节各写一半
站内已经有一批「MMO 服务端参考架构」文章,把玩法系统该分成哪些服务、服务之间怎么交互讲得很细。本节和它们的分工,跟整卷与站内内容的分工一致:别人讲架构方案与取舍,本节讲从零装配 + 真跑 + 端到端验收。
| 既有文章负责 | 本节只补 |
|---|---|
/game-server-reference-architecture-for-midcore-online-games/
讲中型在线游戏的服务端分层、模块边界与玩法域如何切分 | 把这些域装进一个 Server、注册进一张路由表、接回卷一的帧协议,并跑通验收 |
也就是说:那篇文章回答「应该分成哪些模块、边界画在哪」;本节回答「这些模块的代码长什么样、怎么在同一个进程里装配起来、装配完怎么证明它能跑」。
装配点一:每个玩法域往同一张路由表登记
卷一定下了「命令号 → 处理函数」的分发模型,卷二不再另起一套。每个玩法域要做的事只有一件:把自己的命令号注册进同一张 Registry。下面这段是 L1,go test ./internal/gameplay/ 可以直接跑——它是路由表本体,注册重复的命令号会直接报错:
// file: internal/gameplay/wire.go
// tier: L1
// verify: go test ./internal/gameplay/
package gameplay
import (
"encoding/binary"
"fmt"
"sort"
)
// Session 是一个在线会话:角色、运行时实体与所在实例。玩法逻辑用它定位玩家实体。
type Session struct {
PlayerID PlayerID
Entity EntityID
Instance InstanceID
}
// Handler 处理一条已解码的帧,返回应答帧(nil 表示无应答,例如纯通知)。
type Handler func(s *Session, f *Frame) (*Frame, error)
// Registry 是命令号 -> handler 的路由表。卷二把每个玩法命令注册进来,
// 复用卷一「cmd → handler」的分发模型,而不是另起一套。
type Registry struct {
handlers map[uint16]Handler
}
// NewRegistry 建一张空路由表。
func NewRegistry() *Registry { return &Registry{handlers: make(map[uint16]Handler)} }
// Register 注册一条命令。同一命令号注册两次会返回 ErrDuplicateCommand——
// 这是防「两个玩法抢同一个命令号」的断言。
func (r *Registry) Register(cmd uint16, h Handler) error {
if _, dup := r.handlers[cmd]; dup {
return fmt.Errorf("%w: 0x%04X", ErrDuplicateCommand, cmd)
}
r.handlers[cmd] = h
return nil
}
// Commands 返回已注册的命令号(有序)。
func (r *Registry) Commands() []uint16 {
out := make([]uint16, 0, len(r.handlers))
for cmd := range r.handlers {
out = append(out, cmd)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
// Has 报告某命令号是否已注册。
func (r *Registry) Has(cmd uint16) bool {
_, ok := r.handlers[cmd]
return ok
}
// Dispatch 分发一条帧到对应 handler,返回应答帧。未注册则报错。
func (r *Registry) Dispatch(s *Session, f *Frame) (*Frame, error) {
h, ok := r.handlers[f.Cmd]
if !ok {
return nil, fmt.Errorf("gameplay: no handler for cmd 0x%04X", f.Cmd)
}
return h(s, f)
}
// respFrame 用请求的 seq 组一条应答帧。
func respFrame(req *Frame, cmd uint16, payload []byte) *Frame {
return &Frame{Cmd: cmd, Seq: req.Seq, Flags: req.Flags, Payload: payload}
}
// u64 把负载当单个 uint64 读(不足补零)。
func u64(p []byte) uint64 {
var b [8]byte
copy(b[:], p)
return binary.BigEndian.Uint64(b[:])
}
// putU64 把 uint64 编码成 8 字节大端。
func putU64(v uint64) []byte {
return binary.BigEndian.AppendUint64(nil, v)
}
// putU32 把 uint32 编码成 4 字节大端。
func putU32(v uint32) []byte {
return binary.BigEndian.AppendUint32(nil, v)
}
命令号本身也是契约的一部分,必须逐字复用卷二的约定,不能自创。下面这段片段列出了卷二新增的 0x04–0x0C 段(snippet: 表示它只是契约清单,运行在同一个包里):
// snippet: 卷二新增的玩法域命令号(0x04–0x0C),全卷统一
const (
CmdSkillCastReq uint16 = 0x0401 // 战斗
CmdItemUseReq uint16 = 0x0501 // 背包
CmdQuestAcceptReq uint16 = 0x0601 // 任务
CmdQuestFinishReq uint16 = 0x0603
CmdPartyInviteReq uint16 = 0x0701 // 组队
CmdGuildJoinReq uint16 = 0x0801 // 公会
CmdRankQueryReq uint16 = 0x0901 // 排行
CmdMailListReq uint16 = 0x0A01 // 邮件
CmdAuctionBidReq uint16 = 0x0B02 // 交易
CmdMatchJoinReq uint16 = 0x0C01 // 匹配
)
一张表 + 一段命令号清单,就是「接进 aegis」的第一层含义:玩法域没有自己的入口,它们共用卷一的那一个入口。 这也是为什么卷一要把路由表写成通用机制——通用的东西才复用得起。
装配点二:一个进程、一个世界
第二层是把玩法域装进一个 Server。卷二的假设是「一个进程、一个世界」,所以 Server 里世界、战斗、掉落、背包、任务、队伍各只有一份,所有玩家的玩法都跑在同一个世界里。下面这段片段是装配本体(snippet:,与上段同一个包):
// snippet: Server 把玩法域装成一个「一个进程、一个世界」
type Server struct {
World *World // 场景实例:实体 + 九宫格 AOI
Combat *Combat // 战斗管线:冷却 + 伤害结算
Loot *Loot // 掉落服务:掉落物 + 到期回收
LootTable *LootTable // 掉落表
Reg *Registry // 路由表
byPlay map[PlayerID]EntityID
bags map[PlayerID]*Bag
quests map[PlayerID]*QuestLog
parties map[PartyID]*Party
}
// Wire 把所有玩法命令注册进路由表。装配完成即端到端可用。
func (s *Server) Wire() error {
regs := []struct {
cmd uint16
h Handler
}{
{CmdSkillCastReq, s.handleSkillCast},
{CmdItemUseReq, s.handleItemUse},
{CmdQuestAcceptReq, s.handleQuestAccept},
{CmdQuestFinishReq, s.handleQuestFinish},
{CmdPartyInviteReq, s.handlePartyInvite},
}
for _, r := range regs {
if err := s.Reg.Register(r.cmd, r.h); err != nil {
return err
}
}
return nil
}
装配里有一处必须讲清:handleSkillCast 在击杀怪物时会落地掉落物,handleItemUse 拾取时入包,handleQuestFinish 发奖时只发一次。三处副作用都写在 handler 里,因为它们都是「一次玩法操作」的组成部分,而不是独立的系统调用。下面这段片段是战斗与背包两个 handler(snippet:,同一个包):
// snippet: 击杀落地掉落、拾取入包,副作用都写在 handler 里
func (s *Server) handleSkillCast(sess *Session, f *Frame) (*Frame, error) {
target := EntityID(u64(f.Payload))
dmg, killed := s.Combat.Cast(s.skill, sess.Entity, target)
if killed {
items := s.LootTable.Roll(sess.PlayerID, s.Combat.tick, 1)
s.Loot.SpawnDrops(sess.PlayerID, items, 50) // 击杀 -> 落地掉落物
}
payload := putU32(uint32(dmg))
if killed {
payload = append(payload, 1)
} else {
payload = append(payload, 0)
}
return respFrame(f, CmdSkillCastResp, payload), nil
}
func (s *Server) handleItemUse(sess *Session, f *Frame) (*Frame, error) {
item, ok := s.Loot.Pickup(u64(f.Payload)) // 拾取即从掉落表移除,重复拾取失败
if ok {
s.Bag(sess.PlayerID).Add(item, 1) // 拾取 -> 入包
}
payload := putU32(uint32(item))
if ok {
payload = append(payload, 1)
} else {
payload = append(payload, 0)
}
return respFrame(f, CmdItemUseResp, payload), nil
}
两个 handler 都把「一次操作」的全部后果收在自己的调用链里:Cast 负责扣血与冷却,SpawnDrops 负责落地,Pickup 负责去重,Add 负责入包。发奖走幂等(Finish 对已领取的任务返回 false),这是卷一的幂等约定第一次用在玩法上。
装配点三:接回卷一的帧协议
第三层是把玩法命令接回卷一的帧协议。玩法不需要自己的编解码,它复用的还是那个 11 字节固定头:length(4, BE) | cmd(2, BE) | seq(4, BE) | flags(1),length = 7 + N,帧总长 11 + N。下面这段是 L1,go test 可以跑帧的往返:
// file: internal/gameplay/frame_test.go
// tier: L1
// verify: go test ./internal/gameplay/
package gameplay
import (
"bytes"
"testing"
)
// TestFrameRoundTrip 断言帧的编码 / 解码 / 读取一致:粘包由长度前缀保证。
func TestFrameRoundTrip(t *testing.T) {
f := &Frame{Cmd: CmdSkillCastReq, Seq: 42, Flags: 1, Payload: putU64(7)}
var buf bytes.Buffer
buf.Write(Header{Length: uint32(7 + len(f.Payload)), Cmd: f.Cmd, Seq: f.Seq, Flags: f.Flags}.EncodeInto(nil))
buf.Write(f.Payload)
got, err := ReadFrame(&buf)
if err != nil {
t.Fatalf("ReadFrame: %v", err)
}
if got.Cmd != f.Cmd || got.Seq != f.Seq || got.Flags != f.Flags {
t.Fatalf("帧头不一致:%+v", got)
}
if u64(got.Payload) != 7 {
t.Fatalf("负载 = %d, 期望 7", u64(got.Payload))
}
}
装配到这里就完成了:命令进了路由表、玩法进了 Server、帧走卷一的协议。本机跑装配后的注册与分发测试,结果如下:
$ go test -run 'TestWire|TestRegister|TestDispatch' -v ./internal/gameplay/
=== RUN TestWireRegistersGameplayCommands
--- PASS: TestWireRegistersGameplayCommands (0.00s)
=== RUN TestRegisterRejectsDuplicate
--- PASS: TestRegisterRejectsDuplicate (0.00s)
=== RUN TestDispatchSkillCast
--- PASS: TestDispatchSkillCast (0.00s)
=== RUN TestDispatchUnknownCommand
--- PASS: TestDispatchUnknownCommand (0.00s)
PASS
ok aegis/internal/gameplay 0.198s
本节小结 / 实测结论
- 接进 aegis 只有三件装配:每个玩法域往同一张
cmd → handler路由表登记;世界、战斗、掉落、背包、任务、队伍装进一个「一个进程、一个世界」的Server;玩法命令复用卷一的 11 字节帧协议,不另起一套编解码。 - 命令号是契约:卷二新增的玩法命令落在
0x04–0x0C段,不得与卷一的0x00–0x03撞车;Register对重复命令号直接报错,把「两个玩法抢一个命令号」变成一条会失败的断言。 - 副作用写在 handler 里:击杀落地掉落、拾取入包、交任务发奖都是「一次玩法操作」的组成部分;发奖走幂等(重复
Finish返回false),这是卷一的幂等约定第一次用在玩法上。 - 本机实测:装配后的注册、重复注册拒绝、技能分发、未注册命令拒绝四条测试全绿;装配完成不等于能玩,端到端验收见 18.2。
阅读导航:上一节:目录 · 下一节:18.2 《晨曦大陆》单服可玩 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。