本篇教程的视频

本篇教程的源代码

GitHub地址:

本篇教程目标

  • 理解「方块不能坐,实体才能坐」这件事
  • 学会写一个简陋但够用的自定义实体
  • 学会注册实体类型和它的渲染器
  • 学会在方块被右键时生成实体、让玩家骑上去

介绍

上一篇我们做了个沙发,能拼能连,但玩家只能站在上面,坐不下去

这一期把「坐」这个功能补上

为什么得用实体

Minecraft 里「骑乘」这套逻辑是挂在实体上的

马、猪、矿车、船,都是实体,玩家能坐上去,靠的是 Entity 里的 startRiding 那一套

方块没有这个能力

所以我们的做法是:右键沙发的时候,在沙发的位置上生成一个看不见、不能动的小实体,然后让玩家骑上去

玩家看起来是「坐在沙发上」,实际上是坐在一个隐形实体上

这个实体在玩家站起来之后就没用了,得自己删掉,不然坐一次留一个,久了会卡

这期的做法参考的是 Mr.Crayfish 的家具模组

查看源代码

Entity 要求我们实现什么

Entity 是个抽象类,我们可以先看一下它的抽象方法,因为待会我们继承这个类之后就需要实现它们

1
2
3
4
5
6
7
protected abstract void defineSynchedData(SynchedEntityData.Builder entityData);

public abstract boolean hurtServer(ServerLevel level, DamageSource source, float damage);

protected abstract void readAdditionalSaveData(ValueInput input);

protected abstract void addAdditionalSaveData(ValueOutput output);

defineSynchedData 是数据同步,客户端和服务端要对得上的那些字段都放这里,比如血量、坐骑的品种

hurtServer 是受伤逻辑,只有服务端会调

后两个是数据持久化,也就是将实体的数据写入存档或者从存档里读出来

我们的座椅什么都不用同步、不用存,所以前三个空着,hurtServer 返回 false(打不掉,也就不会被打死)

骑乘相关的几个方法

1
2
3
4
5
public Vec3 getVehicleAttachmentPoint(final Entity vehicle);
protected boolean canRide(final Entity vehicle);
protected void addPassenger(final Entity passenger);
protected void positionRider(final Entity passenger, final Entity.MoveFunction moveFunction);
public Vec3 getDismountLocationForPassenger(final LivingEntity passenger);

这些都有默认实现,我们按需要重写

getVehicleAttachmentPoint 决定乘客坐在载具的哪个高度,原版猪和马不一样就是因为这个

positionRider 每一帧都在给乘客摆位置,想限制乘客的朝向就改它

getDismountLocationForPassenger 决定玩家脱离时落在哪,要给玩家找一个落脚的地方,不然容易卡在方块里面

startRiding

1
2
3
4
5
public final boolean startRiding(final Entity entity) {
return this.startRiding(entity, false, true);
}

