Multi-Directional Caterpillars

Caterpillars will always spawn on top of a leaf block. We want to give a bit more variety in our caterpillar behaviour. So we modify caterpillars so that they can crawl on any side of a leaf block. It’s a small detail, but took a decent amount of time to develop.

I thought that this would be a quick change that wouldn’t take too long. I was wrong. One of the reasons I ended up becoming a network programmer is because I struggled with animation, especially when it came to rotations.

And this change is nothing but rotations…

Caterpillar


The bulk of this change is implemented in the Caterpillar entity. We need to define some data to save the direction the caterpillar faces, make sure that it moves in the correct plane, and determine whether or not the entity is affected by gravity.

Direction and Surface Block

We need some extra data in our entities in order to make this work. We store a DIRECTION that tells us which direction it is towards the surface that the caterpillar is crawling upon. We use this to determine the movement and rotation of the caterpillar.

We also store the SURFACE_BLOCK, which is the position of the block that the caterpillar is crawling upon. This is used to detect when the block is destroyed so we can start to apply gravity to the caterpillar entity.

    // Serializers for data stored in the save data.
    private static final EntityDataAccessor<Boolean> DATA_PERSISTENT =
            SynchedEntityData.defineId(
                    Caterpillar.class,
                    EntityDataSerializers.BOOLEAN);

    private static final EntityDataAccessor<Direction> DATA_DIRECTION =
            SynchedEntityData.defineId(
                    Caterpillar.class,
                    EntityDataSerializers.DIRECTION);

    private static final EntityDataAccessor<BlockPos> DATA_SURFACE_BLOCK =
            SynchedEntityData.defineId(
                    Caterpillar.class,
                    EntityDataSerializers.BLOCK_POS);

    // Names of the attributes stored in the save data.
    private static final String PERSISTENT = "persistent";

    private static final String DIRECTION = "direction";

    private static final String SURFACE_BLOCK = "surface_block";

We then need to make sure that these data values are stored when we save the game. We use toShortString() to save the surface block’s position, since save data can’t store BlockPos data natively.

    /**
     * Used to add extra parameters to the entity's save data.
     * @param tag The tag containing the extra save data.
     */
    @Override
    public void addAdditionalSaveData(@NotNull CompoundTag tag) {
        super.addAdditionalSaveData(tag);
        tag.putBoolean(PERSISTENT, this.entityData.get(DATA_PERSISTENT));
        tag.putString(DIRECTION, this.entityData.get(DATA_DIRECTION).getName());
        tag.putString(
                SURFACE_BLOCK,
                this.entityData.get(DATA_SURFACE_BLOCK).toShortString());
    }

We also need to load the data when a world is restarted. To recreate the BlockPos of the surface block we use Java’s native split(), trim() and Integer.parseInt() methods.

   /**
     * Override to read any additional save data.
     * @param tag The tag containing the entity's save data.
     */
    @Override
    public void readAdditionalSaveData(@NotNull CompoundTag tag) {
        super.readAdditionalSaveData(tag);

        // Read the placed-by-player flag if it exists.
        if (tag.contains(PERSISTENT)) {
            this.entityData.set(DATA_PERSISTENT, tag.getBoolean(PERSISTENT));
        }

        // Get the direction
        if (tag.contains(DIRECTION)) {
            String name = tag.getString(DIRECTION);
            Direction direction = Direction.byName(name);
            if (direction != null) {
                this.entityData.set(DATA_DIRECTION, direction);
            }
        }

        if (tag.contains(SURFACE_BLOCK)) {
            String data = tag.getString(SURFACE_BLOCK);
            String[] values = data.split(",");
            BlockPos position = new BlockPos(
                    Integer.parseInt(values[0].trim()),
                    Integer.parseInt(values[1].trim()),
                    Integer.parseInt(values[2].trim()));
            this.entityData.set(DATA_SURFACE_BLOCK, position);
        }
    }

