《MMO 服务器开发》6.2 技能与 Buff 建模

战斗里最容易写崩的两块是技能冷却和 Buff 叠加。本节给 aegis 从零建技能与 Buff 模型:施法为什么拆成请求、前摇、命中三段,技能冷却与公共冷却怎么记,Buff 的刷新与叠层两种策略怎么选、周期伤害怎么按 tick 精确结算,并用真跑的 L1 测试把每条边界钉死。

6.2 技能与 Buff 建模

上一节把命令从连接线程安全地送到了场景线程,现在手上有一条排好序的 CmdSkillCastReq 0x0401。接下来要回答的问题是:这条命令该怎么变成一个「命中」事件?

很多人的第一版实现是「收到施法请求 → 立刻算伤害 → 立刻扣血 → 立刻回包」。它在单人打木桩时没问题,一旦有前摇、有冷却、有 Buff、有移动中的目标,就会处处对不上。真正需要拆开的是三个时刻:请求(客户端按下技能键)、前摇(技能在角色身上转圈,此时还能被打断、目标还会跑)、命中(伤害真正落地的那一刻)。服务器只信请求,前摇和命中之间的世界已经变了,所以施法不能是一个函数,得是一条时间线上的三个点。

Buff 则是另一半麻烦。Buff 要能叠加、能刷新、能按 tick 持续掉血或回血,还要在叠加策略上和技能完全不同——同一个「中毒」叠五层和同一个「祝福」刷新时长,是两套语义。这一节就把技能与 Buff 两个模型从零建出来,重点在于把「什么时候算一次命中」「叠层到底怎么算」这些边界写成能跑通的测试。

一次施法的三段式:请求、前摇、命中

把施法拆成三段,是为了让「服务器权威」落到具体的位置上。

请求段只做校验,不改状态。收到 CmdSkillCastReq 时,服务器检查三件事:技能 id 存不存在、目标在不在施法距离内、两个冷却过没过。任何一条不满足就回一个错误,关键是不能留下副作用——一次被拒绝的施法绝不能烧掉冷却,否则玩家会看到「明明没放出来却进了冷却」这种最招骂的 bug。

前摇段是一段等待。校验通过后,施法被记下来,命中时间 = 请求 tick + CastTicks。这段时间里目标可能跑出范围、可能死了、可能自己被控住。所以前摇结束时要再判一次,而不是盲信请求时的判定。

命中段才真正结算伤害,也就是下一节 6.3 的事。本节把请求段和前摇段写完,并产出一个 Cast{LandAt, Damage} 交给结算。

冷却:技能冷却与公共冷却

冷却有两层,缺一不可。技能冷却是单个技能自己的:火球术 10 秒,这 10 秒内不能再放火球术。**公共冷却(GCD)**是全局的:放了任何一个技能,短时间(比如 0.5 秒)内所有技能都进冷却。GCD 的存在是为了防「一键滚键盘」——没有它,玩家可以在一个网络往返里连发七八个技能。

下面这段是 L1,go test 可以直接跑:

// file: internal/combat/skill.go
// tier: L1
// verify: go test ./internal/combat/ -run 'TestSkill' -v

package combat

import "errors"

// Vec2 is the server's floating-point logical coordinate. It is deliberately
// separate from client pixel coordinates; range checks below are in these units.
type Vec2 struct {
	X, Y float64
}

// SkillDef is the immutable config-table view of a skill: in production it is
// an export of a spreadsheet, here a plain struct so a test can build one.
type SkillDef struct {
	ID         uint32
	Name       string
	Range      float64 // max cast distance, logical units
	CastTicks  int     // wind-up before the hit lands
	Cooldown   int     // ticks before this skill may be cast again
	GCD        int     // global cooldown this cast imposes on every skill
	BaseDamage int
}

// CastRequest is a decoded, pre-validation intent.
type CastRequest struct {
	Caster EntityID
	Target EntityID
	Skill  uint32
	Tick   int64
}

