跳转至

原创教程 · 环境:Minecraft 1.20.1 / Forge 47.x · 示例 modid mymod 前置:18. 方块注册(BB 模型 + 箱子);方法签名经 Forge 1.20.1-47.3.0 jar 实证

21. 方块进阶:交互、状态与动画

目录

  1. 方块进阶:让方块「活」起来
  2. 交互与生命周期:use / onPlace / onRemove / playerWillDestroy
  3. 方块状态与属性:一个有开关的方块
  4. 形状与碰撞:非整方块的形状怎么写
  5. 随机刻、计划刻与掉落
  6. 含水方块:花盆为什么能装水
  7. 方块实体进阶:EntityBlock、ticker 与数据同步
  8. 方块动画(一):贴图逐帧与 animateTick
  9. 方块动画(二):用 BlockEntityRenderer 驱动模型部件
  10. 方块进阶:性能与踩坑清单

方块进阶:让方块「活」起来

18. 方块注册 中,我们已经学会了如何注册一个基本的方块,使其能够被放置、挖掘和存储物品。但是,一个真正「活」起来的方块需要更多的功能,如交互、状态变化、动画效果等。在本章中,我们将一步步地学习如何让方块「活」起来。

本章的内容包括: 1. 交互与生命周期:覆写 useonPlaceonRemove 等方法,使方块能够响应玩家交互和生命周期变化。 2. 状态属性:定义和使用状态属性,使方块能够具有不同的状态。 3. 形状碰撞:覆写 getShapegetCollisionShape 等方法,使方块能够具有不同的形状和碰撞箱。 4. 随机刻与掉落:覆写 tickrandomTick 等方法,使方块能够具有随机的行为。 5. 含水:实现 SimpleWaterloggedBlock 接口,使方块能够含水。 6. 方块实体进阶:使用方块实体来存储和管理方块的状态和行为。 7. animateTick:覆写 animateTick 方法,使方块能够具有客户端动画效果。 8. BER 动画:使用 BlockEntityRenderer 来创建复杂的动画效果。

下面是需求与覆写方法的对照表:

需求 覆写方法
右键交互 use
放置后 onPlace
移除 onRemove
玩家破坏前 playerWillDestroy
邻居变化 neighborChanged
计划刻 tick
随机刻 randomTick
客户端动画 animateTick
状态属性 createBlockStateDefinition
形状碰撞 getShapegetCollisionShape
含水 SimpleWaterloggedBlock 接口
方块实体 newBlockEntitygetTicker

通过学习这些内容,我们将能够创建出更加复杂和有趣的方块,丰富 Minecraft 的游戏世界。接下来,我们将开始学习这些内容的具体实现。


交互与生命周期:use / onPlace / onRemove / playerWillDestroy

当玩家与方块交互时,会触发一系列的回调函数。这些函数可以帮助你控制方块的行为和状态。

首先是 use 函数,它会在玩家右键点击方块时被调用。这个函数返回一个 InteractionResult 对象,表示交互的结果。InteractionResult 有四种可能的值:SUCCESSCONSUMEFAILPASS。其中,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"));
        }
    }
}
注意,这个代码示例只是一个简单的例子,你需要根据你的需求修改和扩展它。


方块状态与属性:一个有开关的方块

你会看到很多方块都有多种状态,比如门的开合、红石的强弱等。这些状态都是通过方块的属性来实现的。我们可以通过 BooleanPropertyIntegerPropertyEnumProperty 来创建这些属性。

首先,我们需要在方块的构造器中定义这些属性。例如,我们可以创建一个有开关的方块:

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,并在方块的构造器中将其添加到方块的状态定义中。我们还设置了方块的默认状态为关闭(OPENfalse)。

接下来,我们需要在方块的 getStateForPlacement 方法中根据玩家的朝向来确定方块的初始状态:

@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
    return this.defaultBlockState().setValue(OPEN, false);
}
在上面的代码中,我们返回了方块的默认状态,即关闭状态。

现在,我们可以通过 state.getValuestate.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=trueopen=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.joinShapes.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)  // 上半部分
);
在定义形状时,需要注意 getShapegetCollisionShapegetInteractionShape 三个方法,它们分别代表了视觉形状、碰撞形状和交互形状。这些形状可以不同,例如地毯的碰撞形状可能比视觉形状低一些,而陷阱的交互形状可能比视觉形状大一些。

对于静态形状,可以将其缓存在静态字段中,以避免每次调用 getShape 方法时都创建一个新的形状。例如:

private static final VoxelShape SHAPE = Block.box(0, 0, 0, 16, 8, 16);
对于动态形状,需要根据状态或朝向来计算形状。为了避免每帧都创建一个新的形状,可以使用一个 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 文件,定义掉落规则。例如:

{
  "pools": [
    {
      "rolls": 1,
      "entries": [
        {
          "type": "item",
          "name": "mymod:my_item"
        }
      ]
    }
  ]
}
这个 JSON 文件定义了一个掉落池,里面只有一个条目,就是 my_item 物品。

如果你需要特殊的掉落逻辑,你可以在 playerWillDestroyonRemove 方法中使用 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 点经验
    }
}
这个例子实现了一个会随时间扩散的方块,当被挖掉时会掉 10 点经验。


含水方块:花盆为什么能装水

含水方块是 Minecraft 中一种特殊的方块,它可以容纳水或其他液体。要创建一个含水方块,我们需要使用 SimpleWaterloggedBlock 类,并实现 BucketPickupLiquidBlockContainer 接口。

首先,我们需要在方块中添加 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);
}

为了让方块可以与水桶交互,我们需要实现 canPlaceLiquidpickupBlock 方法。

@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);
}

最后,我们需要在 FMLClientSetupEventenqueueWork 方法中设置方块的渲染层为 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 接口,这个接口定义了两个重要的方法:newBlockEntitygetTicker

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;
    }
}

方块实体的存盘和加载是通过 saveAdditionalload 方法实现的。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 提供了三种同步方式:getUpdateTaggetUpdatePacketlevel.sendBlockUpdatedgetUpdateTag 方法用于获取方块实体的更新标签,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.pngrune_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 数据(客户端侧改了会被服务端覆盖)

怎么验证

  1. 进游戏放下方块:贴图应该在按 frametime 翻页,粒子按你设的频率冒出来
  2. 让另一个玩家站在远处看:粒子和音效应该只有附近的人能看到/听到(这是 animateTick + playLocalSound 的正常表现)
  3. 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)(单位是格,不是像素)

怎么验证

  1. 放下方块,扇叶应该连续平滑旋转——不是一顿一顿的(顿挫说明你漏了 + partialTick
  2. 走远到 64 格外,F3 看帧率应该回升(getViewDistance 生效)
  3. 拿另一个方块紧贴着放,观察是否出现 Z 轴闪烁/半透明穿帮——有的话先检查 getRenderShapeRenderType 选得对不对

Warning

  • 别在 renderbakeLayernew 对象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

渲染层设置要放在客户端初始化里(FMLClientSetupEventenqueueWork,或 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
挖掉方块后东西没了 / 掉两份 掉落逻辑重复 掉落交给掉落表;只有在需要额外掉落时才在 playerWillDestroyonRemove 里补 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 使用指南