We provide a couple of methods to help set this data. These are used when we spawn caterpillars, as well as to update their state if the surface block is destroyed.

    /**
     * Set the position of the block the caterpillar is crawling on.
     * @param position The position of the block.
     */
    public void setSurfaceBlock(BlockPos position) {
        this.entityData.set(DATA_SURFACE_BLOCK, position);
    }

    /**
     * Set the direction of the block that the caterpillar is crawling on.
     * @param direction The direction of the surface block.
     */
    public void setSurfaceDirection(Direction direction) {
        this.entityData.set(DATA_DIRECTION, direction);
    }

We provide a public accessor to the surface direction. This is used by the renderer to rotate the model, as we will see later.

    /**
     * Get the direction to the surface the caterpillar is crawling on.
     * @return The direction of the block (UP, DOWN, NORTH, SOUTH, EAST, WEST).
     */
    @NotNull
    public Direction getSurfaceDirection() {
        return entityData.get(DATA_DIRECTION);
    }

The accessor for the surface block is private, since we only need to check it when we decide if gravity is being applied or not.

    /**
     * Get the position of the block the caterpillar is crawling on.
     * @return The position of the block.
     */
    private BlockPos getSurfaceBlock() {
        return this.entityData.get(DATA_SURFACE_BLOCK);
    }

Gravity

By default we don’t want gravity to be applied to caterpillars. Otherwise they will all fall off any leaf blocks they spawn on. If the leaf block is destroyed, then we want them to fall under gravity as normal.

We implement this by overriding isNoGravity(). It checks to see if the surface block still exists using isEmptyBlock(). If it doesn’t, then we do a few things:

  • Change the direction to DOWN. The caterpillar is falling now, so this will be the direction of the block it lands on.
  • Set the surface block to the block below it. This will mean that gravity will stop applying automatically if it lands on a block.
  • Reset the target position to null. This is so the caterpillar picks a new target when it lands. Otherwise, it would try and move back towards it’s original position.
    /**
     * Only apply gravity if the caterpillar isn't attached to a block.
     */
    @Override
    public boolean isNoGravity() {
        boolean isNoGravity = true;

        if (this.level().isEmptyBlock(getSurfaceBlock())) {
            setSurfaceDirection(Direction.DOWN);
            setSurfaceBlock(this.blockPosition().below());
            this.targetPosition = null;
            isNoGravity = false;
        }

        return isNoGravity;
    }

Spawning

Before we call finalizeSpawn() we need to make sure we have moved the entity to the correct direction, as well as setting it’s DIRECTION and SURFACE_BLOCK. We modify our spawn() method so it takes an extra Direction parameter, and use this to determine these values.

We modify the position to the surface of the block based on the direction of the caterpillar. In this way it will start on the surface it is supposed to be on and players won’t see it teleport to the position after the first frame.

We also use the direction to determine the position of the spawn block. All of these parameters are then applied to the caterpillar before we call finalizeSpawn() and add it to the world.

    /**
     * Spawns a caterpillar into the world.
     * @param entityId The type of butterfly to release.
     * @param position The current position of the player.
     */
    @SuppressWarnings({"deprecation", "OverrideOnly"})
    public static void spawn(ServerLevel level,
                             String entityId,
                             BlockPos position,
                             Direction direction) {

        ResourceLocation key = new ResourceLocation(
                ButterfliesMod.MODID,
                entityId + "_caterpillar");

        EntityType<?> entityType = ForgeRegistries.ENTITY_TYPES.getValue(key);
        if (entityType != null) {
            Entity entity = entityType.create(level);
            if (entity instanceof Caterpillar caterpillar) {
                double  x = position.getX() + 0.45D;
                double  y = position.getY() + 0.2D;
                double  z = position.getZ() + 0.5D;

                BlockPos spawnPosition;
                if (direction == Direction.DOWN) {
                    y = Math.floor(position.getY());
                    spawnPosition = position.below();
                } else if (direction == Direction.UP) {
                    y = Math.floor(position.getY()) + 1.0d;
                    spawnPosition = position.above();
                } else if (direction == Direction.NORTH) {
                    z = Math.floor(position.getZ());
                    spawnPosition = position.north();
                } else if (direction == Direction.SOUTH) {
                    z = Math.floor(position.getZ()) + 1.0d;
                    spawnPosition = position.south();
                } else if (direction == Direction.WEST) {
                    x = Math.floor(position.getX());
                    spawnPosition = position.west();
                } else {
                    x = Math.floor(position.getX()) + 1.0d;
                    spawnPosition = position.east();
                }

                caterpillar.moveTo(x, y, z, 0.0F, 0.0F);
                caterpillar.setSurfaceDirection(direction);
                caterpillar.setSurfaceBlock(spawnPosition);

                caterpillar.finalizeSpawn(level,
                        level.getCurrentDifficultyAt(position),
                        MobSpawnType.NATURAL,
                        null,
                        null);

                level.addFreshEntity(caterpillar);
            }
        }
    }

