《游戏服务端编程实践》6.1.2 属性系统与动态扩展

用 base + add + mul 的固定公式统一属性结算,梳理等级、装备、buff、公会科技的分层来源,比较固定字段、EAV 与 JSON 列的查询和校验成本,并给出支持修正覆盖、删除回退、顺序无关与上下限钳制的 Go Attrs 实现及测试。

一、最终值只有一个公式

属性系统最常见的线上事故,不是数值不平衡,而是同一个角色在不同界面算出不同攻击力。客户端按 (base × (1+mul)) + add,服务端按 (base+add) × (1+mul),两个结果都“看起来合理”,战斗结算却对不上。解决方式不是让两边都小心,而是把公式写死在一处:先加后乘。

final = clamp((base + sum(add)) × (1 + sum(mul)), min, max)

顺序必须固定,原因很直接:加法和乘法不满足交换律。基础攻击 100,加 20、乘 50%,先加后乘是 180,先乘后加是 170。所有来源只允许贡献“加法值”或“乘法比例”,不允许自定义运算;新增系统只能往这两个桶里放,不能插队。

项含义例子
base角色自身成长值等级、职业成长、初始属性
add固定数值修正,可正可负装备攻击、宝石、光环
mul比例修正,多个比例相加狂暴 +20%、公会科技 +5%
clamp结算后的上下限攻速不低于 0,减伤不超过 90%

二、属性来源要分层

同一个 add 桶里可能同时有装备、buff、公会科技。它们的变化频率和生命周期不同,计算与缓存策略也应不同。

层来源变化时机是否持久化
基础层等级、职业、转生升级或转职是
装备层武器、防具、套装穿脱、强化是
临时层buff、药剂、场景光环秒级,进出战斗否
社交层公会科技、组队光环加入/退出、科技升级是

分层不是为了好看,而是为了缓存失效范围。升级只需要重算基础层;穿装备重算装备层;buff 到期只删一个修正。如果把所有来源摊平成一个列表,每次 buff 变化都要全量扫描装备和公会数据,高峰期会把 CPU 打满。

三、固定字段、EAV 还是 JSON

属性怎么存,取决于查询方式,而不是“哪种更灵活”。

方案优点缺点适用
固定字段类型强、可索引、查询快加属性要改表战斗、排行榜等高频核心属性
EAV加属性不改表多行拼接、类型弱、查询昂贵稀疏且低频的扩展属性
JSON 列结构灵活、一次读写校验靠应用、索引能力有限配置快照、埋点、低频杂项

核心战斗属性用固定字段:attack、defense、max_hp 放在角色表或独立属性表,查询和排序都能走索引。EAV 的 (owner_id, attr_key, value) 看似万能,但读一个角色要拼十几行,排序还要 PIVOT,线上很难优化。JSON 列适合存“客户端展示需要、服务端不参与结算”的扩展信息;一旦某个键进入战斗公式,就应提升为正式字段并加校验。

四、脏标记与重算

不要每帧重算属性。正确做法是“变更打标记,读取时结算”:

  1. 每个属性维护一个 dirty 标记和一个最终值缓存。
  2. 基础值、装备、buff、公会科技任一变化,只标记受影响的属性。
  3. 战斗读取时若 dirty 为真,按固定公式重算并清标记;否则直接返回缓存。
  4. 同一帧内多次变更只重算一次,天然合并。

要区分“属性变化”和“属性显示变化”。buff 剩余时间每秒都在变,但攻击力只在施加和移除时变;把倒计时也当成脏标记,会让缓存完全失效。

五、Go 实现

Attrs 把 base 和修正分开,修正按 ID 覆盖,删除后自动回退到剩余来源。结算前按 ID 排序,保证 map 遍历顺序不会改变浮点求和结果。

// file: ch06/model/attr.go
// tier: L1
// verify: go test ./ch06/model/

package model

import (
	"errors"
	"math"
	"sort"
)

type AttrKey string

const (
	AttrMaxHP   AttrKey = "max_hp"
	AttrAttack  AttrKey = "attack"
	AttrDefense AttrKey = "defense"

	attrMin = 0.0
	attrMax = 1e9
)

var ErrBadModifier = errors.New("attr: modifier needs an id and a finite value")

type ModifierKind uint8

const (
	ModifierAdd ModifierKind = iota // 加在基础值上,先结算
	ModifierMul                     // 比例,0.15 表示 +15%
)

