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)实测,同机相对量级,绝对数值不可移植:
| 指标 | 实测值 | 说明 |
|---|---|---|
TestBuffStackCap | 10 次 Apply 后层数 = 5 | 上限截断生效 |
TestBuffDotTicksExact | 5 tick 各结算 15 伤害 | 3 伤害/层 × 1 层 × 5 tick |
| 区间中间过期 | 只算存活 tick | 交集算法不重不漏 |
两个结论。其一,把「施法」拆成校验与提交两步,是让冷却语义正确的最省事办法——测试里那句 cast after cooldown 能通过,恰恰是因为前面两次失败的施法什么都没改。其二,Buff 的周期结算必须按 tick 精确到格,Tick 里那个 max/min 求交集看着啰嗦,但它换来的是「一万个怪同时挂着不同剩余时长的中毒,伤害总和每次都对得上」——这在下一节的结算里会被直接用到。
技能和 Buff 都备齐了,下一节把命中判定、扣血、死亡和发奖串成一条能防重复的结算链。
阅读导航:上一节:6.1 指令投递与执行队列 · 下一节:6.3 战斗结算与防重复发奖 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。