《游戏服务端编程实践》8.2.2 Buff / 技能系统建模

把 Buff 拆成来源、层数、持续时间、叠加规则与移除条件,把技能拆成消耗、冷却、目标选择与效果列表,并用 BuffSet 实现独立、共存、覆盖、刷新四类规则以及可断言的到期顺序与属性聚合,同时说明效果与 Buff 解耦、属性重算的固定结算顺序,并给出容器级测试。

一、Buff 要回答的五个问题

Buff 不是“一个持续掉血的标记”,而是一组必须同时说清楚的状态:谁施加的、叠了几层、还剩多久、再次施加时怎么处理、什么情况下提前移除。少回答一个,线上就会出现“毒叠了 99 层”“刷新后持续时间没变”“驱散只删了显示没删属性”之类的问题。

维度必须明确的语义常见含糊实现
来源施法者 ID、技能 ID、是否按来源独立只存 Buff ID,无法区分两个玩家的毒
层数当前层数、最大层数、每层效果层数无上限,伤害指数增长
持续时间总时长、剩余时长、计时单位用墙钟时间,暂停或加速时错乱
叠加规则叠层、刷新、覆盖、忽略所有 Buff 一律覆盖
移除条件到期、驱散、死亡、离开场景只处理到期,死亡后属性仍生效

计时单位建议用逻辑帧或固定 tick,而不是 time.Now()。墙钟会受系统时间调整、服务器暂停、副本加速影响;逻辑帧与战斗推进一致,回放时也能按帧重演。展示层再把剩余帧换算成秒。

二、叠加与刷新:四种规则

同一 Buff 再次施加时,至少有四种合理行为。它们没有优劣,只有是否适合具体效果;错误来自“所有 Buff 用同一套默认逻辑”。

规则层数变化持续时间变化适用例子
叠层增加,受最大值限制重置为新持续时间中毒、流血、连击增伤
刷新不变重置为新持续时间护盾、加速、领队光环
覆盖用新实例替换旧实例取新实例时长同名但强度不同的控制
忽略不变不变已存在时不允许重复施加的标记

“按来源独立”是另一个维度,不能和叠加规则混在一起。两个玩家给同一个目标上同一种毒,通常应该有两个独立实例,各自按施法者结算;而一个玩家的护盾被另一个玩家的护盾覆盖,则应该比较强度。实现上要么把来源并入实例键,要么在 Buff 定义里显式声明 PerSource,不要靠调用方自觉。

层数必须有上限。没有上限的叠层在长时间战斗或异常重发下会变成无限增长,最终打穿数值和网络包大小。上限既要在施加时截断,也要在配置校验时拒绝 MaxStacks <= 0 的定义。

三、技能建模:消耗、冷却、目标、效果

技能是“一次被允许发生的状态变更”,至少包含四块:消耗、冷却、目标选择、效果列表。消耗回答扣什么、扣多少、不够时怎么拒绝;冷却回答何时可再次使用、剩余时间如何展示;目标选择回答单选还是多选、范围形状、是否包括友方;效果列表回答命中后发生什么。

效果与 Buff 解耦是关键。技能不应该硬编码“如果目标是敌人就扣血,如果是友方就加血”,而应该产出一组效果,由效果执行器统一处理。这样新增一个“吸血”效果时,不需要修改所有技能;新增一个“反伤”Buff 时,也不需要让技能知道 Buff 的内部结构。

一个实用的拆分是:

  • Skill:ID、消耗、冷却、施法距离、目标类型、效果 ID 列表。
  • Effect:伤害、治疗、施加 Buff、移除 Buff、位移、召唤。
  • Buff:持续状态,引用效果列表,按层数放大。
  • Resolver:按结算顺序执行效果,负责死亡检查和事件产出。

技能表与 Buff 表通常来自配置,但服务器必须在加载时做一致性校验:引用的效果是否存在、层数上限是否合法、持续时间是否为正、冷却是否小于持续时间(否则会出现永远无法刷新的矛盾配置)。

四、结算顺序:属性重算 → 效果执行 → 死亡检查 → 事件

一次技能命中的结算顺序必须固定,否则同一份配置在不同代码路径下会得到不同结果。推荐顺序是:先重算属性,再执行效果,然后检查死亡,最后产出事件。

属性重算把基础属性、装备加成、Buff 加成合并成当前属性。必须在效果执行前完成,因为伤害公式可能读取攻击力,治疗公式可能读取治疗加成。效果执行阶段只读当前属性,不再回头修改 Buff 层数;需要修改 Buff 的效果(例如“命中后叠加一层中毒”)作为独立效果排在伤害之后。

