Skip to main content

2.4 Miscellenous

到目前为止,我们还不知道如何定义物品(item)或者方块(block)的右击行为。

除了右击,还有很多常用功能例如视野投射、冷却消耗、耐久消耗、声音等等效果,它们非常琐碎,我们在本节将以一个例子串起来讨论。

2.4.0 补充:Viewport & Raycast

由于本节涉及 如何判断用户的鼠标指针指向(朝向)的具体位置,以及 3D 游戏开发的知识,故在本节开头补充。

使用 Unity / Godot 等游戏引擎开发的朋友可能会比较熟悉,可以跳过本节。

对于一个 3D 游戏,玩家对游戏的操控也是局限于 2D Screen 上(XR 除外),这意味着我们向游戏的输入永远是一个 2 维坐标(例如触屏、单击),那么游戏程序如何知道玩家指的是 3D 场景中的哪个位置呢?

答案就是 raycast。玩家在 3D 场景中通常会从一个视角来观察整个环境,这个视角位置通常被游戏引擎抽象为 “摄像机”(Camera)。例如,下图是一个正在开发的 Godot 3D 游戏项目,半空中悬停一个摄像机,表示游戏启动后玩家的初始视野将位于摄像机的位置上。

玩家的 2D 屏幕,在几何上可以被认为是:摄像机视角射线所包围的位于空间中的矩形框。如上图的粉色棱锥所示,它的 4 条棱看作玩家屏幕所能看到的 4 个角投射的视线,底面的矩形可以视作是玩家的屏幕。这个在 3D 场景中的屏幕被称为 “视口”(viewport),那个底面的矩形被称为 “视口矩形”(viewport rectangular);

而相机所在的全局位置点(这里称为 “相机原点”),到视口矩形所在平面的垂线(射线),其所指的方向就是相机的朝向(orientation)。在 MC 中,你可以把中心的 “+” 字看作这个垂线在视口上的垂足。

玩家在屏幕上单击的位置点,就对应者这个视口矩形上的一个以 “视口的相对坐标”(指以视口的某个角或者中心为坐标系的坐标)表示的点。

现在,我们就能解决之前的疑惑了:程序如何知道玩家指的是 3D 场景中的哪个位置?

答案很简单:

  • 感性地来说,就是沿着鼠标所指 “视线方向”,最后落到的某个对象上;
  • 理性地来说,就是 “相机原点” 与玩家在视口上指定点,二者连线构成的射线,与全局场景相交的对象的位置;

找这个与视线相交的对象的过程就称为 raycast(“视野投射”);

3D 游戏可以通过这种方法来确认玩家真正想要交互的对象。

2.4.1 从一个例子开始:自定义传送物品

本节,我们用例子同时说明 Item 右击、投掷行为以及 raycast 在 MC Forge 中的具体行为。

假设我们想设计一个的物品(Item),右击后它会沿玩家视线方向将玩家传送到最大的 reachable 的位置,应该怎么做?

首先,为 Item 定义右键行为非常简单,就是重写 Item#use 方法:

@Override
public ActionResult<ItemStack> use(World world, PlayerEntity player, Hand hand) {
return super.use(world, player, hand);
}

实际上,还有三种方法:onItemUseonItemUseFirstuseOn

  • onItemUse:原版 MC 中当玩家对着方块右键使用时会调用的方法,无法被 overwrite,可以尝试通过 onItemUseFirst 或者 Forge Event(参见第 3 章)或者 mixin 来实现。和上面 use 的区别是,上面的 use 当玩家最自然的(innate)右键方法(即对着空气的右键);
  • onItemUseFirst:实际上是 IForgeItem 接口中的方法,在每次 vanilla 方法 onItemUse 被调用前,Forge 引擎都会先调用这个方法。如果返回 ActionResultType.PASS,那么接下来就会继续执行 onItemUse(委托给它继续处理);返回任何其他值都相当于告诉 Forge 事件已处理,不会继续执行 onItemUse
  • useOn:当玩家面向方块使用右键时会触发的方法。

另外我们介绍一下在 MC 中如何做 raycast 判定。首先注意一下 Item 类的静态方法:

protected static BlockRayTraceResult getPlayerPOVHitResult(World pLevel, PlayerEntity pPlayer, RayTraceContext.FluidMode pFluidMode);

这个方法的作用是以玩家摄像头为原点,以当前视角指向的方向为方向,以玩家的最大可达距离为长度组成的线段落到另一端的结果。最后一个 FluidMode 可取 NONE / SOURCE / ANY,表示 “不计液体”、“blocks + 仅计液体源”、“blocks 和包含任何有液体的区域”;

BlockRayTraceResult 的常用属性:

  • locationVector3d,基类 RayTracrResult 属性);
  • direction:相交的方块面的法向量方向;
  • blockPos:相交的方块位置;
  • miss:是否没有指向任何方块(例如看向天空);
  • inside:玩家的头(camera)是否位于方块内部(例如脚手架内);

然后我们还需要了解如何改变玩家的位置:

public void setPos(double pX, double pY, double pZ);

这是 Entity 类的方法,理论上可以对所有实体都应用这个方法。我们在第 7 章再详细讨论。

综合上述思路,我们可以定义这个 Item:

public class TestMe extends Item {
public TestMe() {
// 自己定义的奇怪的物品组
super(new Properties().tab(QuirkyRegistry.quirkyGroup));
}
@Override
public ActionResult<ItemStack> use(World pLevel, PlayerEntity pPlayer, Hand pHand) {
BlockRayTraceResult res = Item.getPlayerPOVHitResult(pLevel, pPlayer, RayTraceContext.FluidMode.ANY);
if (!res.isInside()) {
BlockPos pos = res.getBlockPos();
pPlayer.setPos(pos.getX(), pos.getY(), pos.getZ());
}
return super.use(pLevel, pPlayer, pHand);
}
}

基本的功能已经实现,现在我们需要作出一些改进:

  • 传送距离是否不够?我们应该自定义可以传送的距离:模仿 Item.getPlayerPOVHitResult 的实现代码,写出自己的 raycast 函数:

    protected static BlockRayTraceResult rayCast(World pLevel, PlayerEntity pPlayer, RayTraceContext.FluidMode pFluidMode) {
    float f = pPlayer.xRot;
    float f1 = pPlayer.yRot;
    Vector3d vector3d = pPlayer.getEyePosition(1.0F);
    float f2 = MathHelper.cos(-f1 * ((float)Math.PI / 180F) - (float)Math.PI);
    float f3 = MathHelper.sin(-f1 * ((float)Math.PI / 180F) - (float)Math.PI);
    float f4 = -MathHelper.cos(-f * ((float)Math.PI / 180F));
    float f5 = MathHelper.sin(-f * ((float)Math.PI / 180F));
    float f6 = f3 * f4;
    float f7 = f2 * f4;
    // 定义最大的传送距离为 15 格
    double range = 15;
    Vector3d vector3d1 = vector3d.add((double)f6 * range, (double)f5 * range, (double)f7 * range);
    return pLevel.clip(new RayTraceContext(vector3d, vector3d1, RayTraceContext.BlockMode.OUTLINE, pFluidMode, pPlayer));
    }
  • 直接将玩家的位置设置为方块的位置可能会导致玩家卡在方块中。我们应该在获得方块位置后计算空闲位置;

    // 使用 BlockPos#relative 方法来获取落点方块平面沿法向量的相邻位置
    BlockPos lookPos = ray.getBlockPos().relative(ray.getDirection());
  • 定义冷却时间:

    // 指定玩家实体当前冷却列表,并且给当前物品(this)添加 60 ticks(也就是 3 秒)的冷却
    player.getCooldowns().addCooldown(this, 60);
  • 重置玩家的掉落速度:

    // Minecraft 1.16.5 中判断掉落伤害和速度的就是 Entity.fallDistance
    player.fallDistance = 0.0F;
  • 播放声音:

    // World#playSound 签名请参见源码。声音事件可以自己定义
    world.playSound(player, player.getX(), player.getY(), player.getZ(), SoundEvents.ENDERMAN_TELEPORT, SoundCategory.PLAYERS, 1.0F, 1.0F);
  • 消耗耐久:

    注意到物品的当前耐久是以 damageValue 的形式存放于 ItemStack 中,而最大耐久是以 maxDamage 的形式存在于 Item 中。在初始化 Item 对象时,一旦设置了耐久不为 0,就需要将 maxStackSize 设置为 1(不可堆叠),参见下面的 Item.Properties 的方法:

    因此我们在 Item 类型构造时这么设置 Properties:

    public TestMe() {
    super(
    new Properties()
    .tab(QuirkyRegistry.quirkyGroup)
    .durability(20) // 自己设定这个值,或者自己管理成常量
    );
    }

    然后在 use 是给予消耗耐久的逻辑,当耐久不再大于 0 时删除物品:

    // 获取当前手上物品的 ItemStack
    ItemStack stack = player.getItemInHand(hand);
    // 消耗耐久
    stack.setDamageValue(stack.getDamageValue() + 1);
    // ItemStack#setCount 为 0 时,程序会自动将当前的 stack 更新为 ItemStack.AIR(清除)
    if (stack.getDamageValue() >= stack.getMaxDamage()) stack.setCount(0);