public boolean startRiding(final Entity entityToRide, final boolean force, final boolean sendEventAndTriggers) {

1.21 里是 startRiding(entity, force),现在中间多了个 sendEventAndTriggers,控制要不要发骑乘的游戏事件(成就判定也会用到)

动手写

SeatEntity

先建个 SeatEntity,继承 Entity

1
2
3
4
5
6
public class SeatEntity extends Entity {
public SeatEntity(EntityType<? extends SeatEntity> type, Level level) {
super(type, level);
this.noPhysics = true;
}
}

我们加上noPhysics = true,让这个实体不参与物理相关的内容,比如碰撞,不然它和沙发自己的碰撞箱打架

一个私有的构造函数

1
2
3
4
5
private SeatEntity(Level level, BlockPos pos, double yOffset, Direction direction) {
this(ModEntities.SEAT, level);
this.setPos(pos.getX() + 0.5, pos.getY() + yOffset, pos.getZ() + 0.5);
this.setRot(direction.toYRot(), 0.0f);
}

这个构造函数是给我们自己用的,负责把实体放到方块正中间(所以 +0.5),再按沙发的朝向转一下

yOffset 是坐的高度,是我们后面生成这个实体时要传入的参数,大家可以根据实际需求进行调节

direction.toYRot() 把方位转成角度,NORTH 是 0,SOUTH 是 180,实际上就是和我们方块的朝向一致

四个必须实现的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
protected void defineSynchedData(SynchedEntityData.Builder builder) {
// 数据同步,这里没有需要同步的数据
}

@Override
protected void readAdditionalSaveData(ValueInput input) {
// 数据持久化,读取
}

@Override
protected void addAdditionalSaveData(ValueOutput output) {
// 数据持久化,保存
}

@Override
public boolean hurtServer(ServerLevel level, DamageSource source, float damage) {
return false;
}

tick 里自己清理

1
2
3
4
5
6
7
8
9
@Override
public void tick() {
super.tick();
if (!this.level().isClientSide()) {
if (this.getPassengers().isEmpty() || this.level().isEmptyBlock(this.blockPosition())) {
this.remove(RemovalReason.DISCARDED);
}
}
}

为了减少实体对游戏性能的影响,我们可以在tick里面让实体移除自己

两个条件:

  • 玩家站起来了,没有乘客了;
  • 附属的方块没了,被破坏了;

这两个判断都只在服务端做,客户端跟着服务端的删除包走

座位高度

1
2
3
4
@Override
public Vec3 getVehicleAttachmentPoint(Entity vehicle) {
return new Vec3(0.0, 0.0, 0.0);
}

返回零向量,意思是「乘客就坐在实体原点」

因为我们的实体本身已经被抬到坐垫高度了,这里不用再加

玩家朝向

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
protected void addPassenger(Entity passenger) {
super.addPassenger(passenger);
passenger.setYRot(this.getYRot());
}

@Override
protected void positionRider(Entity passenger, Entity.MoveFunction moveFunction) {
super.positionRider(passenger, moveFunction);
this.clampYaw(passenger);
}

private void clampYaw(Entity passenger) {
passenger.setYBodyRot(this.getYRot());
float wrappedYaw = Mth.wrapDegrees(passenger.getYRot() - this.getYRot());
float clampedYaw = Mth.clamp(wrappedYaw, -120.0F, 120.0F);
passenger.yRotO += clampedYaw - wrappedYaw;
passenger.setYRot(passenger.getYRot() + clampedYaw - wrappedYaw);
passenger.setYHeadRot(passenger.getYRot());
}

addPassenger是刚坐上来的那一刻,把玩家的朝向掰成沙发的朝向

positionRider每帧都在调,里面那句clampYaw是把玩家的脑袋限制在±120度,毕竟我们人的头不可能360度无死角得转吧

脱离位置

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public Vec3 getDismountLocationForPassenger(LivingEntity passenger) {
Direction original = this.getDirection();
Direction[] offsets = {original, original.getClockWise(), original.getCounterClockWise(), original.getOpposite()};
for (Direction dir : offsets) {
Vec3 safeVec = DismountHelper.findSafeDismountLocation(passenger.getType(), this.level(),
this.blockPosition().relative(dir), false);
if (safeVec != null) {
return safeVec.add(0, 0.25, 0);
}
}
return super.getDismountLocationForPassenger(passenger);
}

先试正前方,再试右边、左边、后面,找一个能站人的地方

找到就往上抬一点点,防止脚卡进地里

四个方向都不行就交给原版处理

DismountHelper在net.minecraft.world.entity.vehicle 包下,别导错

生成实体

1
2
3
4
5
6
7
8
9
10
11
12
public static InteractionResult create(Level level, BlockPos pos, double yOffset, Player player, Direction direction) {
if (!level.isClientSide()) {
List<SeatEntity> seats = level.getEntitiesOfClass(SeatEntity.class,
new AABB(pos.getX(), pos.getY(), pos.getZ(), pos.getX() + 1.0, pos.getY() + 1.0, pos.getZ() + 1.0));
if (seats.isEmpty()) {
SeatEntity seat = new SeatEntity(level, pos, yOffset, direction);
level.addFreshEntity(seat);
player.startRiding(seat, true, true);
}
}
return InteractionResult.SUCCESS;
}

先判断服务端,生成实体这种事只在服务端做

然后在那一个方块的范围内找找有没有现成的座椅实体,有就不重复生成

没有就新建一个、生成在世界里面、让玩家骑上去

AABB是包围盒,那个范围就是一个方块的大小

返回值是 InteractionResult.SUCCESS,一会儿方块那边要把它原样传出去

注册实体

新建 ModEntities

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ModEntities {
public static final EntityType<SeatEntity> SEAT = register("seat",
EntityType.Builder.<SeatEntity>of(SeatEntity::new, MobCategory.MISC).sized(0.0f, 0.0f));

private static <T extends Entity> EntityType<T> register(String name, EntityType.Builder<T> builder) {
ResourceKey<EntityType<?>> key = ResourceKey.create(Registries.ENTITY_TYPE,
Identifier.fromNamespaceAndPath(TutorialMod.MOD_ID, name));
return Registry.register(BuiltInRegistries.ENTITY_TYPE, key, builder.build(key));
}

public static void register() {
TutorialMod.LOGGER.info("Registering Mod Entities for " + TutorialMod.MOD_ID);
}
}

EntityType.Builder.of 第一个参数是构造工厂,就是个 (EntityType, Level) -> Entity,我们直接写 SeatEntity::new

MobCategory.MISC 是实体分类,座椅不属于生物,扔杂项里

sized(0.0f, 0.0f) 是碰撞箱的宽和高的,我们给 0

给 0 的原因是这个实体反正看不见、也不该参与碰撞,方块本身已经有碰撞箱了,实体再来一个会打架

注意 26.1 的 build 方法要传一个 ResourceKey,不像以前可以不传

最后在主类里调一下 ModEntities.register(),让这个类被加载到

渲染器

1
2
3
4
5
6
public class TutorialModClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
EntityRendererRegistry.register(ModEntities.SEAT, context -> new NoopRenderer<>(context));
}
}