死亡检查放在全部效果执行完之后。如果每段伤害后都立即检查死亡,多段技能的第二段就可能打在一个已经死亡的目标上,产生“鞭尸”日志或错误仇恨。统一在效果结束后检查,可以让多段伤害完整结算,再由死亡流程处理掉落、经验和移除。

事件在死亡检查之后产出。事件是“已经发生的事实”,不是“准备做什么”。如果先发事件再改状态,客户端可能收到“目标死亡”但服务器状态还没提交,回放时也会出现事件与状态不一致。

五、失败场景:map 遍历导致同帧结算不一致

某次战斗更新把 Buff 容器从切片改成 map[int32]Buff,理由是“按 ID 查找更快”。上线后玩家反馈同一套装备、同一个技能,有时伤害高一点,有时低一点。排查发现,结算时遍历 map 把多个百分比加成依次乘到属性上,而 Go 的 map 遍历顺序不固定;先乘 10% 再乘 20%,和先乘 20% 再乘 10%,在浮点数和取整规则下可能得到不同结果。

更严重的是到期处理。两个 Buff 在同一帧到期,一个减攻击、一个加攻击,如果遍历顺序不同,死亡检查看到的是不同攻击力,可能一边判定存活、一边判定死亡。录像回放时顺序又变了一次,问题无法稳定复现。

修复方式不是“给 map 加锁”,而是换成有序切片:施加时追加,结算时按固定顺序遍历,到期时按同一顺序移除。查找可以用辅助索引,但索引只用于定位,不决定结算顺序。本文的 BuffSet 就采用这个结构,Snapshot 返回副本,Tick 按插入顺序返回到期实例。

六、Go 实现

BuffSet 用切片保存实例,Apply 按 StackRule 处理叠层、刷新、覆盖和忽略,Tick 扣减持续时间并按插入顺序返回到期实例,Mods 聚合属性加成。PerSource 控制同 ID 不同来源是否独立。

// file: ch08/skill/buff.go
// tier: L1
// verify: go test ./ch08/skill/

package skill

import (
	"errors"
	"fmt"
	"sort"
)

var ErrInvalidBuff = errors.New("invalid buff")

type StackRule int

const (
	// StackAdd 叠加层数并刷新持续时间,层数受 MaxStacks 限制。
	StackAdd StackRule = iota
	// StackRefresh 不改变层数,只把持续时间重置为新值。
	StackRefresh
	// StackReplace 仅在强度不弱于旧实例时整体覆盖。
	StackReplace
	// StackIgnore 已存在同源实例时直接忽略。
	StackIgnore
)

func (r StackRule) String() string {
	switch r {
	case StackAdd:
		return "add"
	case StackRefresh:
		return "refresh"
	case StackReplace:
		return "replace"
	case StackIgnore:
		return "ignore"
	default:
		return "unknown"
	}
}

type Effect struct {
	Stat string
	Add  int
}

// Buff 只描述数据和叠加规则,具体效果由 Effect 列表表达,二者解耦。
type Buff struct {
	ID        int32
	Source    uint64
	Name      string
	Stacks    int
	MaxStacks int
	Duration  int
	Remaining int
	Rule      StackRule
	PerSource bool
	Effects   []Effect
}

// BuffSet 用切片保存结算顺序,避免 map 遍历带来的不确定性。
type BuffSet struct {
	items []Buff
}

func NewBuffSet() *BuffSet { return &BuffSet{} }

func (s *BuffSet) Len() int { return len(s.items) }

func (s *BuffSet) Snapshot() []Buff {
	out := make([]Buff, len(s.items))
	copy(out, s.items)
	for i := range out {
		out[i].Effects = append([]Effect(nil), out[i].Effects...)
	}
	return out
}

// Active 返回指定 Buff ID 的第一个实例,用于查询展示。
func (s *BuffSet) Active(id int32) (Buff, bool) {
	for _, b := range s.items {
		if b.ID == id {
			return b, true
		}
	}
	return Buff{}, false
}

func (s *BuffSet) find(b Buff) int {
	for i := range s.items {
		if s.items[i].ID != b.ID {
			continue
		}
		if (b.PerSource || s.items[i].PerSource) && s.items[i].Source != b.Source {
			continue
		}
		return i
	}
	return -1
}

