9.3 离线推进与体力
前两节算的是玩家在线时的属性与等级。但 MMO 里有一类数值是按时间自然增长的:体力每隔几分钟回一点,离线挂机每分钟产一份金币。它们不依赖玩家做了什么,只依赖「过了多久」。玩家下线,时间照样走;再上线时,服务端要把这段离线时间补算给他。
这类结算看着简单——收益 = 时长 × 速率——但真写起来有三个必踩的坑。第一是丢点:体力每 60 秒回 1 点,玩家每 90 秒上下线一次,如果每次都用「现在 - 上次 除以周期再取整」就丢掉那 30 秒的余数,攒点速度会莫名变慢。第二是封顶:离线 100 小时不能全算,否则一次登录发一年的收益,经济直接崩。第三是时钟回拨:服务器换机器、NTP 校时,now 可能小于 lastLogout,时长 变成负数,收益变成负的。
本节把体力与离线推进从零写出来,两者共用一套「时间差 → 结算」的骨架。核心是把时间结算拆成三个动作:先挡住非正的时间差、再按上限封顶、回复类只推进整数周期。全部是 L1,go test ./internal/attr/ 可以直接跑。
体力回复:整数周期推进,余数保留
先说体力。正确的做法不是在 Cur 上加 elapsed/regen,而是只把 LastTick 推进整数个周期,余数留在原地。这样无论玩家是一次挂了 10 分钟,还是分 10 次每次 60 秒上线,累计回复的点数完全一致——TestStaminaNoDrift 就是钉这条。
满体力时还有一条规矩:不攒点。Cur 已经到 Max,这段时间的回点直接作废,LastTick 照样推进。否则玩家可以「攒着不领」,等需要时一次爆出一大堆体力,破坏设计。下面这段是 L1,go test ./internal/attr/ 可以直接跑:
// file: internal/attr/offline.go
// tier: L1
// verify: go test ./internal/attr/
package attr
import "errors"
var (
ErrStaminaNotEnough = errors.New("attr: 体力不足")
)
// Stamina 是体力/行动力:随时间回复、有上限、消耗要原子。
type Stamina struct {
Cur int32
Max int32
RegenSec int64 // 每回复 1 点所需秒数
LastTick int64 // 上次结算的 unix 秒
}
func NewStamina(cur, max int32, regenSec, now int64) *Stamina {
return &Stamina{Cur: cur, Max: max, RegenSec: regenSec, LastTick: now}
}
// Apply 把体力结算到 now,返回本次回复的点数。
// 关键:只按「完整回复周期」推进 LastTick,余数留到下次,避免多次短会话丢点。
func (s *Stamina) Apply(now int64) int32 {
if s.RegenSec <= 0 || now <= s.LastTick {
return 0 // 时钟回拨或零间隔:不回复
}
ticks := (now - s.LastTick) / s.RegenSec
if ticks <= 0 {
return 0 // 不足一个周期,余数保留
}
s.LastTick += ticks * s.RegenSec // 只推进整数个周期
if s.Cur >= s.Max {
return 0 // 满体力时不攒,超出的周期直接作废
}
gained := int32(ticks)
if room := s.Max - s.Cur; gained > room {
gained = room
}
s.Cur += gained
return gained
}
// Consume 原子扣体力:不够就整体失败,不做部分扣减。
func (s *Stamina) Consume(n int32) error {
if n <= 0 {
return nil
}
if s.Cur < n {
return ErrStaminaNotEnough
}
s.Cur -= n
return nil
}
// Add 直接发放体力(道具/购买),超过上限的部分丢弃。
func (s *Stamina) Add(n int32) {
if n <= 0 {
return
}
s.Cur += n
if s.Cur > s.Max {
s.Cur = s.Max
}
}
// OfflineConfig 决定离线推进的结算规则。
type OfflineConfig struct {
MaxOfflineSec int64 // 离线结算上限,如 12 小时
PerSecReward int64 // 每秒产出的资源(示例:金币)
}
// OfflineResult 是一次离线推进的结算结果。
type OfflineResult struct {
Elapsed int64 // 被采纳的有效秒数(已封顶)
Clamped bool // 是否被上限截断
Reward int64 // 结算得到的资源
}
// SettleOffline 结算离线收益,now 与 lastLogout 都是 unix 秒。
// 三处必须处理:时钟回拨(now < lastLogout)、上限封顶、产出按有效秒数算。
func SettleOffline(lastLogout, now int64, cfg OfflineConfig) OfflineResult {
var res OfflineResult
if now <= lastLogout {
return res // 时钟回拨或零间隔:不产出,也绝不产出负收益
}
elapsed := now - lastLogout
if cfg.MaxOfflineSec > 0 && elapsed > cfg.MaxOfflineSec {
elapsed = cfg.MaxOfflineSec
res.Clamped = true
}
res.Elapsed = elapsed
res.Reward = elapsed * cfg.PerSecReward
return res
}
// LoginSettle 汇总一次登录结算:离线收益与体力各算各的,但共用同一个 now。
type LoginSettle struct {
Offline OfflineResult
StaminaGained int32
}
// OnLogin 在玩家上线时调用一次。体力走 Apply(保留余数),
// 离线收益走 SettleOffline(带封顶),两者互不影响,也不共用 LastTick。
func OnLogin(st *Stamina, lastLogout, now int64, cfg OfflineConfig) LoginSettle {
return LoginSettle{
Offline: SettleOffline(lastLogout, now, cfg),
StaminaGained: st.Apply(now),
}
}
Apply 的第一道 if 就挡住两类脏输入:RegenSec <= 0 是配置错误(除以 0 会 panic),now <= s.LastTick 是时钟回拨。回拨时返回 0、不动 LastTick——绝不能因为 now 变小就把 LastTick 往回退,否则玩家能靠反复触发校时刷体力。Consume 是原子的:不够就整体返回 ErrStaminaNotEnough、一点不扣,避免「扣了一半又失败」这种脏状态。
离线推进:封顶与时钟回拨
SettleOffline 处理的是另一类时间结算。它和体力共用同一个「时间差」骨架,但多了封顶这一层:MaxOfflineSec 把有效时长截到上限,比如 12 小时。玩家离线 100 小时,只算 12 小时,Clamped 置位——这个标记要发给客户端,告诉他「离线收益已达上限」,否则玩家会以为系统算错了。
时钟回拨在这里同样要挡:now <= lastLogout 直接返回零结果。注意这里连「零间隔」也一起挡了——now == lastLogout 时长是 0,产出本来就该是 0,归到同一分支更省事。三态(正常 / 封顶 / 回拨)在 TestSettleOffline 里各有一个用例。
体力和离线收益虽然共用骨架,但结算时机不同。体力是高频的:每次消耗前 Apply 一次,保证扣的是最新值。离线收益是低频的:只在登录时结算一次,之后 lastLogout 立刻更新为当前时间。把两者混在一起结算,会让体力的余数被离线的封顶逻辑污染,所以它们是两个独立函数。
登录时的一次性结算:OnLogin
上线这一刻要把三件事做完:算离线收益、回体力、更新时间戳。它们共用同一个 now,但各自独立——OnLogin 就是这个聚合点。它不做任何计算,只是把两个函数按正确顺序调一次,让调用方(登录流程)不可能漏掉其中一步。顺序上有讲究:必须先 SettleOffline 再 st.Apply,因为离线收益要用「登录前」的时间差,而体力 Apply 会把 LastTick 推到 now,谁先谁后决定 lastLogout 会不会被污染。
OnLogin 的返回值 LoginSettle 直接喂给登录响应:离线收益带上 Clamped 标记,客户端据此弹「离线收益已达上限」;体力值则写回角色存档。TestOnLoginCombines 造了一个「离线 2 小时、体力从 0 开始」的场景:离线收益按 12 小时上限里的 3600 秒算、Clamped=true、每秒 2 金得 7200;体力则 7200/60=120 点被上限 100 截住。一次调用、两组断言,把两条时间线都钉住。
两条时间线的关系可以用一张 ASCII 图说清:
lastLogout now(登录)
| |
|<------------- elapsed -------------->|
| |
离线收益 |<---- min(elapsed, MaxOfflineSec) ---->| 超出部分丢弃,Clamped=true
体力回复 | tick tick tick tick ... tick | 只数完整周期,余数留在 LastTick
用测试钉住不丢点与三态
边界全靠测试。TestStaminaNoDrift 对比「一次挂 600 秒」和「10 次各 60 秒」两种路径,断言回复点数一致;再补一个「600 秒后又过 30 秒」,断言余数期不回复。这段属于同一个已跑通的 L1 包:
// snippet: internal/attr/offline_test.go(摘录)
func TestStaminaNoDrift(t *testing.T) {
const regen = 60 // 每 60 秒 1 点
long := NewStamina(0, 100, regen, 0)
if got := long.Apply(600); got != 10 {
t.Fatalf("长会话回复 = %d, 期望 10", got)
}
// 10 次 60 秒的短会话,应回满同样的 10 点
short := NewStamina(0, 100, regen, 0)
total := int32(0)
for i := int64(1); i <= 10; i++ {
total += short.Apply(i * 60)
}
if total != 10 || short.Cur != long.Cur {
t.Fatalf("短会话合计 = %d cur=%d, 期望 10 / %d", total, short.Cur, long.Cur)
}
// 不足一个周期时余数保留:再等 30 秒不回复
if got := short.Apply(600 + 30); got != 0 {
t.Fatalf("余数期回复 = %d, 期望 0", got)
}
}
两条路径都是「从 0 点开始、有效时长 600 秒」,因为 Apply 只推进整数周期,600/60=10 个周期,两条路径都回 10 点、Cur 都等于 10。如果实现图省事写成「Cur += (now-LastTick)/regen 但不更新 LastTick」,短会话路径就会反复从 0 秒重新计,10 次各回 1 点看似也是 10——但一旦某次间隔不足 60 秒,那次就白丢。整数周期推进从根上消除这种漂移。
结算的落库口径与幂等
体力和离线收益都依赖「上次结算时间」这个字段,所以它必须落库:Stamina.LastTick 与 lastLogout 都是 unix 秒,存进角色表。这里有个容易忽略的性质——Apply(now) 对同一个 now 调用两次是幂等的:第二次因为 now <= LastTick 直接返回 0。这条性质让「结算完落库失败、重试」变得安全:重试不会重复回点。
落库时机也要分开。体力是高频结算,不能每次 Apply 都写库;做法是「内存里算、下线或定期落库」。因为 LastTick 单调前进,丢一次落库顶多让玩家少回一两分钟的点,不会算错。离线收益则只在登录结算一次,lastLogout 必须紧随其后更新并落库——否则同一段离线时间会被算两次,玩家反复重登就能刷收益。这条正是 1.2「状态归谁」在时间结算上的复现:结算与时间戳更新必须在同一个事务里。
下面是一份结算参数配置,属于 L3(仅静态,本机只做语法检查):
stamina:
max: 100
regen_sec: 360 # 每 6 分钟回 1 点
offline:
max_offline_sec: 43200 # 12 小时封顶
per_sec_reward: 1
max_offline_sec 与 per_sec_reward 都是策划可调项:封顶太短玩家觉得亏、太长经济通胀,这个平衡靠运营数据调,不靠代码写死。把参数抽到配置,改数值不用重新编译。
本节小结 / 实测结论
本机(Go 1.26.0,darwin/arm64)实跑结果:
$ go vet ./...
(无输出)
$ go test ./internal/attr/
ok aegis/internal/attr 0.4s (15 个 test 全绿)
时间结算的通用口径就三条:时间差先挡非正、再按上限封顶、回复类只推进整数周期。这三条在体力与离线收益上共用。本节四条测试分别覆盖不丢点、满体力不攒、消耗原子、离线三态,加上登录聚合的那条,共五条边界。
至此第 9 章收尾:属性(9.1)算最终数值、等级(9.2)给基础值并管成长迁移、时间推进(9.3)补离线与体力,三者构成了《晨曦大陆》角色成长的全部数值基础。下一章转到另一套驱动系统——任务与进度。以上数字是同机相对量级,绝对数值不可移植。
阅读导航:上一节:9.2 等级与成长状态机 · 下一节:10.1 任务依赖图与循环检测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。