// Cast is a validated cast: the hit lands CastTicks after the request tick.
type Cast struct {
	Caster EntityID
	Target EntityID
	Skill  uint32
	LandAt int64
	Damage int
}

// Sentinel errors let the caller map a rejected cast back onto a wire error.
var (
	ErrUnknownSkill = errors.New("combat: unknown skill")
	ErrOutOfRange   = errors.New("combat: target out of range")
	ErrOnCooldown   = errors.New("combat: skill on cooldown")
	ErrGCD          = errors.New("combat: global cooldown")
)

// castState tracks the cooldowns of one entity.
type castState struct {
	lastCast map[uint32]int64
	lastGCD  int64
}

// SkillEngine owns the skill table and every entity's cooldown state. It is not
// safe for concurrent use: it is driven by the single scene loop.
type SkillEngine struct {
	defs  map[uint32]SkillDef
	state map[EntityID]*castState
}

// NewSkillEngine returns an empty engine.
func NewSkillEngine() *SkillEngine {
	return &SkillEngine{
		defs:  make(map[uint32]SkillDef),
		state: make(map[EntityID]*castState),
	}
}

// Add registers a skill definition.
func (e *SkillEngine) Add(def SkillDef) { e.defs[def.ID] = def }

// Def returns the definition for id.
func (e *SkillEngine) Def(id uint32) (SkillDef, bool) {
	d, ok := e.defs[id]
	return d, ok
}

func (e *SkillEngine) stateFor(id EntityID) *castState {
	st := e.state[id]
	if st == nil {
		st = &castState{lastCast: make(map[uint32]int64), lastGCD: -1 << 62}
		e.state[id] = st
	}
	return st
}

func dist2(a, b Vec2) float64 {
	dx, dy := a.X-b.X, a.Y-b.Y
	return dx*dx + dy*dy
}

// CanCast checks range and both cooldowns at req.Tick without mutating state.
// Splitting it out from Cast lets the scene loop reject a bad cast and reply
// with a specific error code, while the state only advances on a real cast.
func (e *SkillEngine) CanCast(req CastRequest, casterPos, targetPos Vec2) error {
	def, ok := e.defs[req.Skill]
	if !ok {
		return ErrUnknownSkill
	}
	if dist2(casterPos, targetPos) > def.Range*def.Range {
		return ErrOutOfRange
	}
	st := e.stateFor(req.Caster)
	if last, ok := st.lastCast[req.Skill]; ok && req.Tick-last < int64(def.Cooldown) {
		return ErrOnCooldown
	}
	if def.GCD > 0 && req.Tick-st.lastGCD < int64(def.GCD) {
		return ErrGCD
	}
	return nil
}

// Cast validates req and, on success, records the cooldowns and returns the
// resolved Cast. The damage is not applied here: it lands CastTicks later, in
// the settlement step, so a moving target can still be out of range by then.
func (e *SkillEngine) Cast(req CastRequest, casterPos, targetPos Vec2) (Cast, error) {
	if err := e.CanCast(req, casterPos, targetPos); err != nil {
		return Cast{}, err
	}
	def := e.defs[req.Skill]
	st := e.stateFor(req.Caster)
	st.lastCast[req.Skill] = req.Tick
	st.lastGCD = req.Tick
	return Cast{
		Caster: req.Caster,
		Target: req.Target,
		Skill:  req.Skill,
		LandAt: req.Tick + int64(def.CastTicks),
		Damage: def.BaseDamage,
	}, nil
}

两个实现细节。第一,CanCast 与 Cast 分开。校验是不改状态的纯查询,真正的状态推进只发生在 Cast 里。这样场景循环可以先 CanCast 决定要不要回错误包,确认无误后再 Cast,绝不会出现「校验失败却动了冷却」的中间态。测试里那句「被拒绝的施法不能烧掉冷却」就是靠这个拆分保证的。第二,距离比较用平方。dist2 直接比 Range²,省掉一次开方——这在每秒几千次施法的场景里是白捡的性能,且逻辑坐标是浮点,开方还引入无谓的精度问题。