type Modifier struct {
	ID    string
	Kind  ModifierKind
	Value float64
}

type attrSlot struct {
	base float64
	mods map[string]Modifier
}

// Attrs 保存一个角色的基础值与外部修正。结算顺序固定:
// (base + sum(add)) * (1 + sum(mul)),最后钳制到 [0, 1e9]。
type Attrs struct {
	slots map[AttrKey]*attrSlot
}

func NewAttrs() *Attrs { return &Attrs{slots: make(map[AttrKey]*attrSlot)} }

func (a *Attrs) slot(key AttrKey) *attrSlot {
	s, ok := a.slots[key]
	if !ok {
		s = &attrSlot{mods: make(map[string]Modifier)}
		a.slots[key] = s
	}
	return s
}

func (a *Attrs) SetBase(key AttrKey, base float64) {
	if math.IsNaN(base) || math.IsInf(base, 0) {
		return
	}
	a.slot(key).base = base
}

// AddModifier 按 ID 覆盖:buff 刷新是替换而不是叠加,避免无限增长。
func (a *Attrs) AddModifier(key AttrKey, m Modifier) error {
	if m.ID == "" || math.IsNaN(m.Value) || math.IsInf(m.Value, 0) {
		return ErrBadModifier
	}
	if m.Kind == ModifierMul && m.Value < 0 {
		return ErrBadModifier
	}
	a.slot(key).mods[m.ID] = m
	return nil
}

func (a *Attrs) RemoveModifier(key AttrKey, id string) bool {
	s, ok := a.slots[key]
	if !ok {
		return false
	}
	if _, exists := s.mods[id]; !exists {
		return false
	}
	delete(s.mods, id)
	return true
}

func (a *Attrs) Final(key AttrKey) float64 {
	s, ok := a.slots[key]
	if !ok {
		return 0
	}
	mods := make([]Modifier, 0, len(s.mods))
	for _, m := range s.mods {
		mods = append(mods, m)
	}
	// map 遍历顺序随机;先按 ID 排序,浮点求和才可复现。
	sort.Slice(mods, func(i, j int) bool { return mods[i].ID < mods[j].ID })
	var add, mul float64
	for _, m := range mods {
		switch m.Kind {
		case ModifierAdd:
			add += m.Value
		case ModifierMul:
			mul += m.Value
		}
	}
	v := (s.base + add) * (1 + mul)
	switch {
	case math.IsNaN(v) || v < attrMin:
		return attrMin
	case v > attrMax:
		return attrMax
	default:
		return v
	}
}

AddModifier 的覆盖语义很重要:同一个 buff 刷新时 ID 不变、数值更新,应该替换而不是再叠一层。否则玩家反复点同一瓶药,属性会无限增长。

六、测试

测试覆盖加乘顺序、不同插入顺序结果一致、移除修正后回退、上下限钳制,以及空 ID 和负比例被拒绝。

// file: ch06/model/attr_test.go
// tier: L1
// verify: go test ./ch06/model/

package model

import (
	"errors"
	"testing"
)

func TestAttrsAddBeforeMultiply(t *testing.T) {
	a := NewAttrs()
	a.SetBase(AttrAttack, 100)
	_ = a.AddModifier(AttrAttack, Modifier{ID: "weapon", Kind: ModifierAdd, Value: 20})
	_ = a.AddModifier(AttrAttack, Modifier{ID: "rage", Kind: ModifierMul, Value: 0.5})
	if got := a.Final(AttrAttack); got != 180 {
		t.Fatalf("Final() = %v, want 180", got)
	}
}

func TestAttrsOrderIndependent(t *testing.T) {
	mods := []Modifier{
		{ID: "guild", Kind: ModifierMul, Value: 0.25},
		{ID: "buff-a", Kind: ModifierAdd, Value: 7.5},
		{ID: "equip", Kind: ModifierAdd, Value: 12.25},
		{ID: "buff-b", Kind: ModifierMul, Value: 0.125},
	}
	orders := [][]int{{0, 1, 2, 3}, {3, 2, 1, 0}, {2, 0, 3, 1}}
	var want float64
	for i, order := range orders {
		a := NewAttrs()
		a.SetBase(AttrMaxHP, 1000)
		for _, idx := range order {
			if err := a.AddModifier(AttrMaxHP, mods[idx]); err != nil {
				t.Fatal(err)
			}
		}
		got := a.Final(AttrMaxHP)
		if i == 0 {
			want = got
			continue
		}
		if got != want {
			t.Fatalf("order %v: Final() = %v, want %v", order, got, want)
		}
	}
	if want != (1000+7.5+12.25)*1.375 {
		t.Fatalf("formula mismatch: %v", want)
	}
}