// Apply 按叠加规则写入 Buff,返回最终实例以及状态是否发生变化。
func (s *BuffSet) Apply(b Buff) (Buff, bool, error) {
	if b.ID == 0 || b.Duration <= 0 || b.Stacks <= 0 {
		return Buff{}, false, fmt.Errorf("%w: id=%d duration=%d stacks=%d", ErrInvalidBuff, b.ID, b.Duration, b.Stacks)
	}
	if b.MaxStacks <= 0 {
		b.MaxStacks = b.Stacks
	}
	if b.Stacks > b.MaxStacks {
		b.Stacks = b.MaxStacks
	}
	if b.Remaining <= 0 {
		b.Remaining = b.Duration
	}
	i := s.find(b)
	if i < 0 {
		s.items = append(s.items, b)
		return b, true, nil
	}
	cur := s.items[i]
	switch b.Rule {
	case StackAdd:
		cur.Stacks += b.Stacks
		if cur.Stacks > b.MaxStacks {
			cur.Stacks = b.MaxStacks
		}
		if b.MaxStacks > cur.MaxStacks {
			cur.MaxStacks = b.MaxStacks
		}
		cur.Duration = b.Duration
		cur.Remaining = b.Duration
	case StackRefresh:
		cur.Duration = b.Duration
		cur.Remaining = b.Duration
	case StackReplace:
		if b.Stacks < cur.Stacks || b.Duration < cur.Remaining {
			return cur, false, nil
		}
		cur.Stacks = b.Stacks
		cur.MaxStacks = b.MaxStacks
		cur.Duration = b.Duration
		cur.Remaining = b.Remaining
		cur.Effects = append([]Effect(nil), b.Effects...)
	case StackIgnore:
		return cur, false, nil
	default:
		return cur, false, fmt.Errorf("%w: unknown rule %d", ErrInvalidBuff, b.Rule)
	}
	s.items[i] = cur
	return cur, true, nil
}

// Tick 按切片顺序扣减持续时间,并按同一顺序返回到期实例。
func (s *BuffSet) Tick(frames int) []Buff {
	if frames <= 0 {
		return nil
	}
	kept := s.items[:0]
	var expired []Buff
	for _, b := range s.items {
		b.Remaining -= frames
		if b.Remaining <= 0 {
			b.Remaining = 0
			expired = append(expired, b)
			continue
		}
		kept = append(kept, b)
	}
	s.items = kept
	return expired
}

// Mods 把全部 Buff 的属性加成聚合为稳定排序的结果,层数参与乘法。
func (s *BuffSet) Mods() []Effect {
	var out []Effect
	for _, b := range s.items {
		for _, e := range b.Effects {
			add := e.Add * b.Stacks
			merged := false
			for i := range out {
				if out[i].Stat == e.Stat {
					out[i].Add += add
					merged = true
					break
				}
			}
			if !merged {
				out = append(out, Effect{Stat: e.Stat, Add: add})
			}
		}
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].Stat < out[j].Stat })
	return out
}

实现上有三个刻意选择。第一,MaxStacks <= 0 时把上限退化为本次施加的层数,避免配置遗漏导致无限叠层;第二,StackReplace 只在层数和持续时间都不弱于旧实例时覆盖,防止低级技能顶掉高级 Buff;第三,Mods 先按 Buff 顺序聚合,再对结果按属性名排序,输出稳定,测试可以直接比较。

七、测试与运行

测试覆盖独立实例、不同 Buff 共存、叠层上限与刷新、只刷新时间、覆盖强度判断、到期顺序和非法配置。其中到期测试构造两个同帧到期的 Buff,断言返回顺序与插入顺序一致;覆盖测试用一个“层数更低但时间更长”的实例验证不会顶掉旧 Buff。

cd .local/checks/gs-practice-lab && gofmt -l ch08/skill && go vet ./ch08/skill/ && go test ./ch08/skill/ -count=1
ok  	gspractice/ch08/skill	0.293s

这些测试验证的是容器语义,不验证具体数值平衡。技能伤害、Buff 层数系数、冷却时间属于配置与数值测试,应该用表格驱动的方式单独覆盖。

小结

Buff 建模的重点不是字段数量,而是把来源、层数、时长、叠加规则和移除条件显式化;技能建模的重点是把消耗、冷却、目标和效果拆开,让效果与 Buff 互不依赖。结算顺序固定为属性重算、效果执行、死亡检查、事件产出,容器使用有序切片而不是 map 遍历,才能让同一份输入稳定地得到同一份输出。

常见错误与边界

  • 用 map 保存 Buff 并直接遍历结算,同帧顺序不可复现。
  • 层数没有上限,长时间战斗或重发后数值失控。
  • 刷新只改显示时间,没有重置内部剩余时间,Buff 提前消失。
  • 覆盖规则不比较强度,低级技能顶掉高级 Buff。
  • 所有 Buff 都按来源独立,导致同一玩家的同类 Buff 重复叠加。
  • 到期处理只删数据不移除属性,玩家死亡后仍有加速或护盾。
  • 技能硬编码敌我判断,新增效果必须改所有技能。
  • 先发事件再改状态,客户端看到的事实与服务器不一致。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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