Buff:叠加策略与周期结算

Buff 的建模难点全在「同一个 Buff 再来一次」上。真实游戏里这有两种完全不同的语义,必须用策略区分:

  • 刷新(Refresh):时长重置,效果不叠加。典型的「回血祝福」「加速」,再放一次只是把倒计时拨回满。
  • 叠层(Stack):层数 +1,最多到上限,时长同时重置。典型的「中毒」「破甲」,叠得越多数值越高。

把这两种塞进一个 switch,就得到了下面的 BuffSet。完整文件 internal/combat/buff.go 和技能包一起编译、一起被 L1 测试覆盖,这里只摘出最关键的两个方法——Apply(按策略叠加)和 Tick(按 tick 精确结算周期效果):

// snippet: internal/combat/buff.go(节选,完整文件随本节 L1 测试一起编译)

// BuffInst is one live buff: the stack count, the tick it was applied (Start),
// the tick it expires, and the per-stack DoT/HoT copied from the config table.
//
// Apply puts def on the entity at tick. A brand-new buff starts at one stack; a
// repeat follows the policy: Refresh rewinds it to one stack, Stack grows it by
// one up to MaxStacks (MaxStacks <= 1 means a single stack). Either way the
// expiry is pushed to tick+Duration.
func (b *BuffSet) Apply(def BuffDef, tick int64) {
	inst, ok := b.byID[def.ID]
	if !ok {
		inst = &BuffInst{
			Def:        def.ID,
			Stacks:     1,
			DotPerTick: def.DotPerTick,
			HotPerTick: def.HotPerTick,
		}
		b.byID[def.ID] = inst
		b.order = append(b.order, def.ID)
	} else {
		switch def.Policy {
		case Stack:
			if inst.Stacks < max(1, def.MaxStacks) {
				inst.Stacks++
			}
		default: // Refresh
			inst.Stacks = 1
		}
	}
	inst.Start = tick
	inst.Expires = tick + int64(def.Duration)
}

// Tick advances the set across the half-open interval (prev, tick]. It sums the
// periodic damage and healing that fall inside each buff's own lifetime and
// drops the ones whose expiry has passed. Counts are exact: a buff that expires
// mid-interval only pays for the ticks it was actually alive.
func (b *BuffSet) Tick(prev, tick int64) (dmg, heal int) {
	if tick <= prev {
		return 0, 0
	}
	keep := b.order[:0]
	for _, id := range b.order {
		inst := b.byID[id]
		from := max(prev+1, inst.Start+1)
		to := min(tick, inst.Expires)
		if to >= from {
			n := int(to - from + 1)
			dmg += inst.DotPerTick * inst.Stacks * n
			heal += inst.HotPerTick * inst.Stacks * n
		}
		if inst.Expires <= tick {
			delete(b.byID, id)
			continue
		}
		keep = append(keep, id)
	}
	b.order = keep
	return dmg, heal
}

BuffSet 内部是 byID map[uint32]*BuffInst 加一条 order []uint32 的应用顺序。三个设计点。第一,周期效果按「实际存活 tick 数」结算。Tick(prev, tick) 把区间和 Buff 自己的生命周期求交集:from = max(prev+1, Start+1)、to = min(tick, Expires),交集为空就不结算。这样即使一个 Buff 在区间中间过期,也只付它真正活着的那些 tick,不会多算一格,也不会少算一格。第二,byID 与 order 分开。byID 让叠加查询是 O(1),order 保证遍历顺序稳定——结算顺序必须确定,否则同样的输入两次跑出的伤害日志会不一样。第三,过期是「边遍历边删」,用 keep := b.order[:0] 原地复用底层数组,避免每次 Tick 都重新分配切片。

真跑与实测结论

技能与 Buff 的边界条件很多,测试要把它们一条条钉死。下面这段测试覆盖了冷却、GCD、距离,以及「被拒绝的施法不能烧冷却」:

