用Java重现《塞尔达传说 王国之泪》材料复制 bug
前两天看到有人发现了《塞尔达传说 王国之泪》里的材料复制 bug。
在试用后我发现这个 bug 的规律,同时也窥得些许王国之泪的背包管理逻辑。于是用代码复现了出来。
现象
游戏中 bug 的触发步骤:
- 假设在背包中所有材料如下: A 材料有1 个,B 材料有 4 个,C 材料有 5 个,且排列顺序是 A, B, C
- 在滑翔状态打开背包,同时取出 A 材料 1 个,B 材料 4 个并丢弃,关闭背包
- 落地后发现背包中剩下的材料有:B 材料 4 个,C 材料 1 个
- 捡起刚才丢弃的 A 材料 1 个和 B 材料 4 个,于是此时背包内容:A 材料 1 个,B 材料 8 个,C 材料 1 个
通过上述步骤,无中生有了 4 个 B 材料,但是却少了 4 个 C 材料。
通过多次试验,发现某材料增加后,其后面一种材料会减少相应的数量,直到减少到 0 为止。
如果增加的材料本身就位于最后一个位置,则没有任何其它材料损失。
剖析
这个情况其实是典型的数据一致性问题。
由于我没有开发过类似游戏,所以我仅仅从我熟悉的角度去简单解释这个问题。
从上面的步骤可以看出,所有操作没有在一个事务里(也许它没有事务的概念,又或者为了性能或者游戏表现形式没有采用事务而是手动控制)。并且逻辑操作可以拆分为下面几步:
- 记录要取的材料所在背包中的位置
- 根据背包位置取材料(这个取其实只是在手上凭空增加材料,还没有涉及到扣减逻辑)
- 根据要取的材料顺序扣减材料,如果扣减后有位置的材料数量是 0,则所有后续材料都向前挪一格
- 继续根据背包位置扣减材料数量
在简化逻辑以后会发现,这其实是个很明显的bug,不过可能由于游戏中的逻辑判断十分复杂,还和角色滞空状态,游戏前端表现步骤等等相关联,导致没能发现。
重现
先定义一个简单的材料实体
/**
* 材料物品实体
* @param id 编号
* @param name 名称
* @param attackPoint 余料建造攻击力
* @param healPoint 回复点数
* @param description 说明
*/
public record Material(int id, String name, int attackPoint, int healPoint, String description) {}
然后材料图鉴和初始化也搞一搞
// 材料图鉴
public static final HashMap<Integer, Material> materialHandBook;
static {
materialHandBook = new HashMap<>();
materialHandBook.put(1, new Material(1, "金苹果", 1, 6,""));
materialHandBook.put(2, new Material(2, "椰子", 1, 4,""));
materialHandBook.put(3, new Material(3, "海拉鲁番茄", 1, 4,""));
materialHandBook.put(4, new Material(4, "钻石", 25, 0,""));
materialHandBook.put(5, new Material(5, "古代之刃", 50, 0,""));
materialHandBook.put(6, new Material(6, "巨大光亮花的种子", 1, 0,""));
materialHandBook.put(7, new Material(7, "木柴捆", 1, 0,""));
materialHandBook.put(8, new Material(8, "左纳乌能源", 2, 0,""));
}
重点来了,背包类,为了简化逻辑,假设林克都是单手掏背包(单线程)
/**
* 背包类
*/
public static class Bag {
// 背包容量单位(格子)
@Data
@AllArgsConstructor
private static class Slot {
private int materialId;
private int count;
}
// 背包容量
private static final Slot[] slots = new Slot[128];
// 放东西入背包
public void put(int materialId) {
int i = 0;
// 有则直接数量加1
for (; i < slots.length && slots[i] != null; i++) {
if (slots[i].materialId == materialId) {
slots[i].count++;
return;
}
}
// 没有则放到最后一位,数量初始化为1
slots[i] = new Slot(materialId, 1);
}
private int getIndexFromSlot(int materialId) {
for (int i = 0; i < slots.length; i++) {
if (slots[i].materialId == materialId) {
return i;
}
}
return -1;
}
// 取物品出背包 (1 <= indexes.length <= 5,由外部保证)
public List<Material> multiGet(int[] materialIds) {
// 转化为 index - count 键值对
Map<Integer, Integer> indexCountMap = new HashMap<>();
for (int materialId : materialIds) {
indexCountMap.compute(getIndexFromSlot(materialId), (k, v) -> (v == null) ? 1 : v + 1);
}
// 1. 物品取出
List<Material> materials = new ArrayList<>();
for (Map.Entry<Integer, Integer> entry : indexCountMap.entrySet()) {
int idx = entry.getKey();
int count = entry.getValue();
if (slots[idx] != null) {
for (int i = 0; i < count; i++) {
materials.add(materialHandBook.get(slots[idx].materialId));
}
}
}
// 2. 物品扣减
for (Map.Entry<Integer, Integer> entry : indexCountMap.entrySet()) {
int idx = entry.getKey();
int count = entry.getValue();
if (slots[idx] != null) {
slots[idx].count -= count;
int i = idx;
if (slots[i] != null && slots[i].count <= 0) {
while (i + 1 <= slots.length) {
if (i + 1 < slots.length) {
slots[i] = slots[i + 1];
} else {
slots[i] = null;
}
i++;
}
}
}
}
return materials;
}
// 显示背包内容
public void show() {
System.out.println("==========背包==========");
for (Slot slot : slots) {
if (slot != null) {
Material material = materialHandBook.get(slot.materialId);
System.out.println(material.name + ": " + slot.count);
}
}
System.out.println("=======================");
}
}
写个 main 测试一下:
public static void main(String[] args) {
Bag bag = new Bag();
bag.put(1);
bag.put(1);
bag.put(7);
bag.put(8);
bag.put(8);
bag.put(8);
bag.put(8);
System.out.println("初始背包");
bag.show();
List<Material> materials = bag.multiGet(new int[]{7,8,8,8,8});
for (Material material : materials) {
System.out.println("取出的物品:" + material.name);
}
bag.show();
for (Material material : materials) {
System.out.println("捡起物品:" + material.name);
bag.put(material.id);
}
System.out.println("复制材料后的背包");
bag.show();
}
结果:
初始背包
==========背包==========
金苹果: 2
木柴捆: 1
左纳乌能源: 4
=======================
取出的物品:木柴捆
取出的物品:左纳乌能源
取出的物品:左纳乌能源
取出的物品:左纳乌能源
取出的物品:左纳乌能源
==========背包==========
金苹果: 2
左纳乌能源: 4
=======================
捡起物品:木柴捆
捡起物品:左纳乌能源
捡起物品:左纳乌能源
捡起物品:左纳乌能源
捡起物品:左纳乌能源
复制材料后的背包
==========背包==========
金苹果: 2
左纳乌能源: 8
木柴捆: 1
=======================
总结
这个 bug 的问题在于把背包容量改了,却没有更新要扣减的位置信息。
有点像 Java 在循环里删除数组元素,不同的是 Java 会报错,而游戏里显然是做了一定的健壮性处理,不过这个健壮性处理又会令问题在出现时难以被发现。
说明一下,我用 Java 重现出来的 bug 很好修,但游戏里的就不一定了,复杂程度可能高出好几个量级,不要想当然用简单逻辑去替代复杂问题(这种行为往往是懂一点但又不多的人容易犯的错误)。
最后,合理用 bug 可以护肝,过度用 bug 加速游戏生命周期,好游戏不多,且玩且珍惜。
评论
其他文章