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);
}
实际上,还有三种方法:onItemUse
、onItemUseFirst
、useOn
;
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
的常用属性:
location
(Vector3d
,基类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);