背景

最近比较无聊,偶尔会和同学们一起在 Minecraft 的私服上玩一会,现在地狱交通已经初见雏形了,nether hub 已经搭好了,然而地狱交通网络应该基于哪一种运输方式还没有确定。常见的快速运输方式有两种:piston bolt 和冰船。

很显然按照每天半个小时的游戏时间是不可能有精力做双向的 piston bolt 的,况且论最高速度冰船比 piston bolt 还是快上许多的,因此几乎毫无疑议地大家都选择了冰船。

这篇文章是在代码角度对于高级冰船技术的分析。

全文的代码均为使用官方 mapping 以及 CFR 编译器进行反编译所得(但愿这并不违反 EULA)。

带方向校准的高端冰船

冰船系统一直为人所诟病,也是其不如 piston bolt 的一点是:它在运动中是需要玩家控制的。不仅要一直按着 W,还要使用 AD 确保船头一直向前。这在直道冰船上还好,但在斜线冰船道上就非常非常麻烦。

幸好,Rays Works 在几个月前分享了解决方案。其方案的思想是:

如果一开始船就放正,那么就只需要 W 而不需要左右调整了!

那怎么把船放正呢?

观察一下代码(net/minecraft/world/item/BoatItem):

@Override
public InteractionResultHolder<ItemStack>use(Level level, Player player, InteractionHand interactionHand) {
    Object object;
    ItemStack itemStack = player.getItemInHand(interactionHand);
    HitResult hitResult = BoatItem.getPlayerPOVHitResult(level, player, ClipContext.Fluid.ANY);
    ...
    if (hitResult.getType() == HitResult.Type.BLOCK) {
        object = new Boat(level, hitResult.getLocation().x, hitResult.getLocation().y, hitResult.getLocation().z);
        ((Boat) object).setType(this.type);
        ((Boat) object).yRot = player.yRot; // !
        ...
        return InteractionResultHolder.success(itemStack);
    }
    return InteractionResultHolder.pass(itemStack);
}

yRot 自然是 Y-axis Rotation 的意思,也就是说放下船的方向直接等于当时玩家的视角方向。

因此如果一开始视角方向, 那么问题就解决了!

如何确保每一次放船的时候视角方向都是一个固定值呢?

Rays Works 的解决方案是:用另外一艘船!

net/minecraft/client/player/LocalPlayer 当中:

@Override
public boolean startRiding(Entity entity, boolean bl) {
    if (!super.startRiding(entity, bl)) {
        return false;
    }
    ...
    if (entity instanceof Boat) { // !!!
        this.yRotO = entity.yRot;
        this.yRot = entity.yRot;
        this.setYHeadRot(entity.yRot);
    }
    return true;
}

可以看到,当玩家乘上船的一瞬间,玩家的视角方向会强制调整到船的方向。

因此我们只需要准备一艘校准船,确保这艘船的角度正确,之后要使用的时候直接先做上这艘船再立刻放船再坐上新放的船就可以了(简单来说就是三连右键)。

这就是 Rays Works 视频的主旨,我这里只是找到了对应的源代码而已。

放置角度与 Minecraft 的角度系统

Rays Works 在视频当中宣称通过它的方法可以把冰道修到任何角度与朝向。

似乎没有什么不对?只要把校准船放到任意角度就行了。

然而在 Ray 的视频当中可以看到,有的时候明明船放下去是 45°,不一会就转到了 46.4°,Ray 坦言这是这套冰船系统唯一比较麻烦的地方。Ray 提出的解决方案是:

If that happens, just remove this boat and place in a new one. ……and do it a couple times it actually fixes itself.

并认为这是一个随机行为,具有一定意义上的周期性,等一会就好了。

事实并非如此。有一些角度是无论如何都放不到的,刚放下去一会就会微微一转,然后角度就变了。属实糟心,我在自己服务器里面放了 20 次都没有放成功。那个时候我终于意识到 Ray 的 claim 可能是在扯淡,于是去看了下代码。

代码里面似乎没有任何一处体现了 “微微一转” 这种行为。最上面 BoatItem 的代码就是直接把 yRot 设成了玩家的 yRot。我用全文搜索对于 yRot 产生修改的片段,没有一处是这样的。yRot 是用 float 存储的,应该不会产生舍入误差这种事情。

