模拟玩家第三人称视角

简介

最近接触到了 emotecraft 这个 Minecraft 模组,可以让玩家在游戏中表演各种动画,在播放动画的过程中,玩家会自动进入第三人称,并且可以移动镜头观察自己。

于是突发奇想,想通过 Bukkit 服务器插件,在游戏里也实现这种播放玩家动画的功能。

经过几天的研究和编程,最后插件的基本功能已经完成,大概是下面这个样子。

动画效果

动画的实现,是通过 Blender 软件进行建模和编辑动画,编写 bpy 脚本将时间线上每个物体每一帧的动画数据,导出为一种自定义的格式,在 mc 中解析并通过展示实体(也可以用盔甲架)给逐帧渲染出来,具体实现细节本文不做过多讲述。

实现原理

由于 Bukkit 插件只能在服务器上运行,想要实现这样一个第三人称相机的效果,就需要通过游戏原版的特性来模拟,有些人可能会想到通过把玩家隐身并在每个游戏刻计算坐标来把玩家传送到相机的坐标,但是这样难以计算相机的旋转,并且玩家视角会随鼠标发生抖动。

实现原理是这样的,我们借助了一个相机实体,这个实体通过发包的形式显示在玩家客户端,不在于服务器,然后使玩家进入旁观者模式观察相机实体,通过计算坐标和朝向改变相机实体的位置来达到效果。

玩家观察相机实体这个操作也是通过发包实现的,所以在客户端和服务器上,玩家仍然保持着之前的游戏模式,也就是玩家仍然能够进行移动和转动视角的操作,我们再创建一个载具实体,让玩家“坐”在上面,这样玩家就只能进行转动视角的操作。

在服务器上,玩家就像是被固定在了原地,只能转动头部,而我们正是利用玩家的朝向向量,进行后续的计算,得到相机实体的坐标和朝向数据,相机实体始终位于玩家实体头部的正后方,随着玩家实体朝向的转动而移动。

向量计算

向量是高中数学的知识,向量具有长度和方向,在三维世界中由三个浮点数表示,由于相机始终位于玩家头部的正后方一定距离,也就是位于玩家朝向向量的反方向,得到玩家朝向向量之后就能计算出相机的坐标,分为如下几个步骤:

  1. 计算得到玩家朝向向量 v1。

  2. 将向量 v1 乘以 -1 得到反方向的向量,并进行单位化使其长度变为 1,最终得到向量 v2。

  3. 将向量 v2 乘以玩家与相机的距离得到最终向量 v3。

  4. 玩家头部的坐标加上向量 v3 就能得到相机坐标。

首先通过以下代码计算玩家的朝向向量:

//玩家的朝向是用俯仰角和偏航角表示的 要先转换成向量的形式
float rotX = player.getLocation().getPitch();
float rotY = player.getLocation().getYaw();

//计算朝向向量v1
Vector v1 = new Vector();
v1.setY(-Math.sin(Math.toRadians(rotZ)));
double xz = Math.cos(Math.toRadians(rotZ));
v1.setX(-xz * Math.sin(Math.toRadians(rotY)));
v1.setZ(xz * Math.cos(Math.toRadians(rotY)));

//计算向量v2
Vector v2 = v1.clone().multiply(-1).normalize();
//玩家头部与相机的距离
double distance = 4.0D;
//计算向量v3
Vector v3 = v2.clone.multiply(distance);

//得到相机位置
Location cameraLocation = player.getEyeLocation().add(v3);

由于相机实体的朝向与玩家朝向一致,所以不用额外计算,最后将相机传送到对应的位置就可以了。

完整实现

上面这部分其实就是最核心的代码了,然后就可以根据这个实现一个完整的相机功能,代码如下,因为涉及到发包所以使用了 NMS,服务端版本 1.19.4。

import com.google.common.collect.ImmutableList;
import com.mojang.authlib.GameProfile;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.*;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.decoration.ArmorStand;
import org.bukkit.Location;
import org.bukkit.craftbukkit.v1_19_R3.util.CraftLocation;
import org.bukkit.util.Vector;

import java.util.*;

/**
 * @author Nipuru
 * @since 2024/04/05 19:24
 */
public class PlayerCamera {

    private static final double DEFAULT_DISTANCE = 3.0D;
    private static final double ARMORSTAND_OFFSET = -1.1312500178814D;

    private final ServerPlayer player;
    private final ServerPlayer cameraman;
    private final ArmorStand armorStand;
    private double distance;

    public PlayerCamera(ServerPlayer player) {
        GameProfile gameProfile = new GameProfile(UUID.randomUUID(), "Cameraman");
        ServerPlayer cameraman = new ServerPlayer(player.server, player.getLevel(), gameProfile);
        ArmorStand armorStand = new ArmorStand(EntityType.ARMOR_STAND, player.getLevel());

        this.player = player;
        this.cameraman = cameraman;
        this.armorStand = armorStand;
        this.distance = DEFAULT_DISTANCE;
    }

