8.1 物品与堆叠模型
背包是每个 MMO 都要写、又几乎人人写过一遍的东西。真正难的不是「把物品塞进格子」,而是把「物品」这个概念本身切清楚:同一件「小血瓶」在配置表里只有一条记录,但在背包里可能同时存在十几个互不相同的堆叠,各自带着数量、绑定状态、强化等级。分不清这两层,后面每一次发奖、扣道具、交易都会踩坑。
这一节先解决建模问题——把配置表 ID 与物品实例 ID 分开,把可堆叠与不可堆叠物品统一到一套结构里,再给出堆叠合并、容量预检与原子性的实现。它是第 8 章的地基:8.2 的并发事务、8.3 的装备强化,全部建立在这份模型之上。
第 7 章的掉落表产出的是「要发什么」,本节处理的是「发到哪、怎么叠」。掉落与背包的分界线,正是 ItemID 与 ItemInstID 的分界线。
与站内既有文章的分工
本书不是讨论背包架构的唯一来源。站内已经有一批讨论背包与道具「架构方案与取舍」的文章,本节与它们的分工如下:
| 既有文章 | 主题 | 分工 |
|---|---|---|
/inventory-service-handle-concurrent-modifications/ | 背包并发修改 | 既有文章负责方案选型与取舍 |
/game-server-item-expiration-and-temporary-assets/ | 道具过期与临时资产 | 既有文章负责过期策略与资产分类 |
一句话概括:既有文章回答「有哪几种做法、各自代价是什么」,本书回答「选定的这条怎么做出来、能不能真跑、端到端是否对得上」。下面不再重复方案对比,直接进入实现。
两套 ID:配置表与实例
新手最常见的第一处错误,是用一个 ItemID 同时表示「这是什么」和「这一份」。策划表里「小血瓶」的 ID 是 1001,全服所有玩家的小血瓶都指向同一条定义;但玩家 A 背包里的第 3 格和第 7 格可能各是一个小血瓶堆叠,它们的数量不同、绑定状态不同。这两层必须拆开:
ItemID uint32:配置表物品 ID。它回答「这是什么」,由策划表唯一确定,进程内只读、可被所有角色共享。ItemInstID uint64:背包内物品实例 ID。它回答「这一份是哪一份」,在放入背包时分配、全服单调递增。
只有配置表 ID 而没有实例 ID,就没法给「这一份」挂任何个体属性;强化等级、绑定、过期时间这些恰恰都是个体属性。反过来,只有实例 ID 而没有配置表 ID,每次要用物品都得回表查它是什么,既慢又容易在配置热更时对不上。所以两者的关系是:配置表 ID 描述共性,实例 ID 承载个性。
ItemDef 里还有一个决定堆叠行为的关键字段 MaxStack:它规定一格最多放多少个。消耗品与材料给一个大于 1 的上限,装备固定为 1——因为装备是不可堆叠的,每一件都必须独立成格,否则强化等级、附魔词条就没有地方挂。Stackable() 直接由 MaxStack > 1 推出,不需要单独再存一个布尔。
堆叠、容量与原子性
加入物品要回答两个问题:往哪叠、放不下怎么办。
先看「往哪叠」。可堆叠物品的策略是:先把同种、未绑定、未满的已有堆叠从低槽位到高槽位依次补满,还有剩余再开新格。这条规则有两个后果——同样的输入永远得到同样的槽位分布(可预期、可复现),而且会尽量把整格腾出来而不是把新物品塞进零散空格。不可堆叠的装备则跳过合并,每件直接占一格。
再看「放不下怎么办」。这里藏着一个容易忽略的正确性问题:如果一边遍历一边放,放到第 3 格才发现不够,前面两格已经改动了,函数返回错误时背包却处于半成品状态。所以 add 先做容量预检——先算已有堆叠还能补多少、剩余的要开几格、空格够不够——预检通过才真正落盘。这样即使调用方没包事务,add 本身也是「要么全进、要么全不动」。
remove 同理:数量不足时先返回 ErrNotEnough、一个字节都不改。这种「先校验后执行」的习惯看起来啰嗦,但它是后面事务能成立的前提——一个会留下中间态的基础函数,任何外层事务都救不回来。
可运行产物:item.go
下面这段是 L1,go test 可以直接跑。它包含完整的物品模型、配置表、背包状态与堆叠算法:
// file: internal/inventory/item.go
// tier: L1
// verify: go test ./internal/inventory/
// Package inventory 实现 aegis 单服《晨曦大陆》的背包与道具系统:
// 物品堆叠模型、并发事务化修改、装备与强化。
package inventory
import "errors"
// ItemID 是配置表物品 ID(不是实例 ID);同一件「小血瓶」全服共用一个 ItemID。
type ItemID uint32
// ItemInstID 是背包内物品实例 ID。同种物品的每个堆叠各有独立实例号,
// 强化等级、绑定状态这类「个体差异」都挂在实例上。
type ItemInstID uint64
// ItemType 物品大类。
type ItemType uint8
const (
TypeMaterial ItemType = iota // 材料:可堆叠,不直接使用
TypeConsumable // 消耗品:可堆叠,使用后减少数量
TypeEquipment // 装备:不可堆叠,每件占一格
)
// EquipSlot 装备位。
type EquipSlot uint8
const (
SlotNone EquipSlot = iota
SlotWeapon // 武器
SlotHelmet // 头盔
SlotArmor // 护甲
SlotBoots // 靴子
)
// ItemDef 是配置表里一条物品定义,进程内只读、被所有角色共享。
type ItemDef struct {
ID ItemID
Name string
Type ItemType
MaxStack int // 每格最大堆叠数;装备固定为 1
Slot EquipSlot // 可穿戴槽位;非装备为 SlotNone
}
// Stackable 表示该物品能否堆叠。
func (d ItemDef) Stackable() bool { return d.MaxStack > 1 }
// ItemStack 是背包里的一格:一个物品实例。
type ItemStack struct {
Inst ItemInstID // 实例 ID
ID ItemID // 配置表 ID
Count int // 本格数量
Bound bool // 绑定后不可交易
Enhance int // 强化等级(0 表示未强化)
}
// Catalog 是只读的物品配置表。
type Catalog struct{ defs map[ItemID]ItemDef }
// NewCatalog 用给定定义构造配置表。
func NewCatalog(defs ...ItemDef) *Catalog {
m := make(map[ItemID]ItemDef, len(defs))
for _, d := range defs {
m[d.ID] = d
}
return &Catalog{defs: m}
}
// Get 取一条定义。
func (c *Catalog) Get(id ItemID) (ItemDef, bool) { d, ok := c.defs[id]; return d, ok }
// 背包与道具相关的错误。调用方据错误值分流,不靠字符串匹配。
var (
ErrUnknown = errors.New("inventory: unknown item id")
ErrNoSpace = errors.New("inventory: bag has no space")
ErrNotEnough = errors.New("inventory: not enough items")
ErrNotEquip = errors.New("inventory: item is not equipment")
ErrNotInBag = errors.New("inventory: instance not in bag")
ErrMaxEnhance = errors.New("inventory: already at max enhance level")
)
// bagState 是背包的全部可变状态。事务通过整体替换它获得原子性:
// 改动发生在副本上,提交时一次性换指针,失败则丢弃副本。
type bagState struct {
slots []*ItemStack // 定长背包槽位,nil 为空格
equip map[EquipSlot]*ItemStack // 已穿戴装备
index map[ItemInstID]int // 背包内实例 ID → 槽位下标
nextInst ItemInstID // 下一个可分配的实例号
}
func newBagState(slots int) *bagState {
return &bagState{
slots: make([]*ItemStack, slots),
equip: make(map[EquipSlot]*ItemStack),
index: make(map[ItemInstID]int),
}
}
// add 把 count 个 id 放进背包:可堆叠的先补已有堆叠,再开新格;
// 不可堆叠的每件占一格。先做容量预检,放不下就整体不动并返回 ErrNoSpace。
func (s *bagState) add(cat *Catalog, id ItemID, count int) error {
def, ok := cat.Get(id)
if !ok {
return ErrUnknown
}
if count <= 0 {
return nil
}
// 预检:先算已有堆叠能补多少,再算要开几格。
room := 0
if def.Stackable() {
for _, st := range s.slots {
if st != nil && st.ID == id && !st.Bound && st.Count < def.MaxStack {
room += def.MaxStack - st.Count
}
}
}
if need := count - room; need > 0 {
per := 1
if def.Stackable() {
per = def.MaxStack
}
if needSlots := (need + per - 1) / per; needSlots > s.emptyCount() {
return ErrNoSpace
}
}
// 预检通过才落盘:先补已有堆叠,从低槽位开始,保证结果可预期。
if def.Stackable() {
for _, st := range s.slots {
if count == 0 {
break
}
if st != nil && st.ID == id && !st.Bound && st.Count < def.MaxStack {
n := min(def.MaxStack-st.Count, count)
st.Count += n
count -= n
}
}
}
for count > 0 {
slot := s.firstEmpty()
n := 1
if def.Stackable() {
n = min(def.MaxStack, count)
}
s.put(slot, def, n)
count -= n
}
return nil
}
// remove 按配置表 ID 扣减 count 个。数量不足时先返回错误、不做任何改动。
func (s *bagState) remove(cat *Catalog, id ItemID, count int) error {
if count <= 0 {
return nil
}
if s.Count(id) < count {
return ErrNotEnough
}
for i, st := range s.slots {
if count == 0 {
break
}
if st == nil || st.ID != id {
continue
}
if st.Count <= count {
count -= st.Count
s.clearSlot(i)
} else {
st.Count -= count
count = 0
}
}
return nil
}
// Count 返回背包内某配置表物品的总数量(不含已穿戴装备)。
func (s *bagState) Count(id ItemID) int {
n := 0
for _, st := range s.slots {
if st != nil && st.ID == id {
n += st.Count
}
}
return n
}
// Snapshot 返回背包槽位的值拷贝,供只读展示使用。
func (s *bagState) Snapshot() []ItemStack {
out := make([]ItemStack, 0, len(s.slots))
for _, st := range s.slots {
if st != nil {
out = append(out, *st)
}
}
return out
}
func (s *bagState) clearSlot(i int) {
if st := s.slots[i]; st != nil {
delete(s.index, st.Inst)
s.slots[i] = nil
}
}
func (s *bagState) firstEmpty() int {
for i, st := range s.slots {
if st == nil {
return i
}
}
return -1
}
func (s *bagState) emptyCount() int {
n := 0
for _, st := range s.slots {
if st == nil {
n++
}
}
return n
}
func (s *bagState) put(slot int, def ItemDef, count int) {
s.nextInst++
s.slots[slot] = &ItemStack{Inst: s.nextInst, ID: def.ID, Count: count}
s.index[s.nextInst] = slot
}
配套的 internal/inventory/item_test.go 覆盖了六条用例:TestStackMerge 验证 120 个小血瓶被切成 99 + 21 两格且实例号不同;TestAddMergeExisting 验证未满时并堆而不占新格;TestEquipmentNoStack 验证装备每件一格;TestNoSpaceIsAtomic 验证 2 格背包塞 250 个时返回 ErrNoSpace 且一物未进;TestRemove 验证扣减与数量不足;TestUnknownItem 验证未知 ID 报 ErrUnknown。本机实跑 go test ./internal/inventory/ 全绿。
本节小结
- 配置表 ID(
ItemID)描述「这是什么」,实例 ID(ItemInstID)承载「这一份的个体属性」,两者不可混用。 MaxStack一个字段同时决定能否堆叠与每格上限;Stackable()由它推出,不另存布尔。- 加物品先做容量预检,预检通过才落盘,让
add本身满足「要么全进、要么全不动」。 - 本节是第 8 章地基:8.2 在这份状态上加并发事务,8.3 在它上面实现装备与强化。
阅读导航:上一节:目录 · 下一节:8.2 并发修改与事务 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。