从技术角度看Minecraft的高级冰船技术

背景

最近比较无聊,偶尔会和同学们一起在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可能可以解决这个问题。