EntityRendererRegistry.register 是Fabric提供的API,第一个参数是实体类型,第二个是渲染器的构造器

渲染器我们直接用原版的 NoopRenderer

这个类就是「什么都不画」的意思,名字里的 Noop 就是 no operation

1.21.1 那会儿还得自己写个 EntityRenderer 子类,getTextureLocation 返回 null

现在没必要了,原版这个正好是空实现,拿来就用

客户端入口

这里有个坑,也是我们这个工程之前一直没注意的地方

fabric.mod.json 里的入口点,之前只写了 main 和 fabric-datagen

1
2
3
4
5
6
7
8
{
"entrypoints": {
"main": ["com.besson.tutorial.TutorialMod"],
"client": ["com.besson.tutorial.TutorialModClient"],
"fabric-datagen": ["com.besson.tutorial.TutorialModDataGenerator"]
},
...
}

client 这一项不加,onInitializeClient 根本不会被调用,渲染器没注册

前面几期我们没写过客户端的东西,所以一直没发现这个问题

方块那边接上

回到 SofaBlock,重写右键方法

1
2
3
4
5
6
7
@Override
protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hitResult) {
if (!level.isClientSide()) {
return SeatEntity.create(level, pos, 0.4, player, state.getValue(FACING));
}
return InteractionResult.SUCCESS;
}

这里我们重写了 useWithoutItem,这个方法是玩家右键方块时调用的

其实虽然它的名字是 useWithoutItem,但它也会在玩家手里拿着物品时被调用,只要那个物品没有 useOnBlock 的逻辑

测试

最后我们就可以进入游戏进行测试了

常见问题(大肥鱼总结版)

  • 右键没反应:useWithoutItem 里忘了判断服务端,或者 SeatEntity.create 没被调用
  • 坐上去但人是站着的姿势:getVehicleAttachmentPoint 写错了,或者没让玩家真的骑上去
  • 坐上去能 360 度转头:clampYaw 没写,或者 positionRider 忘了调它
  • 站起来卡在沙发里:getDismountLocationForPassenger 没写
  • 座位的实体会一直留着:tick 里的清理逻辑没写,或者只在客户端判断了
  • 崩溃,报 Entity entity has no renderer:fabric.mod.json 少了 client 入口,或者渲染器没注册
  • 崩溃,报抽象方法没实现:Entity 现在要求实现 hurtServer,1.21 的代码里没有这个
  • 编译报 startRiding(Entity, boolean) 找不到:26.1 是三个参数
  • 实体和沙发重叠、椅子自己会动:noPhysics 忘了设
  • 打不死这个实体,但也点不中它:正常,零碰撞箱加上隐形渲染,本来就点不到