两把相同的枪,不是一件物品
上一篇写时间系统,讲的是”一个概念没想清楚,整个系统返工”。
这篇是同一类问题的另一种形态:一个数据模型没升级,一长串操作就各错各的。
一、从”堆栈”到”实例”
早期 Outland 的物品模型很简单:
- 物品 =
id+count - 背包里两把 9 毫米手枪 = 一条
pistol_9mm × 2 - 扔一把 →
count减一 - 丢进容器 → 整条搬走
这个模型对”没有个体差异的物品”完全够用:两瓶水、五块面包,谁是谁无所谓。
但后面加的东西把”无所谓”打破了:
- 耐久:这把枪打了 300 发,那把是新的
- 配件:这把装了消音器,那把没装
- 弹药:这把弹匣里剩 3 发,那把满了
- 改装:强化、附魔、裂纹
到这一步,id + count 已经描述不了现实了。两把 pistol_9mm 不再是”一件物品的两个拷贝”,而是两个独立个体。
正确的模型:每个个体是一个实例(instance),有自己的状态,能独立被拿起、放下、装备、丢弃、存进容器。
二、症状:操作了 A,动了 B
模型没升级之前,bug 长什么样?全是这一种味道——你明确选中了某个物品去操作,实际生效的是”另一个”:
- 背包里两把同 id 的枪,装备”那把装了消音器的”,结果装备上去的是另一把
- 丢弃选中的那一把,减掉的是堆栈里的 count,不是你选的那把
- 把带配件的枪存进容器,取出来的时候配件没了、重量也不对
这类 bug 最烦人的地方:单独看每一条都”对”,合在一起全错。 因为每一处代码都是按”同 id 等价”写的,逻辑自洽,只是整体与现实不符。
更隐蔽的是静默:没有任何报错,游戏照常跑,只是”那把枪”的行为不对。我一度以为是 UI 选中逻辑的问题,查了选中、查了渲染,最后发现根子在数据层——根本没有”个体”这个概念可以选中。
三、怎么改
改动分三层,一层不做好,上层全白搭。
1. 数据:实例携带完整状态
给物品实例加状态字段,配件存完整实例而不是 id 引用:
// 配件槽位存的是完整的 Item(含它自己的状态),不是裸 id
std::vector<InstalledAttachment> attachments; // 每个附件本身也是带状态的实例
为什么附件也要是完整实例?因为消音器自己也有耐久、也可能有改装。存 id 等于又把”个体”压回了”类别”,坑会原样复发。
质量、堆叠比较、存读档全部按实例的完整状态算:
- 实例质量 = 壳 + 装弹 + 嵌套内容 + 挂件
- 两把同 id 但配件不同的枪,不能合并成一个堆栈
2. 操作:一切动作落到”选中的那个实例”
这是关键。每个操作函数都要能表达”是这个 index 的那个个体”,而不是”某个同 id 的”:
// 吃/用/拿/装备,都带 source_index,指向具体实例
void Game::eat_item(const std::string& item_id, std::optional<int> source_index);
engine::Item Game::take_item_from_inv(const std::string& item_id,
bool keep_state,
std::optional<int> source_index);
bool Game::wield_item(const std::string& item_id, std::optional<int> source_index);
实现上就是遍历背包时跳过所有不是选中 index 的同 id 实例:
if (source_index && it != inv.items.begin() + *source_index) continue;
一行,但它是整个改动的核心:同 id 不再是”可互换”的,index 才是身份。
3. 兼容:旧存档不能炸
改了模型,旧的存档里全是 id + count 的堆栈。不能要求玩家重开档。
迁移规则:
- 旧手持缺
attachments字段 → 从旧的根mods迁移 - 明确空数组优先,二次加载不重复迁移
- 无法对应到具体个体的旧件,保留回包并记录,不静默丢弃
迁移代码要幂等:同一个旧档读两次,第二次不能再搬一遍。
四、修了多久
这篇我本来想写”一个下午修好了”。翻提交记录发现不对——这个模型的重构跨了将近两周:
- 9/26:存档开始保留完整实例(跨存储路径)
- 9/30:载具转移、装配枪的完整实例归属,121 项专项
- 10/10:手持(wield)、穿戴(wear)选中实例
- 10/12:丢弃(drop)、容器(drain)选中实例
- 10/13:吃(eat)、使用(use)选中实例
为什么这么长?因为每个操作动词都是一个独立的坑。
“丢弃选中实例”修好了,不等于”吃选中实例”就对了——每个动词的函数签名、遍历逻辑、状态搬运都是各自写的。所以它不是一次性重构,是沿着操作面逐个动词收口,一个动词一条门禁,绿一个再下一个。
到今天,吃/用这两条也刚收口(verify_e3_eat_selected_instance.py、verify_e3_use_selected_instance.py)。
五、教训
1. “同 id 可互换”是个会过期的假设。
只要物品带上任何个体状态(耐久、配件、内容),这个假设就死了。而它不会报错——只会让操作”错得很有规律”。
2. 数据模型不升级,应用层怎么修都是缝。
我一开始是在”修选中逻辑”,修了一堆,全没用。直到意识到:没有”个体”这个概念,就谈不上”选中某个个体”。根在数据层,不在 UI 层。
3. 带状态的对象,状态要跟着”个体”走,不要跟着”类别”走。
配件存完整实例而不是 id,就是这个原则。凡是”存个引用”图省事的地方,都是下一个坑。
4. 这类重构只能沿操作面逐个收口。
不存在”改一个核心函数全搞定”。每个动词独立验证,因为每个动词都独立地错。这也是为什么它花了两周而不是一天——慢是对的,快的那版才是错的。
下一篇讲存档。存档是这个项目的另一个重灾区:五七十多个”修一下持久化”的提交,每一个背后都是”这个状态在存读档之后丢了/变了”。它和这篇是同一类问题的两面——物品实例丢了状态,是存档问题;存档丢了实例,是物品问题。