Movement

This is the largest change. Movement essentially works the same as it did before, except we need to take into account the direction that the caterpillar is facing. Basically we need to apply our movement along different axes depending on the caterpillar’s direction (i.e. xy, xz, or yz).

Before we run our movement code, we first check if gravity is being applied. If it is, then we don’t run our movement code, since the caterpillar is falling.

   /**
     * A custom step for the AI update loop.
     */
    @Override
    protected void customServerAiStep() {
        super.customServerAiStep();

        // If the caterpillar is falling then it can't crawl.
        if (this.isNoGravity()) {
            // Movement code here
        }
    }

If we are moving then the next thing we do is figure out a target position if we don’t have one already. Same as before, we change after a random interval or if we get too close to the target. The difference is that we use different axes depending on the direction that the caterpillar is facing.

   /**
     * A custom step for the AI update loop.
     */
    @Override
    protected void customServerAiStep() {
        super.customServerAiStep();

        // If the caterpillar is falling then it can't crawl.
        if (this.isNoGravity()) {

            Direction direction = this.getSurfaceDirection();
            Direction.Axis axis = direction.getAxis();

            // Set a new target position if:
            //  1. We don't have one already
            //  2. After a 1/30 random chance
            //  3. We get too close to the current target position
            if (this.targetPosition == null ||
                this.targetPosition.distanceToSqr(this.position()) < 0.007d ||
                this.random.nextInt(30) == 0) {

                if (this.targetPosition == null) {
                    this.targetPosition = this.position();
                }

                if (axis == Direction.Axis.X) {
                    this.targetPosition = new Vec3(
                            this.targetPosition.x(),
                            Math.floor(this.targetPosition.y())
                                    + (this.random.nextDouble() % 1.0),
                            Math.floor(this.targetPosition.z())
                                    + (this.random.nextDouble() % 1.0));
                } else if (axis == Direction.Axis.Y) {
                    this.targetPosition = new Vec3(
                            Math.floor(this.targetPosition.x())
                                    + (this.random.nextDouble() % 1.0),
                            this.targetPosition.y(),
                            Math.floor(this.targetPosition.z())
                                    + (this.random.nextDouble() % 1.0));

                } else {
                    this.targetPosition = new Vec3(
                            Math.floor(this.targetPosition.x())
                                    + (this.random.nextDouble() % 1.0),
                            Math.floor(this.targetPosition.y())
                                    + (this.random.nextDouble() % 1.0),
                            this.targetPosition.z());
                }
            }

            // Move the caterpillar
        }
    }

