一、最终值只有一个公式
属性系统最常见的线上事故,不是数值不平衡,而是同一个角色在不同界面算出不同攻击力。客户端按 (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 列适合存“客户端展示需要、服务端不参与结算”的扩展信息;一旦某个键进入战斗公式,就应提升为正式字段并加校验。
四、脏标记与重算
不要每帧重算属性。正确做法是“变更打标记,读取时结算”:
- 每个属性维护一个
dirty标记和一个最终值缓存。 - 基础值、装备、buff、公会科技任一变化,只标记受影响的属性。
- 战斗读取时若
dirty为真,按固定公式重算并清标记;否则直接返回缓存。 - 同一帧内多次变更只重算一次,天然合并。
要区分“属性变化”和“属性显示变化”。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 随在线人数线性上涨。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。