整个事情让我对于 Minecraft 的屎山代码不由生出一份敬畏,我开始觉得这是玄学问题,直到看见 net/minecraft/network/protocol/game/ClientboundAddEntityPacket 的构造函数……

public class ClientboundAddEntityPacket implements Packet<ClientGamePacketListener> {
    ...
    private int xRot;
    private int yRot;

    public ClientboundAddEntityPacket(int n, UUID uUID, double d, double d2, double d3, float f, float f2, EntityType<?> entityType, int n2, Vec3 vec3) {
        ...
        this.xRot = Mth.floor(f * 256.0f / 360.0f);
        this.yRot = Mth.floor(f2 * 256.0f / 360.0f);
        ...
    }
}

以及 net/minecraft/client/multiplayer/PacketListener 如何处理这个 packet 的:

 @Override
 public void handleAddEntity(ClientboundAddEntityPacket clientboundAddEntityPacket) {
     Entity entity;
     ...
     if (entity != null) {
         entity.xRot = (float)(clientboundAddEntityPacket.getxRot() * 360) / 256.0 f;
         entity.yRot = (float)(clientboundAddEntityPacket.getyRot() * 360) / 256.0 f;
         ...
     }
 }

哦…… 原来还真有舍入误差这回事……Mojang 大概想省空间,虽然 rotation 在客户端和服务端内部都是 float 存储的,但是在网络传输的时候硬是塞到了 byte 里面。于是在这一层转换的帮助下,Minecraft 理论上就只有 256 种可能的角度了。

我猜测的整个放船的流程大致如下:

  1. 客户端玩家手持船右键
  2. 客户端发出一个 ServerboundUseItemPacket
  3. 服务端接收到 ServerboundUseItemPacket,然后执行 BoatItem 有关的代码
  4. 服务端新增船的实体,想客户端发送 ClientboundAddEntityPacket,这个时候角度被舍入了
  5. 客户端收到 ClientboundAddEntityPacket,将本地的船同步为舍入的角度,在 10gt 的插值作用下体现为 “微微一转”
  6. 玩家坐上去,这个时候一系列网络同步把服务端的船的角度也舍入了
sequenceDiagram
    participant Client
    participant Server
    Note left of Client: Player right clicks
    Client->>Server: ServerboundUseItemPacket
    Note right of Server: Execute code in <br/> BoatItem.use
    Server->>Client: ClientboundAddEntityPacket

因此,微微一转的问题其实根本就不是 Ray 说的随机事件,而是在网络传输当中舍入误差加上客户端线性插值带来的现象。看似的随机只是因为 F3 的精度不够,44.99 和 45.01 都显示为 45.0,虽然从代码当中我们可以清晰地看到两个数的舍入结果是不一样的。

这么做的好处是对于 90°,45° 等特殊角这一层舍入可以保证绝对的精确,避免了放成 45.01° 然后在船上开 1000 格偏离航道的惨剧。缺点是很多角度就不能精确达到了。两个相邻的可达角度差 1.40625°,大概勉强可以接受。

话又说回来了,ServerboundMovePlayerPacket 这种同步玩家信息倒是还是用 float 作为载体的。所以玩家的角度还是很精确的?难道是因为玩家相对于实体少很多所以有更多流量可以挥霍?

我再次对于 Minecraft 的代码肃然起敬。

辅助高级冰船的 Scarpet 脚本

在知道这些之后我们就可以有针对性地设计冰道了,因为人比较懒所以我写了一个 scarpet 的脚本:

__command() -> null;

_ice_mark(x, y, z) -> create_marker('ICE', l(x + 0.5, y + 0.5, z + 0.5));

_mark_x(y, x1, z1, x2, z2, w) -> (
    if(x1 > x2, l(x1, x2, z1, z2) = l(x2, x1, z2, z1));
    k = (z2 - z1) / (x2 - x1);
    for(range(x1, x2 + 1),
        x = _; z = round((x - x1) * k + z1);
        for(range(-w + 1, w), _ice_mark(x, y, z + _))
    )
);

_mark_z(y, x1, z1, x2, z2, w) -> (
    if(z1 > z2, l(x1, x2, z1, z2) = l(x2, x1, z2, z1));
    k = (x2 - x1) / (z2 - z1);
    for(range(z1, z2 + 1),
        z = _; x = round((z - z1) * k + x1);
        for(range(-w + 1, w), _ice_mark(x + _, y, z))
    )
);