Next we update the caterpillar’s movement delta. We use the same algorithm as before, except in one of three different planes now.

   /**
     * A custom step for the AI update loop.
     */
    @Override
    protected void customServerAiStep() {
        super.customServerAiStep();

        // If the caterpillar is falling then it can't crawl.
        if (this.isNoGravity()) {

            Direction direction = this.getSurfaceDirection();
            Direction.Axis axis = direction.getAxis();

            // Get new target position

            Vec3 deltaMovement = this.getDeltaMovement();
            Vec3 updatedDeltaMovement;
            if (axis == Direction.Axis.X) {
                double dy = this.targetPosition.y() + 0.1d - this.getY();
                double dz = this.targetPosition.z() + 0.1d - this.getZ();
                updatedDeltaMovement = deltaMovement.add(
                        0.0,
                        (Math.signum(dy) * 0.5d - deltaMovement.y)
                                * CATERPILLAR_SPEED,
                        (Math.signum(dz) * 0.5d - deltaMovement.z)
                                * CATERPILLAR_SPEED);
            } else if (axis == Direction.Axis.Y) {
                double dx = this.targetPosition.x() + 0.1d - this.getX();
                double dz = this.targetPosition.z() + 0.1d - this.getZ();
                updatedDeltaMovement = deltaMovement.add(
                        (Math.signum(dx) * 0.5d - deltaMovement.x)
                                * CATERPILLAR_SPEED,
                        0.0,
                        (Math.signum(dz) * 0.5d - deltaMovement.z)
                                * CATERPILLAR_SPEED);
            } else {
                double dx = this.targetPosition.x() + 0.1d - this.getX();
                double dy = this.targetPosition.y() + 0.1d - this.getY();
                updatedDeltaMovement = deltaMovement.add(
                        (Math.signum(dx) * 0.5d - deltaMovement.x)
                                * CATERPILLAR_SPEED,
                        (Math.signum(dy) * 0.5d - deltaMovement.y)
                                * CATERPILLAR_SPEED,
                        0.0);
            }

            this.setDeltaMovement(updatedDeltaMovement);

            // Now we rotate
        }
    }

Finally we make sure the caterpillar is facing in the correct direction. We need a slightly different algorithm depending on the direction, so we have six branches this time. We always rotate around the y-axis here – we will rotate the model again in the renderer so it looks correct.

   /**
     * A custom step for the AI update loop.
     */
    @Override
    protected void customServerAiStep() {
        super.customServerAiStep();

        // If the caterpillar is falling then it can't crawl.
        if (this.isNoGravity()) {

            Direction direction = this.getSurfaceDirection();
            Direction.Axis axis = direction.getAxis();

            // Get target position

            // Update movement delta

            this.zza = 0.5f;

            // Calculate the rotational velocity.
            double updatedRotation;
            if (direction == Direction.DOWN) {
                updatedRotation =
                        (Mth.atan2(
                                updatedDeltaMovement.z,
                                updatedDeltaMovement.x)
                        * (180.0d / Math.PI)) - 90.0d;
            } else if (direction == Direction.UP) {
                updatedRotation =
                        (Mth.atan2(
                                updatedDeltaMovement.x,
                                updatedDeltaMovement.z)
                        * (180.0d / Math.PI)) - 180.0d;
            } else if (direction == Direction.NORTH) {
                updatedRotation =
                        (Mth.atan2(
                                updatedDeltaMovement.x,
                                updatedDeltaMovement.y)
                        * (180.0d / Math.PI)) - 180.0d;
            } else if (direction == Direction.SOUTH) {
                updatedRotation =
                        (Mth.atan2(
                                updatedDeltaMovement.y,
                                updatedDeltaMovement.x)
                        * (180.0d / Math.PI)) - 90.0d;
            } else if (direction == Direction.EAST) {
                updatedRotation =
                        (Mth.atan2(
                                updatedDeltaMovement.z,
                                updatedDeltaMovement.y)
                        * (180.0d / Math.PI)) - 90.0d;
            } else {
                updatedRotation =
                        (Mth.atan2(
                                updatedDeltaMovement.y,
                                updatedDeltaMovement.z)
                        * (180.0d / Math.PI));
            }

            double rotationDelta =
                    Mth.wrapDegrees(updatedRotation - this.getYRot());
            this.setYRot(this.getYRot() + (float) rotationDelta);
        }
    }

