← 全部文章

坐标是三维的,代码不是:一次把「楼层」补回整条链路的修复

上一篇讲的是显式的重构:把代码搬出主文件,结构上看得见。这篇讲另一类——没有人在重构,缺陷就长在那里。

Outland 的地图是多层的(map_.cur_z()),玩家有 Position{x, y, z},怪物有,物品有。但载具和陷阱没有:

// 载具快照(按世界存储,切世界不残留)
struct VehicleState {
    int x = 0, y = 0;
    std::string vehicle_id;
    // ...
};

// 陷阱快照(按世界存储,切世界不丢)
struct TrapState {
    int x = 0, y = 0;
    std::string id;        // 陷阱定义 id
    int use_left = 1;
    int triggered = 0;
};

单层地图里这是对的。有了第二层,它就是错的——错得非常安静。

一、症状:没人在二楼,二楼的东西却动了

两个实例分开说。

载具。 载具的归属查找函数长这样:

static engine::World::Entity player_vehicle(const engine::World& world) {
    for (const auto e : world.view<engine::Vehicle>())
        if (world.alive(e)) return e;     // ← 返回世界上的第一辆载具
    return entt::null;
}

它不接玩家坐标。你在二楼打开载具菜单,它给你返回一楼那辆车;你在二楼按”上车”,上去的是楼下那辆。更糟的是 climb_stairs() 本身没有任何载具约束——你可以坐在一楼的车里直接上楼,然后车在一楼、你在二楼,player_vehicle() 返回的还是那辆一楼的车。

陷阱。 trigger_trap_check(x, y) 只比对两个坐标:

for (const auto e : world_.view<engine::Position, engine::Trap>()) {
    const auto& p = world_.get<engine::Position>(e);
    if (p.x == x && p.y == y) { found = e; break; }   // ← 二楼同格的陷阱也会命中
}

于是一楼那枚绊线,在你二楼同格走过时炸了。拆除同理,“这里已有陷阱”的去重也一样:二楼放不了陷阱,因为一楼同格有。而快照层的 capture/restore 只存 x、y ——存档一次,所有陷阱全部落到一楼。

这类 bug 的排查难点不在定位,在它看起来像别的 bug:玩家会描述成”载具乱传送”、“陷阱莫名其妙爆炸”、“拆完陷阱材料没了”。根因是同一个:三层数据结构少了一个字段。

二、修法:先给数据补字段,再让编译器干活

修的第一步不是找调用点,是改结构体:

struct VehicleState { int x = 0, y = 0, z = 0; /* ... */ };
struct TrapState    { int x = 0, y = 0, z = 0; /* ... */ };

第二步同样是改签名,而不是先在函数体里加判断:

// game.hpp
-    void trigger_trap_check(int x, int y);      // 踏入检查(玩家/怪物)
+    void trigger_trap_check(int x, int y, int z);

这是我在这类修复里最依赖的一招。签名一变,编译器会把每一个调用点指给你——包括你可能忘了的那些。这次它指出来的是三个怪物移动分支(逃跑、追击、常规移动)、玩家移动、爆炸来源、以及 crash-test 里的一段邻域探测。全在同一个 trigger_trap_check 上。

如果反过来、先改函数体(if (p.x==x && p.y==y && p.z==玩家楼层)),编译器什么都不会说,你得靠肉眼把所有调用点找一遍。漏掉怪物移动那一处,玩家的陷阱对怪物失效——而它不会报错,只会在某次试玩里表现为”怪物踩了陷阱没事”。

三、一处很容易漏的地方:判定之外

陷阱除了”踩”,还有一串周边行为,它们各自也要带楼层:

行为修复
触发后掉落物spawn_item_at(idef, x, y) → spawn_item_at(idef, x, y, z)
陷阱击杀留尸体spawn_corpse(x, y, mdef) → spawn_corpse(x, y, mdef, z)
拆除陷阱先取 const int z = world_.get<engine::Position>(player_).z;,再按 x/y/z 匹配
放置去重if (p.x==x && p.y==y) → ... && p.z == z
玩家判定is_player = (ppos.x==x && ppos.y==y) → 追加 && ppos.z == z
谁踩的(找怪物)if (mp.x==x && mp.y==y) → 追加 && mp.z == z

掉落和尸体这两条是最典型的”判定修好了但世界还是错的”:陷阱在二楼正确触发了,钢珠掉在一楼。

快照层是最后一公里,也是唯一的持久化路径:

// capture
state.x = pos.x;  state.y = pos.y;
+ state.z = pos.z;
// restore
- world.emplace<engine::Position>(e, state.x, state.y);
+ world.emplace<engine::Position>(e, state.x, state.y, state.z);

以及一处不起眼但必须改的:crash-test 里”找最近的陷阱”,原本会把一楼那枚算进来 —— 加一行 if (p.z != ppos.z) continue;。

四、载具侧:把”哪辆车”变成显式判定

载具这边的修法是引入一个语义明确的小函数,然后所有取车路径统一走它:

static bool same_floor_vehicle(const engine::World& world, entt::entity vehicle, entt::entity player) {
    return vehicle != entt::null && player != entt::null &&
        world.alive(vehicle) && world.alive(player) &&
        world.has<engine::Vehicle>(vehicle) && world.has<engine::Position>(vehicle) &&
        world.has<engine::Position>(player) &&
        world.get<engine::Position>(vehicle).z == world.get<engine::Position>(player).z;
}

static engine::World::Entity player_vehicle(const engine::World& world, entt::entity player);

然后把它铺到全部取车路径:载具菜单、上车、下车、驾驶、货舱、修车、装/卸部件。原来各自的回退链是”ui_vehicle_id_ → in_vehicle_ → player_vehicle()”,现在每一跳都过 same_floor_vehicle():

entt::entity e = (same_floor_vehicle(world_, ui_vehicle_id_, player_))
                   ? ui_vehicle_id_
                   : (same_floor_vehicle(world_, in_vehicle_, player_) ? in_vehicle_
                                                                      : player_vehicle(world_, player_));

这一类修复的成败全在”漏没漏路径”。 载具菜单修了、货舱忘了,症状就变成”能上车但存不进东西”。

还有两处细节:

  • 造车时写楼层:build_vehicle() / build_vehicle_at() / 调试造车,三处 emplace<Position>(e, x, y) 全要补 p.z,否则新造的车天生在一楼。
  • 占用检查也要带楼层:build_vehicle_at 里”此处有怪物”的判断原本只比 x/y —— 二楼有一楼同格的怪物,车就造不出来。
  • 楼梯上禁止开车:climb_stairs() 开头加一句”请先下车,再使用楼梯”。这是产品决策不是技术修复,但它堵住了唯一能让车辆和玩家分处两层的入口。

五、门禁怎么写:旧二进制必须先红

两次修复各配一个独立门禁,都遵循同一条规矩:拿已发布的旧二进制跑一遍,它必须红,而且要红在预期的那几条上。

  • 载具:tools/verify_c4_vehicle_floor.py(140 行)——正常构建、e 键入口、真实键盘/鼠标操作、货舱、楼梯、旧存档与非法存档。
  • 陷阱:tools/verify_c4_trap_floor.py(185 行)——真实建造流程、失败退还、玩家/怪物/爆炸三种触发来源、键盘与鼠标拆除、快照往返、旧楼层与非法楼层。

同时两条老门禁要跟着改口径:verify_d1_vehicle_snapshot.py 与 verify_d1_trap_snapshot.py。这里的判断原则和上一篇一样——门禁红了先看口径是不是过时了,别先去动实现。楼层是新加到快照里的字段,旧门禁的期望值里没有它,红的是期望值。

六、留给自己的两条规矩

  1. 位置类数据结构一律三坐标。 只要屏幕上存在一个以上的 z 层,“x,y 结构体”就是定时炸弹。加 z 的成本是一行,不加的成本是判定、放置、拆除、掉落、尸体、快照六处各自出错,而且互不相关。
  2. 修复靠签名扩散,不靠肉眼扫调用点。 加参数 → 编译报错 → 逐个处理,编译器保证不漏。这条在这次修复里直接抓出了三个怪物移动分支。

值得说一句的是:这两个缺陷都不是新代码引入的,是原来那套”单层地图”的假设,在多层地图上线之后没有跟着更新。这类账不会自己浮上来,它只会以”载具乱传”、“陷阱乱炸”的形式出现在试玩里——直到你给数据结构补上那个字段。