func TestAttrsRemoveFallsBackToLowerValue(t *testing.T) {
	a := NewAttrs()
	a.SetBase(AttrDefense, 100)
	_ = a.AddModifier(AttrDefense, Modifier{ID: "shield", Kind: ModifierAdd, Value: 40})
	_ = a.AddModifier(AttrDefense, Modifier{ID: "fortress", Kind: ModifierMul, Value: 0.25})
	if got := a.Final(AttrDefense); got != 175 {
		t.Fatalf("with modifiers = %v, want 175", got)
	}
	if !a.RemoveModifier(AttrDefense, "shield") {
		t.Fatal("RemoveModifier returned false for an existing modifier")
	}
	if got := a.Final(AttrDefense); got != 125 {
		t.Fatalf("after removal = %v, want 125", got)
	}
	if a.RemoveModifier(AttrDefense, "shield") {
		t.Fatal("second removal should report false")
	}
}

func TestAttrsClampsBothEnds(t *testing.T) {
	a := NewAttrs()
	a.SetBase(AttrAttack, 1e18)
	if got := a.Final(AttrAttack); got != 1e9 {
		t.Fatalf("upper clamp = %v, want 1e9", got)
	}
	a.SetBase(AttrAttack, 10)
	_ = a.AddModifier(AttrAttack, Modifier{ID: "curse", Kind: ModifierAdd, Value: -500})
	if got := a.Final(AttrAttack); got != 0 {
		t.Fatalf("lower clamp = %v, want 0", got)
	}
}

func TestAttrsModifierReplacementAndValidation(t *testing.T) {
	a := NewAttrs()
	a.SetBase(AttrAttack, 50)
	_ = a.AddModifier(AttrAttack, Modifier{ID: "potion", Kind: ModifierAdd, Value: 10})
	_ = a.AddModifier(AttrAttack, Modifier{ID: "potion", Kind: ModifierAdd, Value: 30})
	if got := a.Final(AttrAttack); got != 80 {
		t.Fatalf("replacement = %v, want 80", got)
	}
	if err := a.AddModifier(AttrAttack, Modifier{ID: "", Kind: ModifierAdd, Value: 1}); !errors.Is(err, ErrBadModifier) {
		t.Fatalf("empty id error=%v, want ErrBadModifier", err)
	}
	if err := a.AddModifier(AttrAttack, Modifier{ID: "bad", Kind: ModifierMul, Value: -0.5}); !errors.Is(err, ErrBadModifier) {
		t.Fatalf("negative mul error=%v, want ErrBadModifier", err)
	}
}

七、运行验证

cd .local/checks/gs-practice-lab && gofmt -l ch06/model && go vet ./ch06/model/ && go test ./ch06/model/ -count=1
ok  	gspractice/ch06/model	0.259s

八、失败场景:两端各算各的

某次版本更新后,玩家发现竞技场伤害比副本高 12%。排查发现客户端为了“显示更直观”,先乘后加;服务端仍是先加后乘。两边都通过了各自的单元测试,因为测试只验证了“结果大于基础值”。修复分三步:把公式和测试向量下发到客户端,服务端提供只读的 calc 接口供对账,版本发布时用同一组角色快照比对两端结果。只改客户端而不加对账,下一次仍会在别的属性上复发。

小结

属性系统的稳定来自约束:一个公式、两个桶、固定顺序、统一钳制。来源按变化频率分层,缓存才能精准失效;存储按查询方式选择,核心属性用固定字段,低频扩展才考虑 EAV 或 JSON;修正是“按 ID 覆盖”而不是无限叠加。把这些规则写进类型和测试,策划改数值时才不会改出事故。

常见错误与边界

  • 客户端计算最终属性并回传,外挂直接改包获得满属性。
  • 加法与乘法顺序在不同模块实现不一致,界面与战斗数值对不上。
  • buff 叠加没有上限,玩家叠几十层后数值溢出或出现负数。
  • 用 EAV 存核心战斗属性,每次战斗读几十行,延迟不可控。
  • 属性重算放在每帧循环里,CPU 随在线人数线性上涨。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist