本篇教程的视频
本篇教程的源代码
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 忘了设
- 打不死这个实体,但也点不中它:正常,零碰撞箱加上隐形渲染,本来就点不到