9.2 等级与成长状态机
9.1 里 SetBase 喂进来的基础属性来自哪里?来自等级。玩家升级后攻击、血量的基础值要跟着涨,所以「等级」本质上是属性系统的一个上游输入。但等级本身不是简单的一个数字,它是一台状态机:经验累积、判定升级、重算属性、发放奖励,四步要按序发生。
这件事的坑集中在三处。第一是连升:打一只精英怪给的经验可能一次顶好几级,循环不能只升一级就返回。第二是结转:升到某级后剩下的经验要留给下一级,丢一个零头,玩家迟早会发现「怎么差一点没升」。第三是封顶:到了满级,多余经验不能再累积,否则内部数值会一直涨,迟早溢出。
本节把这台状态机从零写出来,全部是 L1,go test ./internal/attr/ 可以直接跑。
经验曲线:升级节奏由 Need(level) 决定
曲线决定「从 level 升到 level+1 需要多少经验」。最简单的写法是线性的 100*level,但那样满级太快、手感平。这里用带二次项的 need(level) = 100*level + 50*level^2:早期升级快、后期越来越慢,这是最常见的成长手感。曲线只负责「查表」,把每一级的需求预计算进数组,运行时 Need 就是一次数组下标。
曲线是纯函数,同一个 level 永远返回同一个需求——这点很重要。升级判定要能被重放、能被对账,任何随机或随时间变化的需求都会让「为什么这个玩家在这天升了级」无法复现。下面把它和状态机写进同一个文件,这段是 L1,go test ./internal/attr/ 可以直接跑:
// file: internal/attr/progression.go
// tier: L1
// verify: go test ./internal/attr/
package attr
import "errors"
var (
ErrNegativeExp = errors.New("attr: 经验增量为负")
ErrIllegalPhase = errors.New("attr: 非法的成长相位迁移")
)
// Phase 是成长状态机的相位。
type Phase uint8
const (
PhaseIdle Phase = iota // 未满级,等待经验
PhaseLevelUp // 本次命令触发升级,待重算属性并发奖
PhaseMax // 已满级,经验不再累积
)
func (p Phase) String() string {
switch p {
case PhaseIdle:
return "idle"
case PhaseLevelUp:
return "levelup"
case PhaseMax:
return "max"
default:
return "unknown"
}
}
// Curve 是等级经验曲线:Need(level) 是从 level 升到 level+1 所需经验。
type Curve struct {
max int32
need []int64 // need[level],下标即等级
}
// NewCurve 造一条确定性曲线:need(level) = 100*level + 50*level^2。
// 二次项让升级越来越慢,这是最常见的成长手感。
func NewCurve(max int32) *Curve {
need := make([]int64, max+1)
for l := int32(1); l <= max; l++ {
need[l] = 100*int64(l) + 50*int64(l)*int64(l)
}
return &Curve{max: max, need: need}
}
func (c *Curve) MaxLevel() int32 { return c.max }
// Need 返回当前等级升下一级所需经验;满级返回 0。
func (c *Curve) Need(level int32) int64 {
if level < 1 || level >= c.max {
return 0
}
return c.need[level]
}
// Progression 是等级、当前等级内经验,以及成长相位。
type Progression struct {
Level int32
Exp int64 // 当前等级内已积累的经验
Phase Phase
curve *Curve
}
func NewProgression(c *Curve) *Progression {
return &Progression{Level: 1, Exp: 0, Phase: PhaseIdle, curve: c}
}
// Result 描述一次 Gain 造成的迁移。
type Result struct {
FromLevel, ToLevel int32
FromPhase, ToPhase Phase
LevelsGained int32
CarryExp int64 // 结转进当前等级的经验
Capped bool // 本次是否撞到满级
}
// Gain 是唯一改变成长状态的入口,驱动状态机完成一次(可能连升多级)迁移。
func (p *Progression) Gain(amount int64) (Result, error) {
if amount < 0 {
return Result{}, ErrNegativeExp
}
// 上一次升级还没结算完,不允许再灌经验:属性还没重算,灌进去就会算错。
if p.Phase == PhaseLevelUp {
return Result{}, ErrIllegalPhase
}
res := Result{FromLevel: p.Level, FromPhase: p.Phase}
if p.Phase == PhaseMax {
res.ToLevel, res.ToPhase = p.Level, PhaseMax
return res, nil // 满级后经验直接丢弃
}
p.Exp += amount
for p.Level < p.curve.MaxLevel() {
need := p.curve.Need(p.Level)
if p.Exp < need {
break
}
p.Exp -= need
p.Level++
res.LevelsGained++
}
switch {
case p.Level >= p.curve.MaxLevel():
p.Level = p.curve.MaxLevel()
p.Phase = PhaseMax
res.Capped = true
case res.LevelsGained > 0:
p.Phase = PhaseLevelUp
default:
p.Phase = PhaseIdle
}
res.ToLevel, res.ToPhase = p.Level, p.Phase
res.CarryExp = p.Exp
return res, nil
}
// CompleteLevelUp 在重算属性、发放升级奖励之后调用,把状态机推回 Idle。
func (p *Progression) CompleteLevelUp() error {
if p.Phase != PhaseLevelUp {
return ErrIllegalPhase
}
p.Phase = PhaseIdle
return nil
}
为什么升级要分相位
如果只存一个 Level,升级就是「加经验、循环加等级」,看起来不需要状态机。真正需要相位的原因是升级不是原子的:升一级之后,服务端要先按新等级重算属性(回 9.1 的 SetBase),再发升级奖励、广播特效,这些步骤之间可能失败、可能要等一次落库。如果这中间又灌进经验,就会出现「属性还是旧等级的,但等级已经涨了」的错位。
所以 PhaseLevelUp 是一个闸门:Gain 一旦把相位推到 levelup,下一次 Gain 直接返回 ErrIllegalPhase,逼调用方先走完 CompleteLevelUp。这把「升级的四步」从「靠调用顺序自觉」变成「状态机强制」。PhaseMax 则是终态:满级后 Gain 立刻返回、不动任何数值,经验被丢弃。
等级如何驱动属性:Growth.ApplyTo
等级涨了,属性得跟着涨。把 9.2 的 Level 接到 9.1 的 AttributeSet 上,需要一个「每级基础属性增量」的配置。这里用一张 Growth 表描述「初始值 + 每级增量」,ApplyTo 只写基础值——装备与 buff 的加成挂在 AttributeSet 上,不会被覆盖。这样升级流程就是「Gain 升到新等级 → Growth.ApplyTo(set, newLevel) → 加成仍在 → Compute 出新数值」。下面这段是 L1,go test ./internal/attr/ 可以直接跑:
// file: internal/attr/levelattr.go
// tier: L1
// verify: go test ./internal/attr/
package attr
// Growth 描述每升一级基础属性的增量,以及 1 级时的初始值。
// 它把 9.2 的等级喂进 9.1 的 SetBase,是「成长」与「属性」两套机制的接缝。
type Growth struct {
BaseHP, HPPerLevel int64
BaseMP, MPPerLevel int64
BaseATK, ATKPerLevel int64
BaseDEF, DEFPerLevel int64
}
// ApplyTo 把某等级的基础属性写进 AttributeSet。注意它只改 base,
// 装备与 buff 的加成仍挂在 AttributeSet 上,重算时不会被冲掉。
func (g Growth) ApplyTo(s *AttributeSet, level int32) {
lv := int64(level)
s.SetBase(AttrMaxHP, g.BaseHP+g.HPPerLevel*lv)
s.SetBase(AttrMaxMP, g.BaseMP+g.MPPerLevel*lv)
s.SetBase(AttrATK, g.BaseATK+g.ATKPerLevel*lv)
s.SetBase(AttrDEF, g.BaseDEF+g.DEFPerLevel*lv)
}
ApplyTo 刻意只碰 base,不碰 mods。这条边界一旦破掉,升级时把装备加成也顺手重算,就会出现「升一级攻击掉了」这种灵异现象——因为装备加成被当基础值覆盖了。TestGrowthApplyTo 里先 ApplyTo(s, 5) 断言基础值,再挂一件武器、ApplyTo(s, 6),断言新攻击等于「6 级基础 + 20 武器加成」:升级重算没有把武器冲掉。
用测试钉住连升、结转与封顶
状态机的正确性全靠边界。TestGainMultiLevelCarry 一次灌入 10000 经验,从 1 级连升到 8 级(升了 7 级),结转 200 点;随后断言「升级中再 Gain 报错」。这段测试属于同一个已跑通的 L1 包:
// snippet: internal/attr/progression_test.go(摘录)
func TestGainMultiLevelCarry(t *testing.T) {
p := NewProgression(NewCurve(20))
res, err := p.Gain(10000)
if err != nil {
t.Fatalf("Gain: %v", err)
}
if p.Level != 8 || res.LevelsGained != 7 {
t.Fatalf("Level=%d gained=%d, 期望 8 / 7", p.Level, res.LevelsGained)
}
if res.CarryExp != 200 || p.Exp != 200 {
t.Fatalf("结转 = %d, 期望 200", p.Exp)
}
if p.Phase != PhaseLevelUp {
t.Fatalf("Phase=%v, 期望 levelup", p.Phase)
}
// 升级未结算前不允许再灌经验
if _, err := p.Gain(1); !errors.Is(err, ErrIllegalPhase) {
t.Fatalf("升级中 Gain = %v, 期望 ErrIllegalPhase", err)
}
if err := p.CompleteLevelUp(); err != nil {
t.Fatalf("CompleteLevelUp: %v", err)
}
if p.Phase != PhaseIdle {
t.Fatalf("结算后 Phase=%v, 期望 idle", p.Phase)
}
}
手算一遍这 10000 点是怎么走完的:1 级需 150、2 级 400、3 级 750、4 级 1200、5 级 1750、6 级 2400、7 级 3150,累计 9800,正好升到 8 级、剩 200;8 级要 4000,不够,停下。所以 LevelsGained=7、CarryExp=200。这个手算和断言逐项对上,是「连升 + 结转」最直接的验收。
TestCapAtMaxLevel 则验证封顶:3 级封顶的曲线灌入天文数字经验,等级停在 3、Capped=true、相位变 max,之后再 Gain 数值纹丝不动。TestNegativeExpRejected 守住负经验——配置表写错一个负号,若不拦,玩家会掉级掉经验。
本节小结 / 实测结论
本机(Go 1.26.0,darwin/arm64)实跑结果:
$ go vet ./...
(无输出)
$ go test ./internal/attr/
ok aegis/internal/attr 0.4s (15 个 test 全绿)
等级状态机把「经验 → 升级 → 重算属性 → 发奖」四步用相位串起来,连升靠循环、结转靠减法、封顶靠终态。但它只管玩家在线时的经验。玩家下线这段时间,等级之外还有一类东西要继续走——离线收益和体力。9.3 处理它们。以上数字是同机相对量级,绝对数值不可移植。
阅读导航:上一节:9.1 属性计算与加成来源 · 下一节:9.3 离线推进与体力 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。