← 全部文章

两把相同的枪,不是一件物品

本文目录
  1. 1. 数据:实例携带完整状态
  2. 2. 操作:一切动作落到”选中的那个实例”
  3. 3. 兼容:旧存档不能炸

上一篇写时间系统,讲的是”一个概念没想清楚,整个系统返工”。

这篇是同一类问题的另一种形态:一个数据模型没升级,一长串操作就各错各的。

一、从”堆栈”到”实例”

早期 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. 这类重构只能沿操作面逐个收口。

不存在”改一个核心函数全搞定”。每个动词独立验证,因为每个动词都独立地错。这也是为什么它花了两周而不是一天——慢是对的,快的那版才是错的。


下一篇讲存档。存档是这个项目的另一个重灾区:五七十多个”修一下持久化”的提交,每一个背后都是”这个状态在存读档之后丢了/变了”。它和这篇是同一类问题的两面——物品实例丢了状态,是存档问题;存档丢了实例,是物品问题。