// snippet: internal/combat/skill_test.go(节选)

func TestSkillCooldownAndGCD(t *testing.T) {
	e := testEngine()
	here := Vec2{0, 0}
	near := Vec2{1, 0}

	c1, err := e.Cast(CastRequest{Caster: 7, Target: 9, Skill: 2, Tick: 100}, here, near)
	if err != nil {
		t.Fatalf("first cast: %v", err)
	}
	if c1.LandAt != 100 {
		t.Fatalf("instant skill LandAt = %d, want 100", c1.LandAt)
	}
	// A different skill inside the GCD window must be refused.
	if _, err := e.Cast(CastRequest{Caster: 7, Target: 9, Skill: 1, Tick: 102}, here, near); err != ErrGCD {
		t.Fatalf("cast inside GCD: err = %v, want ErrGCD", err)
	}
	// Same skill before its own cooldown is over is refused too.
	if _, err := e.Cast(CastRequest{Caster: 7, Target: 9, Skill: 2, Tick: 103}, here, near); err != ErrOnCooldown {
		t.Fatalf("cast on cooldown: err = %v, want ErrOnCooldown", err)
	}
	// Past both cooldowns it succeeds again (tick 105 clears the 5-tick GCD).
	if _, err := e.Cast(CastRequest{Caster: 7, Target: 9, Skill: 2, Tick: 105}, here, near); err != nil {
		t.Fatalf("cast after cooldown: %v", err)
	}
}

Buff 侧则验证叠层上限与周期结算的精确性:

// snippet: internal/combat/buff_test.go(节选)

func TestBuffStackCap(t *testing.T) {
	b := NewBuffSet()
	for i := 0; i < 10; i++ {
		b.Apply(poison, int64(i))
	}
	if got := b.Stacks(100); got != 5 {
		t.Fatalf("poison stacks = %d, want cap 5", got)
	}
}

func TestBuffDotTicksExact(t *testing.T) {
	b := NewBuffSet()
	b.Apply(poison, 0) // 3 dmg/stack/tick, one stack, expires at 10
	dmg, heal := b.Tick(0, 5)
	if dmg != 15 || heal != 0 {
		t.Fatalf("ticks 1..5: dmg=%d heal=%d, want 15/0", dmg, heal)
	}
	// Ticks 6..10 are the last five; the buff then expires.
	dmg, _ = b.Tick(5, 12)
	if dmg != 15 {
		t.Fatalf("ticks 6..10: dmg=%d, want 15", dmg)
	}
	if b.Has(100) {
		t.Fatal("poison survived past its expiry")
	}
	// After expiry no further damage is produced.
	if dmg, _ := b.Tick(12, 20); dmg != 0 {
		t.Fatalf("damage after expiry = %d, want 0", dmg)
	}
}

本机(Go 1.26,macOS arm64)实测,同机相对量级,绝对数值不可移植:

指标实测值说明
TestBuffStackCap10 次 Apply 后层数 = 5上限截断生效
TestBuffDotTicksExact5 tick 各结算 15 伤害3 伤害/层 × 1 层 × 5 tick
区间中间过期只算存活 tick交集算法不重不漏

两个结论。其一,把「施法」拆成校验与提交两步,是让冷却语义正确的最省事办法——测试里那句 cast after cooldown 能通过,恰恰是因为前面两次失败的施法什么都没改。其二,Buff 的周期结算必须按 tick 精确到格,Tick 里那个 max/min 求交集看着啰嗦,但它换来的是「一万个怪同时挂着不同剩余时长的中毒,伤害总和每次都对得上」——这在下一节的结算里会被直接用到。

技能和 Buff 都备齐了,下一节把命中判定、扣血、死亡和发奖串成一条能防重复的结算链。

阅读导航:上一节:6.1 指令投递与执行队列 · 下一节:6.3 战斗结算与防重复发奖 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标