-
Notifications
You must be signed in to change notification settings - Fork 0
Custom Block Animations
This page covers implementing animated blocks using Hytale Model Loader. The system has three moving parts: the block class, the block entity, and the renderer. The chest is used as the example throughout.
- The block class handles player interaction and drives the ticker
- The block entity tracks animation state and syncs it to clients
- The renderer reads that state and samples the correct animation file each frame
Your block must implement EntityBlock and extend HytaleBlockBase. Two things are required
beyond a normal block: a ticker that drives the animation clock, and RenderShape.INVISIBLE so
Minecraft doesn't try to render a vanilla model on top.
public class HytaleChest extends HytaleBlockBase implements EntityBlock {
public HytaleChest(Properties properties) {
super(properties);
}
@Override
public @Nullable BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new AnimatedChestBlockEntity(pos, state);
}
// Drive the animation tick on the client
@Override
public @Nullable <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state,
BlockEntityType<T> type) {
if (!level.isClientSide()) return null;
return (lvl, pos, blockState, blockEntity) -> {
if (blockEntity instanceof AnimatedChestBlockEntity chest) {
AnimatedChestBlockEntity.clientTick(lvl, pos, blockState, chest);
}
};
}
// Required — prevents Minecraft rendering a vanilla model
@Override
protected RenderShape getRenderShape(BlockState state) {
return RenderShape.INVISIBLE;
}
// Trigger state changes however your block needs to — here it's on click
@Override
protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hitResult) {
if (level.isClientSide()) {
BlockEntity entity = level.getBlockEntity(pos);
if (entity instanceof AnimatedChestBlockEntity chest) {
if (chest.isOpen()) chest.closeChest();
else chest.openChest();
}
}
return InteractionResult.SUCCESS;
}
}Your block entity extends HytaleBlockEntity and is responsible for three things: tracking the
animation tick, tracking your custom state (e.g. open/closed), and syncing that state to clients
when it changes.
public class AnimatedChestBlockEntity extends HytaleBlockEntity {
private static final String NBT_KEY = "ChestData";
private static final String NBT_IS_OPEN = "IsOpen";
private boolean isOpen = false;
private int animationTick = 0;
private boolean lastKnownOpen = false;
public AnimatedChestBlockEntity(BlockPos pos, BlockState state) {
// The last argument must match the model name used in your block model JSON
super(BlockEntityInit.CHEST_TEST_ENT.get(), pos, state, "chest_small");
}
// Called by the ticker — increments the clock the renderer samples against
public static void clientTick(Level level, BlockPos pos, BlockState state,
AnimatedChestBlockEntity be) {
be.animationTick++;
}
@Override
public int getAnimationTick() {
return animationTick;
}
// State change methods — reset the tick and sync to clients for swapping animations
public void openChest() {
if (!isOpen) {
animationTick = 0;
isOpen = true;
setChanged();
syncToClients();
}
}
public void closeChest() {
if (isOpen) {
animationTick = 0;
isOpen = false;
setChanged();
syncToClients();
}
}
public boolean isOpen() { return isOpen; }
// Save your custom state to NBT
@Override
protected void saveAnimationData(ValueOutput output) {
CompoundTag tag = new CompoundTag();
tag.putBoolean(NBT_IS_OPEN, isOpen);
output.store(NBT_KEY, CompoundTag.CODEC, tag);
}
// Load it back — reset the tick if state changed since last sync
@Override
protected void loadAnimationData(ValueInput input) {
input.read(NBT_KEY, CompoundTag.CODEC).ifPresent(tag -> {
boolean newOpen = tag.getBoolean(NBT_IS_OPEN).orElse(false);
if (newOpen != lastKnownOpen) {
animationTick = 0;
lastKnownOpen = newOpen;
}
isOpen = newOpen;
});
}
private void syncToClients() {
if (level != null && !level.isClientSide()) {
level.blockEntityChanged(getBlockPos());
}
}
}Note: The animation tick is reset to
0whenever state changes. This ensures the animation always plays from the beginning when transitioning between open and closed.
The render state is a simple data class that carries everything the renderer needs from the block
entity. It extends HytaleRenderState and adds whatever custom fields your animation logic requires.
public class AnimatedChestRenderState extends HytaleRenderState {
public boolean isOpen;
}Your renderer extends HytaleBlockEntityRenderer typed to your block entity and render state.
There are two methods to implement: extractAdditionalRenderState copies data from the block
entity into the render state, and calculateAnimationTransforms uses that state to sample the
correct animation file.
public class AnimatedChestRenderer extends HytaleBlockEntityRenderer<AnimatedChestBlockEntity, AnimatedChestRenderState> {
private static final Identifier ANIM_OPEN =
Identifier.fromNamespaceAndPath(HytaleModelLoader.MODID,
"animations/chest_small/chest_open.blockyanim");
private static final Identifier ANIM_CLOSE =
Identifier.fromNamespaceAndPath(HytaleModelLoader.MODID,
"animations/chest_small/chest_close.blockyanim");
public AnimatedChestRenderer(BlockEntityRendererProvider.Context context) {
super(context);
}
@Override
public AnimatedChestRenderState createRenderState() {
return new AnimatedChestRenderState();
}
// Copy what you need from the block entity into the render state
@Override
protected void extractAdditionalRenderState(AnimatedChestBlockEntity blockEntity,
AnimatedChestRenderState renderState,
float partialTick) {
renderState.isOpen = blockEntity.isOpen();
}
// Pick the right animation and sample it at the current tick
@Override
protected Map<String, NodeTransform> calculateAnimationTransforms(AnimatedChestRenderState renderState,
BlockyModelGeometry geometry) {
Map<String, NodeTransform> transforms = new HashMap<>();
Identifier animId = renderState.isOpen ? ANIM_OPEN : ANIM_CLOSE;
BlockyAnimationPlayer animationPlayer = getOrCreatePlayer(animId);
if (animationPlayer != null) {
transforms.putAll(animationPlayer.calculateTransforms(renderState.ageInTicks));
}
return transforms;
}
}Animation files are loaded by identifier and sampled using ageInTicks, which comes from
HytaleRenderState and is automatically populated from your block entity's getAnimationTick().
Animation file location:
assets/<modid>/animations/<model_name>/your_animation.blockyanim
Register your block entity in your BlockEntityInit and your renderer in your client setup. Don't forget to also register your block and block item.
// BlockEntityInit.java
public static final Supplier<BlockEntityType<AnimatedChestBlockEntity>> CHEST_TEST_ENT = BLOCK_ENTITIES.register(
"hytale_chest", () -> new BlockEntityType<>(
AnimatedChestBlockEntity::new,
false,
BlockInit.SMALL_CHEST.get()
)
);public static final DeferredBlock<Block> SMALL_CHEST = BLOCKS.registerBlock("chest_small", HytaleChest::new);public static final DeferredItem<BlockItem> SMALL_CHEST = ITEMS.registerSimpleBlockItem("chest_small", BlockInit.SMALL_CHEST);// Client setup — wherever you register your other block entity renderers
BlockEntityRenderers.register(BlockEntityInit.CHEST_TEST_ENT.get(), AnimatedChestRenderer::new);Instead of (or alongside) a .blockyanim file, you can drive bone transforms directly in
calculateAnimationTransforms using NodeTransform. The key is the string you pass to
transforms.put() — it must exactly match the bone name in your .blockymodel file.
NodeTransform has four factory methods:
NodeTransform.translation(new Vector3f(x, y, z)); // Move a bone
NodeTransform.rotation(new Quaternionf().rotateX(angle)); // Rotate a bone
NodeTransform.scale(new Vector3f(x, y, z)); // Scale a bone
NodeTransform.visibility(false); // Show/hide a bone@Override
protected Map<String, NodeTransform> calculateAnimationTransforms(AnimatedChestRenderState renderState,
BlockyModelGeometry geometry) {
Map<String, NodeTransform> transforms = new HashMap<>();
float angle = (float) Math.sin(renderState.ageInTicks * ANIMATION_SPEED) * MAX_LID_ANGLE;
Quaternionf rotation = new Quaternionf().rotateX((float) Math.toRadians(-Math.abs(angle)));
transforms.put("Lid", NodeTransform.rotation(rotation));
return transforms;
}ageInTicks increments every client tick, so passing it into a Math.sin() produces a smooth
repeating motion. Multiply by a small speed constant to control how fast it oscillates, and by a
max angle to control how far it rotates.
You can mix both systems in the same method. Load the animation file first, then put any procedural transforms on top. They will overwrite the file-based transform for that specific bone while leaving all others untouched.
@Override
protected Map<String, NodeTransform> calculateAnimationTransforms(AnimatedChestRenderState renderState,
BlockyModelGeometry geometry) {
Map<String, NodeTransform> transforms = new HashMap<>();
// File-based animation drives most bones
BlockyAnimationPlayer animationPlayer = getOrCreatePlayer(animId);
if (animationPlayer != null) {
transforms.putAll(animationPlayer.calculateTransforms(renderState.ageInTicks));
}
// Procedural transform overrides a specific bone on top
float angle = (float) Math.sin(renderState.ageInTicks * 0.05f) * 15f;
transforms.put("Lid", NodeTransform.rotation(
new Quaternionf().rotateX((float) Math.toRadians(-Math.abs(angle)))
));
return transforms;
}If you want to combine two transforms on the same bone additively rather than overwrite one with
the other, use NodeTransform.merge():
NodeTransform fileTransform = transforms.get("Lid");
NodeTransform proceduralTransform = NodeTransform.translation(new Vector3f(0, 0.1f, 0));
if (fileTransform != null) {
transforms.put("Lid", fileTransform.merge(proceduralTransform));
}merge() adds positions, multiplies rotations, multiplies scales, and ANDs visibility, so both
transforms contribute to the final result.
Note: When making procedural animations remember that all child nodes will be transformed attached to their parent node.