原创教程 · 环境:Minecraft 1.20.1 / Forge 47.x · 示例 modid
mymod前置:18. 方块注册(BB 模型 + 箱子);方法签名经 Forge 1.20.1-47.3.0 jar 实证
21. 方块进阶:交互、状态与动画¶
目录¶
- 方块进阶:让方块「活」起来
- 交互与生命周期:use / onPlace / onRemove / playerWillDestroy
- 方块状态与属性:一个有开关的方块
- 形状与碰撞:非整方块的形状怎么写
- 随机刻、计划刻与掉落
- 含水方块:花盆为什么能装水
- 方块实体进阶:EntityBlock、ticker 与数据同步
- 方块动画(一):贴图逐帧与 animateTick
- 方块动画(二):用 BlockEntityRenderer 驱动模型部件
- 方块进阶:性能与踩坑清单
方块进阶:让方块「活」起来¶
在 18. 方块注册 中,我们已经学会了如何注册一个基本的方块,使其能够被放置、挖掘和存储物品。但是,一个真正「活」起来的方块需要更多的功能,如交互、状态变化、动画效果等。在本章中,我们将一步步地学习如何让方块「活」起来。
本章的内容包括:
1. 交互与生命周期:覆写 use、onPlace、onRemove 等方法,使方块能够响应玩家交互和生命周期变化。
2. 状态属性:定义和使用状态属性,使方块能够具有不同的状态。
3. 形状碰撞:覆写 getShape、getCollisionShape 等方法,使方块能够具有不同的形状和碰撞箱。
4. 随机刻与掉落:覆写 tick、randomTick 等方法,使方块能够具有随机的行为。
5. 含水:实现 SimpleWaterloggedBlock 接口,使方块能够含水。
6. 方块实体进阶:使用方块实体来存储和管理方块的状态和行为。
7. animateTick:覆写 animateTick 方法,使方块能够具有客户端动画效果。
8. BER 动画:使用 BlockEntityRenderer 来创建复杂的动画效果。
下面是需求与覆写方法的对照表:
| 需求 | 覆写方法 |
|---|---|
| 右键交互 | use |
| 放置后 | onPlace |
| 移除 | onRemove |
| 玩家破坏前 | playerWillDestroy |
| 邻居变化 | neighborChanged |
| 计划刻 | tick |
| 随机刻 | randomTick |
| 客户端动画 | animateTick |
| 状态属性 | createBlockStateDefinition |
| 形状碰撞 | getShape、getCollisionShape |
| 含水 | SimpleWaterloggedBlock 接口 |
| 方块实体 | newBlockEntity、getTicker |
通过学习这些内容,我们将能够创建出更加复杂和有趣的方块,丰富 Minecraft 的游戏世界。接下来,我们将开始学习这些内容的具体实现。
交互与生命周期:use / onPlace / onRemove / playerWillDestroy¶
当玩家与方块交互时,会触发一系列的回调函数。这些函数可以帮助你控制方块的行为和状态。
首先是 use 函数,它会在玩家右键点击方块时被调用。这个函数返回一个 InteractionResult 对象,表示交互的结果。InteractionResult 有四种可能的值:SUCCESS、CONSUME、FAIL 和 PASS。其中,SUCCESS 表示交互成功,CONSUME 表示交互成功并且消耗了物品,FAIL 表示交互失败,PASS 表示交互被忽略。
接下来是 onPlace 函数,它会在方块被放置时被调用。这个函数可以用来初始化方块的状态或执行其他放置后的操作。
当方块被移除时,会调用 onRemove 函数。这个函数可以用来清理方块的状态或执行其他移除后的操作。注意,onRemove 函数会被调用两次:一次是当方块被破坏时,另一次是当方块被替换时。因此,你需要检查新旧状态是否相等,以避免执行多余的操作。
playerWillDestroy 函数会在玩家破坏方块之前被调用。这个函数可以用来掉落额外的物品或取消破坏操作。
最后是 neighborChanged 函数,它会在方块的邻居变化时被调用。这个函数可以用来判断方块的状态是否需要更新,例如,当方块受到红石信号时。
下面是一个完整的代码示例,实现了一个「点击一次计数的方块」:
public class CounterBlock extends Block {
public static final BooleanProperty CLICKED = BooleanProperty.create("clicked");
public CounterBlock(Properties properties) {
super(properties);
this.registerDefaultState(this.stateDefinition.any().setValue(CLICKED, false));
}
@Override
public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand, BlockHitResult hit) {
if (!level.isClientSide) {
level.setBlock(pos, state.cycle(CLICKED), 3);
level.sendBlockUpdated(pos, state, state.cycle(CLICKED), Block.UPDATE_ALL);
}
return InteractionResult.SUCCESS;
}
@Override
public void onPlace(BlockState state, Level level, BlockPos pos, BlockState oldState, boolean isMoving) {
if (!level.isClientSide) {
level.setBlock(pos, state.setValue(CLICKED, false), 3);
}
}
@Override
public void onRemove(BlockState state, Level level, BlockPos pos, BlockState newState, boolean isMoving) {
if (state.getValue(CLICKED) != newState.getValue(CLICKED)) {
// 清理状态
}
}
@Override
public void playerWillDestroy(Level level, BlockPos pos, BlockState state, Player player) {
// 掉落额外物品
level.addFreshEntity(new ItemEntity(level, pos.getX(), pos.getY(), pos.getZ(), new ItemStack(Items.DIAMOND)));
}
@Override
public void neighborChanged(BlockState state, Level level, BlockPos pos, Block block, BlockPos fromPos, boolean isMoving) {
// 判断方块状态是否需要更新
if (level.getBlockState(fromPos).getBlock() instanceof RedStoneBlock) {
// 更新方块状态
}
}
}
@Mod.EventBusSubscriber(value = Dist.CLIENT, bus = MOD)
public class ClientEventHandler {
@SubscribeEvent
public void onPlaySound(PlaySoundEvent event) {
if (event.getSound() == SoundEvents.BLOCK_STONE_PLACE) {
// 播放音效
Minecraft.getInstance().getSoundManager().play(SoundEventRegistry.getSoundEvent(Registry.SOUND_EVENTS, "my_mod.click"));
}
}
}
方块状态与属性:一个有开关的方块¶
你会看到很多方块都有多种状态,比如门的开合、红石的强弱等。这些状态都是通过方块的属性来实现的。我们可以通过 BooleanProperty、IntegerProperty 和 EnumProperty 来创建这些属性。
首先,我们需要在方块的构造器中定义这些属性。例如,我们可以创建一个有开关的方块:
public class MyBlock extends Block {
public static final BooleanProperty OPEN = BooleanProperty.create("open");
public MyBlock(Properties properties) {
super(properties);
this.registerDefaultState(this.defaultBlockState().setValue(OPEN, false));
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(OPEN);
}
}
BooleanProperty 名为 OPEN,并在方块的构造器中将其添加到方块的状态定义中。我们还设置了方块的默认状态为关闭(OPEN 为 false)。
接下来,我们需要在方块的 getStateForPlacement 方法中根据玩家的朝向来确定方块的初始状态:
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
return this.defaultBlockState().setValue(OPEN, false);
}
现在,我们可以通过 state.getValue 和 state.setValue 来获取和设置方块的状态。例如,我们可以在方块的 use 方法中切换方块的开合状态:
@Override
public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand, BlockHitResult hit) {
level.setBlock(pos, state.cycle(OPEN), 3);
return InteractionResult.SUCCESS;
}
state.cycle 来切换方块的开合状态,并使用 level.setBlock 来更新方块的状态。level.setBlock 的第三个参数是更新标志,3 表示更新方块的状态并发送更新到客户端。
最后,我们需要在 blockstates JSON 文件中定义方块的模型映射。我们可以使用属性来确定方块的模型。例如:
{
"variants": {
"open=true": {
"model": "mymod:block/my_block_open"
},
"open=false": {
"model": "mymod:block/my_block_closed"
}
}
}
open=true 和 open=false 来确定方块的模型。
注意,我们需要在 blockstates JSON 文件中添加 .noOcclusion() 来防止模型之间的遮挡。例如:
{
"variants": {
"open=true": {
"model": "mymod:block/my_block_open",
"noOcclusion": true
},
"open=false": {
"model": "mymod:block/my_block_closed",
"noOcclusion": true
}
}
}
形状与碰撞:非整方块的形状怎么写¶
非整方块的形状可以通过 Block.box(x1, y1, z1, x2, y2, z2) 来定义,这个方法接受六个参数,分别代表了形状在 x、y、z 轴上的最小和最大坐标值,单位是 1/16 格,范围是 0 到 16。例如,Block.box(0, 0, 0, 16, 16, 16) 就代表了一个完整的方块。
当你需要组合多个形状时,可以使用 Shapes.join 或 Shapes.or 方法。Shapes.join 方法会将两个形状合并成一个新的形状,而 Shapes.or 方法会将两个形状合并成一个新的形状,并且保留两个形状的所有部分。例如:
VoxelShape shape = Shapes.join(
Block.box(0, 0, 0, 16, 8, 16), // 下半部分
Block.box(0, 8, 0, 16, 16, 16) // 上半部分
);
getShape、getCollisionShape 和 getInteractionShape 三个方法,它们分别代表了视觉形状、碰撞形状和交互形状。这些形状可以不同,例如地毯的碰撞形状可能比视觉形状低一些,而陷阱的交互形状可能比视觉形状大一些。
对于静态形状,可以将其缓存在静态字段中,以避免每次调用 getShape 方法时都创建一个新的形状。例如:
Map 来缓存不同的形状。例如:
private final Map<BlockState, VoxelShape> shapes = new HashMap<>();
@Override
public VoxelShape getShape(BlockState state, BlockGetter getter, BlockPos pos, CollisionContext context) {
return shapes.computeIfAbsent(state, this::calculateShape);
}
private VoxelShape calculateShape(BlockState state) {
// 根据状态计算形状
if (state.getValue(OPEN)) {
return Block.box(0, 0, 0, 16, 8, 16);
} else {
return Block.box(0, 0, 0, 16, 16, 16);
}
}
public class MyBlock extends Block {
public static final BooleanProperty OPEN = BooleanProperty.create("open");
private static final VoxelShape SHAPE_CLOSED = Block.box(0, 0, 0, 16, 4, 16);
private static final VoxelShape SHAPE_OPEN = Block.box(0, 0, 0, 16, 8, 16);
public MyBlock(Properties properties) {
super(properties);
this.registerDefaultState(this.defaultBlockState().setValue(OPEN, false));
}
@Override
public VoxelShape getShape(BlockState state, BlockGetter getter, BlockPos pos, CollisionContext context) {
if (state.getValue(OPEN)) {
return SHAPE_OPEN;
} else {
return SHAPE_CLOSED;
}
}
@Override
public VoxelShape getCollisionShape(BlockState state, BlockGetter getter, BlockPos pos, CollisionContext context) {
return SHAPE_CLOSED; // 碰撞形状始终为关闭状态
}
@Override
public VoxelShape getInteractionShape(BlockState state, BlockGetter getter, BlockPos pos) {
return SHAPE_OPEN; // 交互形状为打开状态
}
}
getShape 方法根据 OPEN 属性的值来返回不同的形状,getCollisionShape 方法始终返回关闭状态的形状,而 getInteractionShape 方法返回打开状态的形状。
随机刻、计划刻与掉落¶
你会看到,Forge 提供了多种方式让你的方块「活」起来。我们先来看随机刻和计划刻。
随机刻是通过 randomTick 方法实现的,这个方法会在方块被随机选中时调用。要使用随机刻,你需要在方块的 Properties 中添加 .randomTicks(),并覆写 isRandomlyTicking 方法返回 true。例如:
public class MyBlock extends Block {
public MyBlock(Properties properties) {
super(properties.randomTicks());
}
@Override
public boolean isRandomlyTicking(BlockState state) {
return true;
}
@Override
public void randomTick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
// 做一些随机的事情
}
}
tick 方法实现的,这个方法会在方块被计划好时调用。要使用计划刻,你需要使用 level.scheduleTick 方法来计划一个刻。例如:
public class MyBlock extends Block {
@Override
public void onPlace(BlockState state, Level level, BlockPos pos, BlockState oldState, boolean isMoving) {
level.scheduleTick(pos, this, 10); // 10 个 tick 后调用 tick 方法
}
@Override
public void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
// 做一些计划好的事情
}
}
getDrops 方法实现的,但是我们不推荐直接覆写这个方法。相反,我们推荐使用掉落表(loot table)来定义掉落。掉落表是一个 JSON 文件,定义了方块被破坏时应该掉落什么物品。
标准的做法是创建一个 loot_tables 文件夹,在里面创建一个 JSON 文件,定义掉落规则。例如:
my_item 物品。
如果你需要特殊的掉落逻辑,你可以在 playerWillDestroy 或 onRemove 方法中使用 Block.popResource 方法来掉落物品。例如:
public class MyBlock extends Block {
@Override
public void playerWillDestroy(Level level, BlockPos pos, BlockState state, Player player) {
Block.popResource(level, pos, new ItemStack(MyMod.MY_ITEM));
}
}
getDrops 方法的理由是,掉落表交给数据生成/JSON,可以更容易地管理和修改掉落规则。
下面是一个例子,实现一个会随时间扩散、挖掉掉经验的方块:
public class MyBlock extends Block {
public MyBlock(Properties properties) {
super(properties.randomTicks());
}
@Override
public boolean isRandomlyTicking(BlockState state) {
return true;
}
@Override
public void randomTick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
// 扩散逻辑
if (random.nextInt(10) == 0) {
level.setBlock(pos.north(), this.defaultBlockState());
}
}
@Override
public void playerWillDestroy(Level level, BlockPos pos, BlockState state, Player player) {
Block.popExperience(level, pos, 10); // 掉 10 点经验
}
}
含水方块:花盆为什么能装水¶
含水方块是 Minecraft 中一种特殊的方块,它可以容纳水或其他液体。要创建一个含水方块,我们需要使用 SimpleWaterloggedBlock 类,并实现 BucketPickup 和 LiquidBlockContainer 接口。
首先,我们需要在方块中添加 WATERLOGGED 属性,这个属性用于表示方块是否含有水。我们可以通过 BooleanProperty.create("waterlogged") 来创建这个属性。
public class MyWaterBlock extends SimpleWaterloggedBlock {
public MyWaterBlock(Properties properties) {
super(properties);
}
}
在 getStateForPlacement 方法中,我们需要判断放置方块的位置是否有水体,如果有,则设置方块的 WATERLOGGED 属性为 true。
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
BlockState state = super.getStateForPlacement(context);
FluidState fluidState = context.getLevel().getFluidState(context.getClickedPos());
return state.setValue(WATERLOGGED, fluidState.getType() == Fluids.WATER);
}
在 getFluidState 方法中,我们需要返回方块当前的液体状态,如果方块含有水,则返回 Fluids.WATER.getSource(false)。
@Override
public FluidState getFluidState(BlockState state) {
return state.getValue(WATERLOGGED) ? Fluids.WATER.getSource(false) : Fluids.EMPTY.getDefaultState();
}
在 updateShape 方法中,我们需要更新方块的形状,当方块邻近的水体变化时,需要更新方块的 WATERLOGGED 属性。
@Override
public void updateShape(BlockState state, Level level, BlockPos pos, Block block) {
super.updateShape(state, level, pos, block);
level.scheduleTick(pos, this, 1);
}
为了让方块可以与水桶交互,我们需要实现 canPlaceLiquid 和 pickupBlock 方法。
@Override
public boolean canPlaceLiquid(BlockState state, Level level, BlockPos pos, Fluid fluid) {
return !state.getValue(WATERLOGGED) && fluid == Fluids.WATER;
}
@Override
public ItemStack pickupBlock(Level level, BlockPos pos, BlockState state) {
return new ItemStack(this);
}
最后,我们需要在 FMLClientSetupEvent 的 enqueueWork 方法中设置方块的渲染层为 cutout,并且需要调用 .noOcclusion() 方法来防止方块的渲染出现问题。
@Mod.EventBusSubscriber(value = Dist.CLIENT, bus = Mod.EventBusSubscriber.Bus.MOD)
public class ClientSetup {
@SubscribeEvent
public static void onClientSetup(FMLClientSetupEvent event) {
event.enqueueWork(() -> {
ItemBlockRenderTypes.setRenderLayer(MyWaterBlock.getInstance(), RenderType.cutout());
});
}
}
以下是完整的自定义含水方块代码:
public class MyWaterBlock extends SimpleWaterloggedBlock {
public static final BooleanProperty WATERLOGGED = BooleanProperty.create("waterlogged");
public MyWaterBlock(Properties properties) {
super(properties);
this.registerDefaultState(this.stateDefinition.any().setValue(WATERLOGGED, false));
}
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
BlockState state = super.getStateForPlacement(context);
FluidState fluidState = context.getLevel().getFluidState(context.getClickedPos());
return state.setValue(WATERLOGGED, fluidState.getType() == Fluids.WATER);
}
@Override
public FluidState getFluidState(BlockState state) {
return state.getValue(WATERLOGGED) ? Fluids.WATER.getSource(false) : Fluids.EMPTY.getDefaultState();
}
@Override
public void updateShape(BlockState state, Level level, BlockPos pos, Block block) {
super.updateShape(state, level, pos, block);
level.scheduleTick(pos, this, 1);
}
@Override
public boolean canPlaceLiquid(BlockState state, Level level, BlockPos pos, Fluid fluid) {
return !state.getValue(WATERLOGGED) && fluid == Fluids.WATER;
}
@Override
public ItemStack pickupBlock(Level level, BlockPos pos, BlockState state) {
return new ItemStack(this);
}
}
Warning
注意:含水方块的渲染需要特殊处理,否则可能会出现渲染问题。
方块实体进阶:EntityBlock、ticker 与数据同步¶
方块实体(BlockEntity)是 Minecraft 中一种特殊的实体,它与特定的方块绑定,用于存储和管理方块的状态和行为。要创建一个方块实体,你需要让你的方块类实现 EntityBlock 接口,这个接口定义了两个重要的方法:newBlockEntity 和 getTicker。
newBlockEntity 方法用于创建一个新的方块实体实例,它会在方块被放置或加载时被调用。getTicker 方法则用于获取一个 BlockEntityTicker 实例,这个实例负责更新方块实体的状态。
BlockEntityTicker 是一个函数式接口,它只有一个抽象方法,根据 level.isClientSide 的值,你可以分别为客户端和服务端提供不同的更新逻辑。客户端的 ticker 通常负责更新方块实体的表现,例如动画、粒子效果等,而服务端的 ticker 则负责更新方块实体的逻辑状态,例如计时、计数等。
public class MyBlock extends Block implements EntityBlock {
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new MyBlockEntity(pos, state);
}
@Override
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> type) {
return level.isClientSide() ? MyBlockEntity::clientTick : MyBlockEntity::serverTick;
}
}
方块实体的存盘和加载是通过 saveAdditional 和 load 方法实现的。saveAdditional 方法用于存储方块实体的额外数据,load 方法则用于加载这些数据。当方块实体的数据发生变化时,需要调用 setChanged 方法来标记方块实体为脏数据,这样 Minecraft 才会在下一次存盘时保存这些变化。
public class MyBlockEntity extends BlockEntity {
private int energy;
public MyBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.MY_BLOCK_ENTITY.get(), pos, state);
}
@Override
protected void saveAdditional(CompoundTag tag) {
tag.putInt("energy", energy);
}
@Override
public void load(CompoundTag tag) {
energy = tag.getInt("energy");
}
public void addEnergy(int amount) {
energy += amount;
setChanged();
}
}
为了让客户端能够看到方块实体的变化,需要使用同步机制。Minecraft 提供了三种同步方式:getUpdateTag、getUpdatePacket 和 level.sendBlockUpdated。getUpdateTag 方法用于获取方块实体的更新标签,getUpdatePacket 方法用于获取方块实体的更新数据包,level.sendBlockUpdated 方法则用于发送更新数据包到客户端。
@Override
public CompoundTag getUpdateTag() {
return saveWithoutMetadata();
}
@Override
public ClientboundBlockEntityDataPacket getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
public void syncToClient() {
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), Block.UPDATE_ALL);
}
最后,需要注册方块实体类型。可以使用 BlockEntityType.Builder 来创建一个新的方块实体类型,然后注册它。
public static final RegistryObject<BlockEntityType<MyBlockEntity>> MY_BLOCK_ENTITY =
Registry.register(Registry.BLOCK_ENTITY_TYPE, "my_block_entity",
BlockEntityType.Builder.of(MyBlockEntity::new, ModBlocks.MY_BLOCK.get()).build(null));
以下是一个完整的例子,展示了一个会累积充能并同步到客户端的方块实体:
public class MyBlockEntity extends BlockEntity {
private int energy;
public MyBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.MY_BLOCK_ENTITY.get(), pos, state);
}
@Override
protected void saveAdditional(CompoundTag tag) {
tag.putInt("energy", energy);
}
@Override
public void load(CompoundTag tag) {
energy = tag.getInt("energy");
}
public void addEnergy(int amount) {
energy += amount;
setChanged();
syncToClient();
}
public void syncToClient() {
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), Block.UPDATE_ALL);
}
@Override
public CompoundTag getUpdateTag() {
return saveWithoutMetadata();
}
@Override
public ClientboundBlockEntityDataPacket getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
public static void clientTick(MyBlockEntity be, Level level, BlockPos pos, BlockState state) {
// 客户端更新逻辑
}
public static void serverTick(MyBlockEntity be, Level level, BlockPos pos, BlockState state) {
// 服务端更新逻辑
be.addEnergy(1);
}
}
Warning
注意,客户端和服务端的更新逻辑应该分开,以避免不必要的同步和更新。同时,应该使用 setChanged 方法来标记方块实体为脏数据,以便 Minecraft 在下一次存盘时保存这些变化。
方块动画(一):贴图逐帧与 animateTick¶
想要方块"动"起来,最省事的两条路都不需要写渲染器:贴图自己逐帧翻页,或者每 tick 撒点粒子。先把这两招吃透,再上第九章的自定义渲染器。
路线一:贴图逐帧(零代码,最稳)¶
把整套动画帧竖着拼进一张 PNG(或者横排但用 frames 指定顺序),然后放一个同名的 .mcmeta 文件:
// assets/mymod/textures/block/rune_block.png.mcmeta
{
"animation": {
"frametime": 2,
"frames": [0, 1, 2, 3, 2, 1]
}
}
frametime:每帧停留多少 tick,2就是 0.1 秒一帧;frames里写反复/倒放顺序(不写就按从上到下的顺序播)- 文件名必须严格同名:
rune_block.png配rune_block.png.mcmeta,目录也必须同目录 - 16×16 的贴图,4 帧就拼成 16×64;配合
blockstates/models/block/*.json正常引用贴图即可 - 物品贴图同样适用:
textures/item/xxx.png.mcmeta
Tip
逐帧动画是纯客户端表现,不影响任何逻辑,也不吃服务端性能。想要"发光/闪烁/流水/裂缝蔓延"这类效果,优先考虑它。
路线二:animateTick(客户端每 tick)¶
animateTick 只在客户端调用,专门用来撒粒子、放本地音效。它不能改世界状态(改了会和服务端对不上):
public class RuneBlock extends Block {
public RuneBlock(Properties properties) {
super(properties);
}
@Override
public void animateTick(BlockState state, Level level, BlockPos pos, RandomSource random) {
// 每 6 次 tick 冒一次魔法粒子(约每秒 3~4 次)
if (random.nextInt(6) == 0) {
double x = pos.getX() + 0.5 + (random.nextDouble() - 0.5) * 0.7;
double y = pos.getY() + 1.0;
double z = pos.getZ() + 0.5 + (random.nextDouble() - 0.5) * 0.7;
level.addParticle(ParticleTypes.ENCHANT, x, y, z, 0.0D, 0.02D, 0.0D);
}
// 偶尔响一声(本地音效,只有附近玩家听得到)
if (random.nextInt(80) == 0) {
level.playLocalSound(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5,
SoundEvents.AMETHYST_BLOCK_CHIME, SoundSource.BLOCKS, 0.5F,
0.8F + random.nextFloat() * 0.4F, false);
}
}
}
关键点:
| 要点 | 说明 |
|---|---|
| 随机性 | 用 random.nextInt(n) == 0 控制频率,n 越大越稀疏;别每 tick 都撒 |
| 粒子位置 | pos.getX() + 0.5 是方块中心;要范围随机就加 (random.nextDouble() - 0.5) * 范围 |
| 音效 | playLocalSound 不发给全服,适合"靠近才听得见"的效果 |
| 不能做什么 | 不要在这里 level.setBlock(...)、不要改 BlockEntity 数据(客户端侧改了会被服务端覆盖) |
怎么验证¶
- 进游戏放下方块:贴图应该在按
frametime翻页,粒子按你设的频率冒出来 - 让另一个玩家站在远处看:粒子和音效应该只有附近的人能看到/听到(这是
animateTick+playLocalSound的正常表现) - 把
frametime改成10重进:动画明显变慢,说明 mcmeta 生效了
Warning
常见翻车:
- .mcmeta 名字写错或放在 textures/item/ 却引用 block/ 的贴图 → 动画完全不生效(先查文件名和目录)
- 动画贴图忘了把帧竖着拼(只画了第一帧) → 看起来"没动"
- 在 animateTick 里每 tick 无条件撒 10 个粒子 → 帧数暴跌,玩家骂街
方块动画(二):用 BlockEntityRenderer 驱动模型部件¶
贴图逐帧只能"换皮",animateTick 只能撒粒子。真正的"模型本体在动"——旋转的风扇、漂浮的宝箱、开合的门——要靠 BlockEntityRenderer(BER) 在每一帧改变模型部件的角度/位置。
整体流程¶
定义模型层 (LayerDefinition + ModelLayerLocation)
↓ 注册:EntityRenderersEvent.RegisterLayerDefinitions
↓ 注册:EntityRenderersEvent.RegisterRenderers
实现 BER:BlockEntityRenderer<T>.render(...)
↓ 方块必须:getRenderShape() → RenderShape.ENTITYBLOCK_ANIMATED
渲染时用 gameTime + partialTick 算角度 → pose.rotate → modelPart.render
第 1 步:定义并注册模型层¶
模型(LayerDefinition)只创建一次,注册在 mod 事件总线上,之后每次渲染从缓存里 bakeLayer 取:
public class FanModel {
public static LayerDefinition createBodyLayer() {
MeshDefinition mesh = new MeshDefinition();
PartDefinition root = mesh.getRoot();
// 一块 12×2×12 的"扇叶",位于模型中心
root.addOrReplaceChild("blades", CubeListBuilder.create()
.texOffs(0, 0).addBox(-6.0F, -1.0F, -6.0F, 12, 2, 12),
PartPose.ZERO);
return LayerDefinition.create(mesh, 64, 64);
}
}
@Mod.EventBusSubscriber(modid = MyMod.MODID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
public class ClientSetup {
public static final ModelLayerLocation FAN_LAYER =
new ModelLayerLocation(new ResourceLocation(MyMod.MODID, "fan"), "main");
@SubscribeEvent
public static void onRegisterLayers(EntityRenderersEvent.RegisterLayerDefinitions event) {
event.registerLayerDefinition(FAN_LAYER, FanModel::createBodyLayer);
}
@SubscribeEvent
public static void onRegisterRenderers(EntityRenderersEvent.RegisterRenderers event) {
event.registerBlockEntityRenderer(ModBlockEntities.FAN_BE.get(), FanRenderer::new);
}
}
想省事的话,也可以在 Blockbench 里把模型建成"实体模型"(Entity)导出,然后用
LayerDefinition.create一样的流程;方块模型(Block)导出的是烘焙模型 JSON,不能直接拿来逐帧驱动。
第 2 步:方块必须声明"交给渲染器"¶
只要方块存在 BlockEntity 且外观靠 BER 画,就要覆写 getRenderShape,否则原版烘焙模型和你的 BER 会打架(穿帮、双层、闪面):
@Override
public RenderShape getRenderShape(BlockState state) {
return RenderShape.ENTITYBLOCK_ANIMATED;
}
第 3 步:写渲染器¶
public class FanRenderer implements BlockEntityRenderer<FanBlockEntity> {
private static final ResourceLocation TEXTURE =
new ResourceLocation(MyMod.MODID, "textures/block/fan.png");
private final ModelPart blades;
public FanRenderer(BlockEntityRendererProvider.Context context) {
this.blades = context.bakeLayer(ClientSetup.FAN_LAYER); // 取缓存好的模型
}
@Override
public void render(FanBlockEntity be, float partialTick, PoseStack pose, MultiBufferSource buffer,
int packedLight, int packedOverlay) {
// 动画时间 = 世界时间 + 帧间插值(这样转动是平滑的,不会卡在整 tick)
float time = (be.getLevel() == null ? 0.0F : be.getLevel().getGameTime()) + partialTick;
pose.pushPose();
pose.translate(0.5D, 0.5D, 0.5D); // 挪到方块中心
pose.mulPose(Axis.YP.rotationDegrees(time * 6.0F)); // 每秒转 120°
blades.render(pose, buffer.getBuffer(RenderType.entityCutout(TEXTURE)),
packedLight, packedOverlay);
pose.popPose();
}
@Override
public boolean shouldRenderOffScreen(FanBlockEntity be) {
return false;
}
@Override
public int getViewDistance() {
return 64; // 超过这个距离就不渲染,省性能
}
}
常用变换速查¶
| 想要的效果 | 写法 |
|---|---|
| 绕竖轴转(风扇) | pose.mulPose(Axis.YP.rotationDegrees(time * 6.0F)) |
| 绕横轴转(齿轮/轴承) | Axis.XP / Axis.ZP,或 Axis.of(new Vector3f(1, 1, 0)) 复合轴 |
| 上下漂浮 | pose.translate(0, Math.sin(time * 0.1F) * 0.05F, 0) |
| 呼吸缩放 | pose.scale(1.0F + 0.05F * Mth.sin(time * 0.2F), 1.0F, 1.0F) |
| 偏移到方块某角 | pose.translate(0.25D, 0.75D, 0.25D)(单位是格,不是像素) |
怎么验证¶
- 放下方块,扇叶应该连续平滑旋转——不是一顿一顿的(顿挫说明你漏了
+ partialTick) - 走远到 64 格外,
F3看帧率应该回升(getViewDistance生效) - 拿另一个方块紧贴着放,观察是否出现 Z 轴闪烁/半透明穿帮——有的话先检查
getRenderShape和RenderType选得对不对
Warning
- 别在
render里bakeLayer或new对象:bakeLayer每帧调用会直接拖垮帧率,模型必须在构造器里取好 - 别在
render里读写世界数据:渲染线程之外的数据改动要靠getUpdateTag/getUpdatePacket同步(见第七章) - 用自己写的纹理渲染时
RenderType.entityCutout(tex)会自动处理彩色与光照;玻璃/半透明材质要换RenderType.translucent() - 如果只是想要"模型不动但贴图动",回到第八章用
.mcmeta,别上 BER
Tip
更进一步的"由 JSON 参数驱动的模型动画"(例如让模型 JSON 里写转速,由自定义 loader 解析)走 ModelEvent.RegisterGeometryLoaders + IGeometryLoader/IUnbakedGeometry 这套路,属于进阶内容,先把手写 BER 这一套吃透再看。
方块进阶:性能与踩坑清单¶
这一章把前面几章最容易翻车的地方收成一张表。发版前逐条过一遍,能省掉大半"玩家来报 bug"的时间。
一、渲染类¶
| 症状 | 原因 | 怎么修 |
|---|---|---|
| 自己写的 BER 完全不显示 / 出现双层方块 | 方块没声明交给渲染器 | getRenderShape 返回 RenderShape.ENTITYBLOCK_ANIMATED |
| 动画一顿一顿 | 拿 getGameTime() 当角度,没做帧间插值 |
时间用 gameTime + partialTick |
| 远处方块也在狂掉帧 | 没有视距限制 | BER 覆写 getViewDistance(),大模型按需调小 |
| 放好几个同款方块,帧率直接腰斩 | 每个 BER 每帧都 bakeLayer / new 对象 |
模型在构造器里 context.bakeLayer(...) 存字段,渲染里只做 pose 变换 |
| 贴图透明边缘发黑 / 玻璃不对 | 渲染类型选错 | 树叶花草用 RenderType.cutout(),玻璃水用 RenderType.translucent(),见下 |
| 方块的洞能看穿到隔壁方块 | 没关遮挡 | Properties 里加 .noOcclusion() |
| 从远处看方块闪烁(Z-fighting) | 烘焙模型和 BER 重叠 | 确认只留一条渲染路径(要么 MODEL,要么 ENTITYBLOCK_ANIMATED) |
渲染层设置要放在客户端初始化里(FMLClientSetupEvent 的 enqueueWork,或 Dist.CLIENT 的 mod 总线订阅):
@Mod.EventBusSubscriber(modid = MyMod.MODID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
public class ClientSetup {
@SubscribeEvent
public static void onClientSetup(FMLClientSetupEvent event) {
event.enqueueWork(() -> {
ItemBlockRenderTypes.setRenderLayer(ModBlocks.RUNE_BLOCK.get(), RenderType.cutout());
ItemBlockRenderTypes.setRenderLayer(ModBlocks.FAN_BLOCK.get(), RenderType.translucent());
});
}
}
二、逻辑类¶
| 症状 | 原因 | 怎么修 |
|---|---|---|
| 重启服务器后方块实体的数据全没了 | 忘了写盘 / 没标记脏 | 覆写 saveAdditional(CompoundTag) + load(CompoundTag),改动后立刻 setChanged() |
| 服务端数据变了,客户端界面/外观不更新 | 没同步 | getUpdateTag() / getUpdatePacket(),改完调 level.sendBlockUpdated(pos, state, state, Block.UPDATE_ALL) |
| 客户端 ticker 把世界改乱了 / 实体双方各跑一套 | 没分侧 | getTicker 里按 level.isClientSide 返回对应的 ticker;客户端只做表现 |
| 作物/扩散方块永远不长大 | 忘了随机刻 | Properties 加 .randomTicks() 且 isRandomlyTicking(state) 返回 true |
| 点击一次触发了两次(或一次都不触发) | 返回值用错 | 想"消耗这次交互"用 InteractionResult.CONSUME,没处理用 PASS,成功用 SUCCESS |
| 挖掉方块后东西没了 / 掉两份 | 掉落逻辑重复 | 掉落交给掉落表;只有在需要额外掉落时才在 playerWillDestroy 或 onRemove 里补 Block.popResource(...),并注意 newState 判断 |
三、注册与资源类¶
| 症状 | 原因 | 怎么修 |
|---|---|---|
| 启动就崩,报找不到方块实体类型 | 方块比 BlockEntityType 先用到 |
BlockEntityType.Builder.of(MyBE::new, ModBlocks.MY_BLOCK.get()),注册顺序保持"方块 → 方块实体" |
手里拿不到方块 / 名字是 block.mymod.x |
没注册 BlockItem 或语言键缺失 |
注册同名 BlockItem,补 block.mymod.x / item.mymod.x 语言键 |
| 服务端能起、客户端崩(或反过来) | 客户端类被 common 代码加载 | 渲染/按键相关代码单独放 client 包,用 Dist.CLIENT 隔离 |
| 贴图/模型紫黑方块 | 资源路径或模型 JSON 层级错 | 对照 assets/<modid>/blockstates|models|textures 三处路径逐字检查 |
| 动画贴图不播 | .mcmeta 缺了或不同名 |
文件名、目录必须和贴图完全一致 |
Warning
最贵的两个坑:每帧 bakeLayer/新建对象(帧率杀手)和 getRenderShape 没设(渲染错乱)。这两条每次写 BER 都要自查。
Tip
写完一个新方块/新物品,建议固定跑一遍流程:放出来 → 挖掉 → 存档退出重进 → 客户端看外观 → 和另一个玩家一起看同步 → 远处看帧率。这一圈走完,90% 的问题当场暴露。
相关章节:18. 方块注册(BB 模型 + 箱子) · 20. 物品进阶:功能与动画 · 19. FPSMatch 使用指南