mark(y, x1, z1, x2, z2, w) -> (
    dx = abs(x1 - x2); dz = abs(z1 - z2);
    if(dx == 0 || dx < dz, _mark_z(y, x1, z1, x2, z2, w),
        _mark_x(y, x1, z1, x2, z2, w))
);

clear() -> remove_all_markers();

_wrap_deg(x) -> (
    tmp = x % 360.0;
    if(tmp < -180, tmp + 360, tmp > 180, tmp - 360, tmp)
);

_wrap_internal(x) -> (
    tmp = x % 360.0;
    if (tmp < 0, tmp + 360, tmp)
);

_angle(x1, z1, x2, z2) -> -atan2(x2 - x1, z2 - z1);

_floor_angle(x) -> _wrap_deg(floor(x * 256 / 360.0) * 360 / 256);

angle(x1, z1, x2, z2) -> (
    theta = _angle(x1, z1, x2, z2);
    print(str('exact yaw: %f', theta));
    lb = floor_angle(_wrap_internal(theta));
    ub = floor_angle(_wrap_internal(theta + 360.0 / 256));
    print(str('floored yaw (lb): %f', lb));
    print(str('floored yaw (ub): %f', ub));
    ret = if(abs(lb - theta) < abs(ub - theta), lb, ub);
    print(str('suggested yaw: %f', ret));
);

_best_angle(x1, z1, x2, z2) -> (
    theta = _angle(x1, z1, x2, z2);
    lb = floor_angle(_wrap_internal(theta));
    ub = floor_angle(_wrap_internal(theta + 360.0 / 256));
    if(abs(lb - theta) < abs(ub - theta), lb, ub)
);

adjust(x1, z1, x2, z2, dir) -> (
    x = _best_angle(x1, z1, x2, z2);
    ov = 360; opt = 0;
    print(str('yaw: %f', x));
    if(
        dir == 'x',
        for(range(x2 - 1000, x2 + 1000), 
            tmp = abs(_angle(x1, z1, _, z2) - x);
            if (tmp < ov, ov = tmp; opt = _)
        );
        print(str('%f %f %f %f', x1, z1, opt, z2)),
        dir == 'z',
        for(range(z2 - 1000, z2 + 1000), 
            tmp = abs(_angle(x1, z1, x2, _) - x);
            if (tmp < ov, ov = tmp; opt = _)
        );
        print(str('%f %f %f %f', x1, z1, x2, opt)),
        print('direction must either be x or z')
    )
)

用法如下:

  1. /iceboat y x1 z1 x2 z2 w 标记一条从 \((x_1,y,z_1)\)\((x_2,y,z_2)\) 的,宽度为 \(2w-1\) 的冰道。

  2. /iceboat clear 清除标记。

  3. /iceboat angle x1 z1 x2 z2 计算站在 \((x_1,z_1)\) 望向 \((x_2,z_2)\) 的视角方向,最近的两个可达角度,以及推荐的可达角度。

  4. /iceboat adjust x1 z1 x2 z2 x/z 固定 \((x_1,z_1)\),沿 \(x/z\) 轴在 \((x_2,z_2)\) 附近寻找最贴近可达角度的方块坐标。考虑到在几千格之后哪怕 0.7° 的角度差都会有导致最终百格的偏差,这个命令非常重要。在算法实现角度,用三分写是最快的,但是考虑到从 - 180° 到 180° 有一个不连续间断点,导致写三分会涉及到一些繁琐的细节。我比较懒,所以就用了比较慢的写法(这个写法其实也有明显 bug,但是我不准备修)。

常见的使用流程:

  1. 确定冰道的两点。
  2. 固定一点(通常是 nether hub 或者已经固定留好空间的端点),使用 /iceboat adjust 微调另一端点的位置。同时计算得出角度。
  3. 使用 /iceboat mark 标记放冰的位置。
  4. 放冰。
  5. 使用 /iceboat clear 清除标记。

目前比较不人性化的一点是 carpet 的 create_marker 用的是隐形盔甲架实现的,hitbox 的制约导致有的时候放不了方块,用 area effect cloud 可能可以解决这个问题。