    private void applyPosition() {
        Location playerLocation = player.getBukkitEntity().getLocation();
        Vector playerDirection = playerLocation.getDirection().normalize();

        Vector direction = playerDirection.clone().multiply(-1);
        Location cameraLocation = playerLocation.clone().add(direction.clone().multiply(this.distance));
        cameraLocation.setDirection(playerDirection);
        this.cameraman.setPos(CraftLocation.toVec3D(cameraLocation));
        this.cameraman.setYHeadRot(cameraLocation.getYaw());
        this.cameraman.setYRot(cameraLocation.getYaw());
        this.cameraman.setYBodyRot(cameraLocation.getYaw());
        this.cameraman.setXRot(cameraLocation.getPitch());
        this.armorStand.setPos(playerLocation.getX(), playerLocation.getY() + ARMORSTAND_OFFSET, playerLocation.getZ());
    }

    public void init() {
        this.cameraman.setInvisible(true);
        this.armorStand.setNoGravity(true);
        this.armorStand.setInvisible(true);
        this.armorStand.passengers = ImmutableList.of(this.player);
        this.applyPosition();
        this.sendPackets(this.getSpawnPackets());
    }

    public void destroy() {
        this.armorStand.passengers = ImmutableList.of();
        this.sendPackets(this.getDespawnPackets());
    }

    public void update() {
        this.applyPosition();
        this.sendPackets(this.getUpdatePackets());
    }

    public void setDistance(double distance) {
        this.distance = distance;
    }

    public double getDistance() {
        return this.distance;
    }

    private void sendPackets(List<Packet<?>> packets) {
        for (Packet<?> packet : packets) {
            this.player.connection.send(packet);
        }
    }

    private List<Packet<?>> getSpawnPackets() {
        ClientboundAddEntityPacket spawnArmorStand = new ClientboundAddEntityPacket(this.armorStand);
        ClientboundSetEntityDataPacket data = new ClientboundSetEntityDataPacket(this.armorStand.getId(), Objects.requireNonNull(this.armorStand.getEntityData().getNonDefaultValues()));
        ClientboundSetPassengersPacket setPassenger = new ClientboundSetPassengersPacket(this.armorStand);
        ClientboundPlayerInfoUpdatePacket playerInfoUpdate = ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(Collections.singleton(this.cameraman));
        ClientboundAddPlayerPacket spawnCameraman = new ClientboundAddPlayerPacket(this.cameraman);
        ClientboundSetEntityDataPacket data2 = new ClientboundSetEntityDataPacket(this.cameraman.getId(), Objects.requireNonNull(this.cameraman.getEntityData().getNonDefaultValues()));
        ClientboundSetCameraPacket setCamera = new ClientboundSetCameraPacket(this.cameraman);
        return Arrays.asList(spawnArmorStand, data, setPassenger, playerInfoUpdate, spawnCameraman, data2, setCamera);
    }

    private List<Packet<?>> getUpdatePackets() {
        ClientboundTeleportEntityPacket teleportCamera = new ClientboundTeleportEntityPacket(this.cameraman);
        ClientboundRotateHeadPacket rotateHead = new ClientboundRotateHeadPacket(this.cameraman, (byte) Mth.floor(this.cameraman.getYHeadRot() * 256.0F / 360.0F));
        return Arrays.asList(teleportCamera, rotateHead);
    }

    private List<Packet<?>> getDespawnPackets() {
        ClientboundSetPassengersPacket setPassenger = new ClientboundSetPassengersPacket(this.armorStand);
        ClientboundRemoveEntitiesPacket removeArmorStand = new ClientboundRemoveEntitiesPacket(this.armorStand.getId());
        ClientboundRemoveEntitiesPacket removeCameraman = new ClientboundRemoveEntitiesPacket(this.cameraman.getId());
        ClientboundPlayerInfoRemovePacket playerInfoRemove = new ClientboundPlayerInfoRemovePacket(Collections.singletonList(this.cameraman.gameProfile.getId()));
        ClientboundSetCameraPacket setCamera = new ClientboundSetCameraPacket(this.player);
        return Arrays.asList(setPassenger, removeArmorStand, removeCameraman, playerInfoRemove, setCamera);
    }
}

懒得写注释了,使用起来也非常简单,先把玩家隐藏起来,新建一个对象,第一步调用 init() 方法,并且创建一个定时任务,每个游戏刻执行一次 update() 方法,最后使用 destroy() 方法销毁,可以优化的地方还有很多,仅提供一种思路供参考。