9.1 属性计算与加成来源
属性是玩法系统的地基。玩家的血量、攻击、暴击,最终都由一堆来源拼出来:基础值来自等级,装备加固定值,buff 加百分比,称号再叠一层。这些来源什么时候加、什么时候乘、按什么顺序,决定了同一身装备在结算时到底打出多少伤害。
难的地方不在于加法本身,而在于三个口径必须全服统一:聚合顺序(平加先还是百分比先)、取整规则(浮点还是定点、每层取整还是最后取整)、失效时机(穿上一件装备后,哪些缓存要重算)。任何一处两端不一致,客户端显示的数字和服务端结算的数字就会差,玩家一眼就能看出来。
本节从零写 internal/attr 包,把属性从「一堆来源」算成「一个最终值」,再用卷一定好的 CmdAttrSync 0x0103 把变化同步出去。全部是 L1,go test ./internal/attr/ 可以直接跑。
本章与站内既有内容的分工
站内已有几篇文章讲过属性系统的架构、成长状态机、离线推进与体力服务的方案与取舍。本章不复述这些结论,只把第 9 章要讲的三个机制从零实现出来并跑出真结果。分工如下。
| 既有文章 | 它负责什么 | 本章只做什么 |
|---|---|---|
/game-server-attribute-calculation-architecture/ | 属性系统的整体架构与来源划分 | 只写三层聚合与增量同步的实现 |
/game-server-progression-state-machine-architecture/ | 成长状态机的状态与迁移设计 | 只写经验曲线与相位迁移(见 9.2) |
/game-server-offline-progression-architecture-design/ | 离线推进的架构与结算时机 | 只写封顶与时钟回拨的处理(见 9.3) |
/game-server-energy-stamina-service-architecture/ | 体力服务的架构与限流 | 只写回复不丢点的整数周期结算(见 9.3) |
三层聚合:平加、加算、乘算
先写核心。设计上定三条规则。第一,百分比一律用万分比定点整数(Scale = 10000),不用浮点——同一件装备穿脱几百次、buff 每秒抖动,浮点误差会一点点累积,最后服务端和客户端对不上。第二,聚合固定按「平加 → 加算百分比 → 乘算百分比」三层,顺序写死,任何来源都只能落在某一层。第三,同一属性上 Group 相同的平加只取最大值,多份食物 buff 只吃最高那份,避免「吃满一背包」这种堆叠。
下面这段是 L1,go test ./internal/attr/ 可以直接跑:
// file: internal/attr/attribute.go
// tier: L1
// verify: go test ./internal/attr/
package attr
// AttrID 是属性编号,落在配置表里,不是字符串名。
type AttrID uint16
const (
AttrMaxHP AttrID = 1
AttrMaxMP AttrID = 2
AttrATK AttrID = 3
AttrDEF AttrID = 4
)
// Scale 是百分比固定点标度:万分之几。用整数避免浮点误差在多个来源叠加时累积。
const Scale int64 = 10000
// Op 决定一条加成参与哪一层聚合。
type Op uint8
const (
OpAdd Op = iota // 平加:进入 base + Σadd
OpPercentAdd // 加算百分比:同类求和后一次性乘
OpPercentMul // 乘算百分比:各自独立连乘
)
// Source 标记加成来自哪里,用于审计与整组移除。
type Source uint8
const (
SrcBase Source = iota
SrcEquip
SrcBuff
SrcTitle
)
// Modifier 是一条加成来源。Group 用于同类取最强:同一属性上 Group 相同、
// Op 为 OpAdd 的修正只保留最大值(例如多份食物 buff 只吃最高那份)。
type Modifier struct {
Source Source
Group string
Attr AttrID
Op Op
Value int64
}
// AttributeSet 保存基础属性与全部加成,Compute 时才聚合成最终值。
type AttributeSet struct {
base map[AttrID]int64
mods []Modifier
final map[AttrID]int64
dirty bool
}
func NewAttributeSet() *AttributeSet {
return &AttributeSet{base: map[AttrID]int64{}, final: map[AttrID]int64{}, dirty: true}
}
// SetBase 设置某个属性的基础值(来自等级曲线,见 9.2)。
func (s *AttributeSet) SetBase(a AttrID, v int64) {
s.base[a] = v
s.dirty = true
}
// Add 追加一条加成。装备穿上、buff 生效都走这里。
func (s *AttributeSet) Add(m Modifier) {
s.mods = append(s.mods, m)
s.dirty = true
}
// Remove 按「来源 + 组」整组移除。卸装备、buff 到期的正确粒度是整组,
// 而不是按数值删——同一件装备可能对多个属性各有加成。
func (s *AttributeSet) Remove(source Source, group string) {
out := s.mods[:0]
for _, m := range s.mods {
if m.Source == source && m.Group == group {
continue
}
out = append(out, m)
}
s.mods = out
s.dirty = true
}
// Compute 返回某属性的最终值。任何 Add/Remove/SetBase 之后首次查询会触发一次全量重建。
func (s *AttributeSet) Compute(a AttrID) int64 {
if s.dirty {
s.rebuild()
}
return s.final[a]
}
// rebuild 只在脏标记置位时跑一次,把全部属性算好放进缓存。
func (s *AttributeSet) rebuild() {
s.final = make(map[AttrID]int64, len(s.base))
for a := range s.base {
s.final[a] = s.computeOne(a)
}
// 只在加成里出现、没有基础值的属性也要算(例如纯 buff 提供的暴击率)。
for _, m := range s.mods {
if _, ok := s.final[m.Attr]; !ok {
s.final[m.Attr] = s.computeOne(m.Attr)
}
}
s.dirty = false
}
// computeOne 按「平加 → 加算百分比 → 乘算百分比」三层聚合。
func (s *AttributeSet) computeOne(a AttrID) int64 {
// 先求每个 Group 里 OpAdd 的最大值,用于同类取最强。
best := map[string]int64{}
for _, m := range s.mods {
if m.Attr != a || m.Op != OpAdd || m.Group == "" {
continue
}
if v, ok := best[m.Group]; !ok || m.Value > v {
best[m.Group] = m.Value
}
}
var add, pctAdd int64
mul := Scale // 10000 表示 1.0
applied := map[string]bool{}
for _, m := range s.mods {
if m.Attr != a {
continue
}
switch m.Op {
case OpAdd:
if m.Group != "" {
if applied[m.Group] {
continue
}
applied[m.Group] = true
add += best[m.Group]
continue
}
add += m.Value
case OpPercentAdd:
pctAdd += m.Value
case OpPercentMul:
mul = mul * (Scale + m.Value) / Scale
}
}
v := s.base[a] + add
v = v * (Scale + pctAdd) / Scale
v = v * mul / Scale
if v < 0 {
v = 0
}
return v
}
为什么是三层而不是两层?看加算与乘算的差别:两条 +20%,加算是 (1+0.2+0.2)=1.4,乘算是 1.2×1.2=1.44。computeOne 里加算把所有 OpPercentAdd 先求和再一次乘,乘算则逐个连乘——同一批 buff,落哪一层直接决定最终数值。测试 TestAdditiveVsMultiplicative 就是钉这条:加算得 140、乘算得 144,谁改错一层立刻红。
Group 的去重要按整组做。applied 这张表保证同一组只加一次,且加的是 best[group] 那个最大值;若偷懒写成「每遇到一条就比一次」,两条同值 buff 会被加两次。TestSameGroupTakesStrongest 里两条 food 组、值 30 和 50,期望结果 150(吃 50),而不是 180。
缓存失效:重算只在变更时发生
属性查询在战斗里是热点:一次技能结算要读攻击、防御、暴击好几个属性,每秒可能几十次。如果每次 Compute 都遍历全部加成,mods 一多就成了瓶颈。这里的做法是脏标记 + 全量重建:Add / Remove / SetBase 只把 dirty 置位,真正的聚合推迟到下一次 Compute 才做,一次把所有属性算完塞进 final 缓存。
为什么是「全量重建」而不是「只重算被问到的那个属性」?因为一次装备变更几乎总会牵动多个属性(一件盔甲同时给血、防、韧性),而 rebuild 只跑一次、循环里按属性分桶,均摊下来比逐属性重算更省。代价是缓存里可能存了一些本次没人问的属性,但属性总数是个位到几十的量级,这点浪费可以忽略。
Remove 按「来源 + 组」整组删,而不是按数值删,也是同一考量:卸下武器要一次性摘掉它带来的所有加成,按数值删会误伤别的来源恰好同值的加成。测试 TestCacheInvalidationAndRemove 覆盖了「加上→缓存命中→卸下→回基线」这条链路。
增量同步:复用 CmdAttrSync 0x0103
属性算完了要发给客户端。卷一已经占用世界域命令号 CmdAttrSync 0x0103,本节直接复用,不另起炉灶。帧格式沿用卷一的固定头:length(4, BE) | cmd(2, BE) | seq(4, BE) | flags(1),length = 7 + N,帧总长 11 + N。进世界时发一次全量快照,之后只走增量——属性每秒都可能因 buff 抖动,全量重发会把带宽吃光。
下面这段同样是 L1,go test ./internal/attr/ 会一并跑到:
// file: internal/attr/attrsync.go
// tier: L1
// verify: go test ./internal/attr/
package attr
import (
"encoding/binary"
"errors"
"sort"
)
// CmdAttrSync 复用卷一世界域的命令号 0x0103,不另起炉灶。
const CmdAttrSync uint16 = 0x0103
const (
HeaderSize = 11 // length(4) + cmd(2) + seq(4) + flags(1)
FlagsNone = 0
)
var ErrShortFrame = errors.New("attr: 帧长度不足")
// AttrDelta 是同步里的一项:只发发生变化的属性。
type AttrDelta struct {
ID AttrID
Value int64
}
// EncodeAttrSync 把变化集编成一条完整帧:11 字节头 + 计数(2) + 每项 10 字节。
// 进世界时发一次全量快照,之后只走增量——属性每秒都可能因 buff 抖动,
// 全量重发会把带宽吃光。
func EncodeAttrSync(seq uint32, deltas []AttrDelta) []byte {
// 先排序,保证同一集合编出同一串字节,便于测试与抓包比对。
sort.Slice(deltas, func(i, j int) bool { return deltas[i].ID < deltas[j].ID })
payload := make([]byte, 0, 2+len(deltas)*10)
payload = binary.BigEndian.AppendUint16(payload, uint16(len(deltas)))
for _, d := range deltas {
payload = binary.BigEndian.AppendUint16(payload, uint16(d.ID))
payload = binary.BigEndian.AppendUint64(payload, uint64(d.Value))
}
out := make([]byte, HeaderSize, HeaderSize+len(payload))
binary.BigEndian.PutUint32(out[0:], uint32(7+len(payload))) // length = cmd+seq+flags+payload
binary.BigEndian.PutUint16(out[4:], CmdAttrSync)
binary.BigEndian.PutUint32(out[6:], seq)
out[10] = FlagsNone
return append(out, payload...)
}
// DecodeAttrSync 解析 EncodeAttrSync 的产物,越界返回错误而非 panic。
func DecodeAttrSync(frame []byte) (uint32, []AttrDelta, error) {
if len(frame) < HeaderSize {
return 0, nil, ErrShortFrame
}
if cmd := binary.BigEndian.Uint16(frame[4:]); cmd != CmdAttrSync {
return 0, nil, errors.New("attr: 命令号不是 CmdAttrSync")
}
seq := binary.BigEndian.Uint32(frame[6:])
body := frame[HeaderSize:]
if len(body) < 2 {
return 0, nil, ErrShortFrame
}
n := int(binary.BigEndian.Uint16(body))
body = body[2:]
if len(body) < n*10 {
return 0, nil, ErrShortFrame
}
deltas := make([]AttrDelta, n)
for i := 0; i < n; i++ {
deltas[i] = AttrDelta{
ID: AttrID(binary.BigEndian.Uint16(body[i*10:])),
Value: int64(binary.BigEndian.Uint64(body[i*10+2:])),
}
}
return seq, deltas, nil
}
编码前先 sort 一次,是为了让同一组变化永远编出同一串字节——测试能直接比对字节,抓包时也容易肉眼对。AttrID 用 uint16、值用 uint64,每项固定 10 字节;属性个数通常不超过几十,一次同步几百字节封顶。解码端每一步都先校验长度,n 是被网络传来、攻击者可控的,body[i*10:] 若不先确认 len(body) >= n*10 就是一次越界切片。TestAttrSyncRoundTrip 除了往返一致,还专门喂了一个截断帧,断言返回 ErrShortFrame 而不是 panic。
本节小结 / 实测结论
本机(Go 1.26.0,darwin/arm64)实跑结果:
$ go vet ./...
(无输出)
$ go test ./internal/attr/
ok aegis/internal/attr 0.4s (15 个 test 全绿)
本节定下三个口径:百分比走万分比定点、聚合固定三层、缓存按脏标记全量重建;同步复用 CmdAttrSync 0x0103,只发增量。属性算出来了,下一步要回答的是「基础值从哪来」——等级。9.2 写经验曲线与成长状态机,SetBase 里那个基础值正是它喂进来的。以上数字是同机相对量级,绝对数值不可移植。
阅读导航:上一节:目录 · 下一节:9.2 等级与成长状态机 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。