Caterpillar Renderer


Our caterpillar is almost perfect now. The only problem is that it will always render as if it’s body was on the ground normally. Entities in Minecraft can’t be rotated in all planes easily, so instead we use the renderer to rotate the model itself.

We can do this by overriding the render() method and applying a rotation to the poseStack based on the direction of the caterpillar.

    /**
     * Rotates the caterpillar so it's attached to its block.
     * @param entity The caterpillar entity.
     * @param p_115456_ Unknown.
     * @param p_115457_ Unknown.
     * @param poseStack The posed model to render.
     * @param multiBufferSource The render buffer.
     * @param p_115460_ Unknown.
     */
    @Override
    public void render(@NotNull Caterpillar entity,
                       float p_115456_,
                       float p_115457_,
                       @NotNull PoseStack poseStack,
                       @NotNull MultiBufferSource multiBufferSource,
                       int p_115460_) {
        Direction direction = entity.getSurfaceDirection();
        if (direction == Direction.UP) {
            poseStack.mulPose(Axis.XP.rotationDegrees(180.f));
        } else if (direction == Direction.NORTH) {
            poseStack.mulPose(Axis.XP.rotationDegrees(90.f));
        } else if (direction == Direction.SOUTH) {
            poseStack.mulPose(Axis.XP.rotationDegrees(-90.f));
        } else if (direction == Direction.WEST) {
            poseStack.mulPose(Axis.ZP.rotationDegrees(-90.f));
        } else if (direction == Direction.EAST){
            poseStack.mulPose(Axis.ZP.rotationDegrees(90.f));
        }

        super.render(entity, p_115456_, p_115457_, poseStack, multiBufferSource, p_115460_);
    }

Hatching


The final piece of the puzzle is to spawn caterpillars in all directions. Currently we only spawn caterpillars in the DOWN direction, so we modify our trySpawnCaterpillar() method so that it chooses a random direction when an egg hatches.

    /**
     * Try to spawn a caterpillar.
     * @param blockState The current block state.
     * @param level The current level.
     * @param position The position of the block.
     * @param random The random number generator.
     */
    default void trySpawnCaterpillar(@NotNull BlockState blockState,
                                     @NotNull ServerLevel level,
                                     @NotNull BlockPos position,
                                     @NotNull RandomSource random) {
        if (random.nextInt(15) == 0) {
            int directionIndex = random.nextInt(6);
            BlockPos spawnPosition;
            Direction direction;
            switch (directionIndex) {
                case 0 -> {
                    spawnPosition = position.below();
                    direction = Direction.UP;
                }
                case 1 -> {
                    spawnPosition = position.north();
                    direction = Direction.SOUTH;
                }
                case 2 -> {
                    spawnPosition = position.south();
                    direction = Direction.NORTH;
                }
                case 3 -> {
                    spawnPosition = position.east();
                    direction = Direction.WEST;
                }
                case 4 -> {
                    spawnPosition = position.west();
                    direction = Direction.EAST;
                }
                default -> {
                    spawnPosition = position.above();
                    direction = Direction.DOWN;
                }
            }

            if (level.isEmptyBlock(spawnPosition)) {
                int index = blockState.getValue(
                        ButterflyLeavesBlock.BUTTERFLY_INDEX);

                Caterpillar.spawn(
                        level,
                        ButterflyIds.IndexToEntityId(index),
                        spawnPosition,
                        direction);

                ButterflyLeavesBlock.removeButterflyEgg(level, position);
            }
        }
    }

Conclusion


This was a fair amount of work for something that seems so trivial. But I believe it’s worth working on these small details as it can make a mod seem more complete.

In addition, with this groundwork I’m already in a good position to implement chrysalises that can spawn in any direction on a leaf block.

I will get to them eventually…