Fabric 1.20 custom creature lesson

让 Blockbench 大象变成真正的生物

这节课根据一次真实实现过程整理:先研究 `Elefant.bbmodel`,再把贴图、实体注册、AI、刷怪蛋、客户端渲染器和生成动画一步步接进 Fabric 1.20 项目。学生学完以后,不只是会复制一只大象,也会明白“服务端负责生物,客户端负责看起来怎么动”。

Fabric 1.20 Blockbench EntityType Spawn Egg Custom Renderer Spawn Animation

Research recap

我们先像侦探一样检查模型,再决定代码路线。

拿到 `Elefant.bbmodel` 后,第一步不是立刻写实体类,而是先看它到底是什么格式。检查结果很关键:它是 Blockbench 的 `java_block` 模型,里面有 24 个方块、7 张嵌入 PNG 贴图,但是没有自带动画轨道。

这意味着课程不能简单地“播放模型动画”。我们采用的路线是:服务端做一个真正的大象实体,客户端写一个自定义渲染器读取 bbmodel,再用 Java 代码给耳朵、鼻子、腿和出生过程补动画。

01

模型检查

确认文件结构、贴图数量、方块数量和是否存在动画轨道。

02

方案选择

不加 GeckoLib,改用 Fabric 原生实体 + 自定义渲染器。

03

游戏验证

进世界自动生成大象,也可以用“大象刷怪蛋”手动测试。

Minecraft 风格大象生物从粒子中生成,旁边有代码面板和 Blockbench 网格提示
本图由 AI 生成,视觉参考了这次上传的 Blockbench 大象截图:灰色方块身体、大耳朵、鼻子、白色象牙和模型编辑网格。
Fabric 大象生物生成动画教程思维导图
思维导图:从输入资源、服务端实体、客户端渲染、动画、教学重点到构建验证。

Mind map

把“大象”拆成六个模块,代码就不乱了。

  • 输入资源:`Elefant.bbmodel` 和 7 张贴图决定了外观。
  • 服务端:`EntityType`、属性、AI、刷怪蛋决定它是不是一个真正的 Minecraft 生物。
  • 客户端:渲染器决定模型如何显示,贴图如何贴到每个面。
  • 动画:没有 Blockbench 动画轨道时,可以用 Java 在渲染阶段做摆动和出生效果。
  • 验证:编译、打包、进世界、刷怪蛋测试,一个都不能省。

课堂提醒:学生最容易把“实体逻辑”和“模型渲染”混在一起。可以反复强调:服务器知道大象在哪里、会不会走;客户端负责把它画成什么样、耳朵怎么动。

Build flow

实现流程:资源先落地,再让实体活起来。

Fabric 大象生物从模型研究到完整构建的流程图
流程图:研究模型、导出贴图、注册实体、添加行为、注册渲染、补动画、编译构建、游戏内验证。

File map

本节课新增和修改的文件。

实体注册

ModEntities.java

注册 `maik:elephant`,设置碰撞箱、跟踪距离和默认属性。

src/main/java/mls/maik/entity/ModEntities.java
实体行为

ElephantEntity.java

设置血量、速度、AI 目标、繁殖食物和出生粒子。

src/main/java/mls/maik/entity/custom/ElephantEntity.java
客户端

ElephantEntityRenderer.java

读取 bbmodel,按贴图和 UV 画出模型,再给部件加动画。

src/client/java/mls/maik/client/render/entity/ElephantEntityRenderer.java
生成入口

ModPlayerEvents.java

玩家进入世界后,在前方安全位置生成一只大象。

src/main/java/mls/maik/event/ModPlayerEvents.java
物品

ModItems.java

新增“大象刷怪蛋”,加入创造模式刷怪蛋栏目。

src/main/java/mls/maik/item/ModItems.java
资源

bbmodel + textures

保留原始模型文件,并把嵌入贴图解出到实体贴图目录。

assets/maik/models/entity/elephant.bbmodel

Complete code

完整代码教学:每个标签页都是可复制的真实源码。

Maik.java + ModEntities.java
package mls.maik;

import mls.maik.block.ModBlocks;
import mls.maik.entity.ModEntities;
import mls.maik.event.ModPlayerEvents;
import mls.maik.item.ModItems;
import net.fabricmc.api.ModInitializer;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Maik implements ModInitializer {
	public static final String MOD_ID = "maik";

	// This logger is used to write text to the console and the log file.
	// It is considered best practice to use your mod id as the logger's name.
	// That way, it's clear which mod wrote info, warnings, and errors.
	public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);

	@Override
	public void onInitialize() {
		// This code runs as soon as Minecraft is in a mod-load-ready state.
		// However, some things (like resources) may still be uninitialized.
		// Proceed with mild caution.
        ModEntities.registerModEntities();
        ModBlocks.registerModBlocks();
        ModItems.registerModItems();
        ModPlayerEvents.register();
		LOGGER.info("Hello Fabric world!");
	}
}
package mls.maik.entity;

import mls.maik.Maik;
import mls.maik.entity.custom.ElephantEntity;
import net.fabricmc.fabric.api.object.builder.v1.entity.FabricDefaultAttributeRegistry;
import net.fabricmc.fabric.api.object.builder.v1.entity.FabricEntityTypeBuilder;
import net.minecraft.entity.EntityDimensions;
import net.minecraft.entity.EntityType;
import net.minecraft.entity.SpawnGroup;
import net.minecraft.registry.Registries;
import net.minecraft.registry.Registry;
import net.minecraft.util.Identifier;

public class ModEntities {
    public static final EntityType<ElephantEntity> ELEPHANT = Registry.register(
            Registries.ENTITY_TYPE,
            new Identifier(Maik.MOD_ID, "elephant"),
            FabricEntityTypeBuilder.create(SpawnGroup.CREATURE, ElephantEntity::new)
                    .dimensions(EntityDimensions.fixed(2.25F, 2.75F))
                    .trackRangeBlocks(10)
                    .trackedUpdateRate(3)
                    .build()
    );

    public static void registerModEntities() {
        FabricDefaultAttributeRegistry.register(ELEPHANT, ElephantEntity.createElephantAttributes());
        Maik.LOGGER.info("Registering ModEntities for " + Maik.MOD_ID);
    }
}
ElephantEntity.java
package mls.maik.entity.custom;

import mls.maik.entity.ModEntities;
import net.minecraft.entity.EntityType;
import net.minecraft.entity.ai.goal.AnimalMateGoal;
import net.minecraft.entity.ai.goal.FollowParentGoal;
import net.minecraft.entity.ai.goal.LookAroundGoal;
import net.minecraft.entity.ai.goal.LookAtEntityGoal;
import net.minecraft.entity.ai.goal.SwimGoal;
import net.minecraft.entity.ai.goal.TemptGoal;
import net.minecraft.entity.ai.goal.WanderAroundFarGoal;
import net.minecraft.entity.attribute.DefaultAttributeContainer;
import net.minecraft.entity.attribute.EntityAttributes;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.passive.AnimalEntity;
import net.minecraft.entity.passive.PassiveEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.item.ItemStack;
import net.minecraft.item.Items;
import net.minecraft.particle.ParticleTypes;
import net.minecraft.recipe.Ingredient;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.world.World;
import org.jetbrains.annotations.Nullable;

public class ElephantEntity extends AnimalEntity {
    public static final int SPAWN_ANIMATION_TICKS = 42;
    private static final Ingredient BREEDING_INGREDIENT = Ingredient.ofItems(Items.SUGAR_CANE, Items.WHEAT, Items.APPLE);

    public ElephantEntity(EntityType<? extends AnimalEntity> entityType, World world) {
        super(entityType, world);
    }

    public static DefaultAttributeContainer.Builder createElephantAttributes() {
        return MobEntity.createMobAttributes()
                .add(EntityAttributes.GENERIC_MAX_HEALTH, 44.0D)
                .add(EntityAttributes.GENERIC_MOVEMENT_SPEED, 0.18D)
                .add(EntityAttributes.GENERIC_FOLLOW_RANGE, 24.0D)
                .add(EntityAttributes.GENERIC_KNOCKBACK_RESISTANCE, 0.65D);
    }

    @Override
    protected void initGoals() {
        this.goalSelector.add(0, new SwimGoal(this));
        this.goalSelector.add(1, new AnimalMateGoal(this, 0.6D));
        this.goalSelector.add(2, new TemptGoal(this, 0.75D, BREEDING_INGREDIENT, false));
        this.goalSelector.add(3, new FollowParentGoal(this, 0.7D));
        this.goalSelector.add(4, new WanderAroundFarGoal(this, 0.55D));
        this.goalSelector.add(5, new LookAtEntityGoal(this, PlayerEntity.class, 8.0F));
        this.goalSelector.add(6, new LookAroundGoal(this));
    }

    @Override
    public boolean isBreedingItem(ItemStack stack) {
        return BREEDING_INGREDIENT.test(stack);
    }

    @Nullable
    @Override
    public PassiveEntity createChild(ServerWorld world, PassiveEntity entity) {
        return ModEntities.ELEPHANT.create(world);
    }

    @Override
    public void tickMovement() {
        super.tickMovement();

        if (this.getWorld().isClient && this.age < SPAWN_ANIMATION_TICKS && this.age % 3 == 0) {
            for (int i = 0; i < 4; i++) {
                this.getWorld().addParticle(
                        ParticleTypes.POOF,
                        this.getParticleX(1.2D),
                        this.getY() + 0.05D,
                        this.getParticleZ(1.2D),
                        (this.random.nextDouble() - 0.5D) * 0.03D,
                        0.035D,
                        (this.random.nextDouble() - 0.5D) * 0.03D
                );
            }
        }
    }
}
ModItems.java + ModPlayerEvents.java
package mls.maik.item;

import mls.maik.Maik;
import mls.maik.entity.ModEntities;
import net.fabricmc.fabric.api.itemgroup.v1.ItemGroupEvents;
import net.minecraft.item.Item;
import net.minecraft.item.ItemGroups;
import net.minecraft.item.Items;
import net.minecraft.item.SpawnEggItem;
import net.minecraft.registry.Registries;
import net.minecraft.registry.Registry;
import net.minecraft.util.Identifier;

public class ModItems {
    public static final Item MY_NEW_AXE = registerItem("mynewaxe",
            new MyNewAxeItem(ModToolMaterials.MYNEWAXE, 10, -2.8f, new Item.Settings().fireproof()));
    public static final Item ELEPHANT_SPAWN_EGG = registerItem("elephant_spawn_egg",
            new SpawnEggItem(ModEntities.ELEPHANT, 0x6F6A62, 0xE4D8C2, new Item.Settings()));

    private static Item registerItem(String name, Item item) {
        return Registry.register(Registries.ITEM, new Identifier(Maik.MOD_ID, name), item);
    }

    public static void registerModItems() {
        ItemGroupEvents.modifyEntriesEvent(ItemGroups.TOOLS)
                .register(entries -> entries.addAfter(Items.NETHERITE_PICKAXE, MY_NEW_AXE));
        ItemGroupEvents.modifyEntriesEvent(ItemGroups.SPAWN_EGGS)
                .register(entries -> entries.add(ELEPHANT_SPAWN_EGG));
        Maik.LOGGER.info("Registering ModItems for " + Maik.MOD_ID);
    }
}
package mls.maik.event;

import mls.maik.entity.ModEntities;
import mls.maik.entity.custom.ElephantEntity;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Direction;

public class ModPlayerEvents {
    private static final int SPAWN_DISTANCE = 5;

    public static void register() {
        ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
            ServerPlayerEntity player = handler.getPlayer();
            spawnElephantInFront(player);
        });
    }

    private static void spawnElephantInFront(ServerPlayerEntity player) {
        ServerWorld world = player.getServerWorld();
        Direction forward = player.getHorizontalFacing();
        BlockPos center = player.getBlockPos().offset(forward, SPAWN_DISTANCE);

        ElephantEntity elephant = ModEntities.ELEPHANT.create(world);
        if (elephant != null) {
            BlockPos safePos = findSafeSpawnPos(world, center);
            elephant.refreshPositionAndAngles(
                    safePos.getX() + 0.5,
                    safePos.getY(),
                    safePos.getZ() + 0.5,
                    player.getYaw() + 180.0F,
                    0.0F
            );
            world.spawnEntity(elephant);
        }
    }

    private static BlockPos findSafeSpawnPos(ServerWorld world, BlockPos startPos) {
        for (int yOffset = 2; yOffset >= -3; yOffset--) {
            BlockPos pos = startPos.up(yOffset);
            if (world.getBlockState(pos).isAir()
                    && world.getBlockState(pos.up()).isAir()
                    && world.getBlockState(pos.up(2)).isAir()
                    && !world.getBlockState(pos.down()).isAir()) {
                return pos;
            }
        }

        return startPos;
    }
}
MaikClient.java + ElephantEntityRenderer.java
package mls.maik.client;

import mls.maik.client.render.entity.ElephantEntityRenderer;
import mls.maik.client.screen.MyScreen;
import mls.maik.entity.ModEntities;
import mls.maik.network.ModNetworking;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry;
import net.minecraft.client.MinecraftClient;

public class MaikClient implements ClientModInitializer {

    @Override
    public void onInitializeClient() {
        EntityRendererRegistry.register(ModEntities.ELEPHANT, ElephantEntityRenderer::new);

        ClientPlayNetworking.registerGlobalReceiver(ModNetworking.OPEN_GUI_PACKET,
                (client, handler, buf, responseSender) -> {

                    client.execute(() -> {
                        MinecraftClient.getInstance().setScreen(new MyScreen());
                    });

                });
    }
}
package mls.maik.client.render.entity;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import mls.maik.Maik;
import mls.maik.entity.custom.ElephantEntity;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.render.OverlayTexture;
import net.minecraft.client.render.RenderLayer;
import net.minecraft.client.render.VertexConsumer;
import net.minecraft.client.render.VertexConsumerProvider;
import net.minecraft.client.render.entity.EntityRenderer;
import net.minecraft.client.render.entity.EntityRendererFactory;
import net.minecraft.client.util.math.MatrixStack;
import net.minecraft.resource.Resource;
import net.minecraft.util.Identifier;
import net.minecraft.util.math.MathHelper;
import net.minecraft.util.math.RotationAxis;

import java.io.BufferedReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;

public class ElephantEntityRenderer extends EntityRenderer<ElephantEntity> {
    private static final Identifier MODEL_ID = new Identifier(Maik.MOD_ID, "models/entity/elephant.bbmodel");
    private static final Identifier FALLBACK_TEXTURE = new Identifier(Maik.MOD_ID, "textures/entity/elephant/koeper.png");
    private static final float PIXEL = 1.0F / 16.0F;
    private static final float MODEL_SCALE = 2.05F;
    private static final float[] MODEL_CENTER = {4.5F, 0.0F, 4.5F};
    private BlockbenchModel model;

    public ElephantEntityRenderer(EntityRendererFactory.Context context) {
        super(context);
        this.shadowRadius = 0.9F;
    }

    @Override
    public void render(ElephantEntity entity, float yaw, float tickDelta, MatrixStack matrices,
                       VertexConsumerProvider vertexConsumers, int light) {
        BlockbenchModel elephantModel = getModel();
        float spawnProgress = getSpawnProgress(entity, tickDelta);
        float birthScale = 0.18F + 0.82F * spawnProgress;

        matrices.push();
        matrices.translate(0.0D, -0.85D * (1.0F - spawnProgress), 0.0D);
        matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(180.0F - yaw));
        matrices.scale(MODEL_SCALE * birthScale, MODEL_SCALE * birthScale, MODEL_SCALE * birthScale);
        matrices.translate(-MODEL_CENTER[0] * PIXEL, 0.0F, -MODEL_CENTER[2] * PIXEL);

        elephantModel.render(entity, tickDelta, spawnProgress, matrices, vertexConsumers, light);
        matrices.pop();

        super.render(entity, yaw, tickDelta, matrices, vertexConsumers, light);
    }

    @Override
    public Identifier getTexture(ElephantEntity entity) {
        return FALLBACK_TEXTURE;
    }

    private BlockbenchModel getModel() {
        if (this.model == null) {
            this.model = BlockbenchModel.load();
        }

        return this.model;
    }

    private static float getSpawnProgress(ElephantEntity entity, float tickDelta) {
        float raw = MathHelper.clamp((entity.age + tickDelta) / ElephantEntity.SPAWN_ANIMATION_TICKS, 0.0F, 1.0F);
        return raw * raw * (3.0F - 2.0F * raw);
    }

    private static final class BlockbenchModel {
        private final List<Cube> cubes;
        private final TextureRef[] textures;
        private final float uvWidth;
        private final float uvHeight;

        private BlockbenchModel(List<Cube> cubes, TextureRef[] textures, float uvWidth, float uvHeight) {
            this.cubes = cubes;
            this.textures = textures;
            this.uvWidth = uvWidth;
            this.uvHeight = uvHeight;
        }

        private static BlockbenchModel load() {
            try {
                Optional<Resource> resource = MinecraftClient.getInstance().getResourceManager().getResource(MODEL_ID);
                if (resource.isEmpty()) {
                    Maik.LOGGER.error("Missing elephant model resource: {}", MODEL_ID);
                    return empty();
                }

                try (BufferedReader reader = resource.get().getReader()) {
                    JsonObject root = JsonParser.parseReader(reader).getAsJsonObject();
                    float uvWidth = getResolution(root, "width", 32.0F);
                    float uvHeight = getResolution(root, "height", 32.0F);
                    TextureRef[] textures = parseTextures(root);
                    Map<String, JsonObject> elements = objectsByUuid(root.getAsJsonArray("elements"));
                    Map<String, JsonObject> groups = objectsByUuid(root.getAsJsonArray("groups"));
                    List<Cube> cubes = new ArrayList<>();

                    JsonArray outliner = root.getAsJsonArray("outliner");
                    if (outliner != null) {
                        for (JsonElement entry : outliner) {
                            collectCubes(entry, "root", elements, groups, cubes);
                        }
                    }

                    return new BlockbenchModel(cubes, textures, uvWidth, uvHeight);
                }
            } catch (IOException | IllegalStateException exception) {
                Maik.LOGGER.error("Could not load elephant model", exception);
                return empty();
            }
        }

        private static BlockbenchModel empty() {
            return new BlockbenchModel(List.of(), new TextureRef[]{new TextureRef(FALLBACK_TEXTURE)}, 32.0F, 32.0F);
        }

        private void render(ElephantEntity entity, float tickDelta, float spawnProgress, MatrixStack matrices,
                            VertexConsumerProvider vertexConsumers, int light) {
            float age = entity.age + tickDelta;
            float limbAngle = entity.limbAnimator.getPos(tickDelta);
            float limbDistance = MathHelper.clamp(entity.limbAnimator.getSpeed(tickDelta), 0.0F, 1.0F);
            float headYaw = MathHelper.wrapDegrees(
                    MathHelper.lerp(tickDelta, entity.prevHeadYaw, entity.headYaw)
                            - MathHelper.lerp(tickDelta, entity.prevBodyYaw, entity.bodyYaw)
            );
            float headPitch = MathHelper.lerp(tickDelta, entity.prevPitch, entity.getPitch());

            for (Cube cube : this.cubes) {
                matrices.push();
                applyAnimatedTransform(cube, matrices, age, limbAngle, limbDistance, headYaw, headPitch, spawnProgress);
                applyRotation(matrices, cube.origin(), cube.rotation()[0], cube.rotation()[1], cube.rotation()[2]);
                renderCube(cube, matrices, vertexConsumers, light);
                matrices.pop();
            }
        }

        private void renderCube(Cube cube, MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light) {
            float x1 = cube.from()[0] * PIXEL;
            float y1 = cube.from()[1] * PIXEL;
            float z1 = cube.from()[2] * PIXEL;
            float x2 = cube.to()[0] * PIXEL;
            float y2 = cube.to()[1] * PIXEL;
            float z2 = cube.to()[2] * PIXEL;

            for (Map.Entry<CubeFace, Face> faceEntry : cube.faces().entrySet()) {
                Face face = faceEntry.getValue();
                if (face.textureIndex() < 0 || face.textureIndex() >= this.textures.length) {
                    continue;
                }

                VertexConsumer vertices = vertexConsumers.getBuffer(RenderLayer.getEntityCutoutNoCull(this.textures[face.textureIndex()].id()));
                emitFace(vertices, matrices, light, faceEntry.getKey(), face, x1, y1, z1, x2, y2, z2);
            }
        }

        private void emitFace(VertexConsumer vertices, MatrixStack matrices, int light, CubeFace cubeFace, Face face,
                              float x1, float y1, float z1, float x2, float y2, float z2) {
            float u0 = face.uv()[0] / this.uvWidth;
            float v0 = face.uv()[1] / this.uvHeight;
            float u1 = face.uv()[2] / this.uvWidth;
            float v1 = face.uv()[3] / this.uvHeight;

            switch (cubeFace) {
                case NORTH -> {
                    vertex(vertices, matrices, x1, y1, z1, u0, v1, 0.0F, 0.0F, -1.0F, light);
                    vertex(vertices, matrices, x1, y2, z1, u0, v0, 0.0F, 0.0F, -1.0F, light);
                    vertex(vertices, matrices, x2, y2, z1, u1, v0, 0.0F, 0.0F, -1.0F, light);
                    vertex(vertices, matrices, x2, y1, z1, u1, v1, 0.0F, 0.0F, -1.0F, light);
                }
                case SOUTH -> {
                    vertex(vertices, matrices, x2, y1, z2, u0, v1, 0.0F, 0.0F, 1.0F, light);
                    vertex(vertices, matrices, x2, y2, z2, u0, v0, 0.0F, 0.0F, 1.0F, light);
                    vertex(vertices, matrices, x1, y2, z2, u1, v0, 0.0F, 0.0F, 1.0F, light);
                    vertex(vertices, matrices, x1, y1, z2, u1, v1, 0.0F, 0.0F, 1.0F, light);
                }
                case EAST -> {
                    vertex(vertices, matrices, x2, y1, z1, u0, v1, 1.0F, 0.0F, 0.0F, light);
                    vertex(vertices, matrices, x2, y2, z1, u0, v0, 1.0F, 0.0F, 0.0F, light);
                    vertex(vertices, matrices, x2, y2, z2, u1, v0, 1.0F, 0.0F, 0.0F, light);
                    vertex(vertices, matrices, x2, y1, z2, u1, v1, 1.0F, 0.0F, 0.0F, light);
                }
                case WEST -> {
                    vertex(vertices, matrices, x1, y1, z2, u0, v1, -1.0F, 0.0F, 0.0F, light);
                    vertex(vertices, matrices, x1, y2, z2, u0, v0, -1.0F, 0.0F, 0.0F, light);
                    vertex(vertices, matrices, x1, y2, z1, u1, v0, -1.0F, 0.0F, 0.0F, light);
                    vertex(vertices, matrices, x1, y1, z1, u1, v1, -1.0F, 0.0F, 0.0F, light);
                }
                case UP -> {
                    vertex(vertices, matrices, x1, y2, z1, u0, v1, 0.0F, 1.0F, 0.0F, light);
                    vertex(vertices, matrices, x1, y2, z2, u0, v0, 0.0F, 1.0F, 0.0F, light);
                    vertex(vertices, matrices, x2, y2, z2, u1, v0, 0.0F, 1.0F, 0.0F, light);
                    vertex(vertices, matrices, x2, y2, z1, u1, v1, 0.0F, 1.0F, 0.0F, light);
                }
                case DOWN -> {
                    vertex(vertices, matrices, x1, y1, z2, u0, v1, 0.0F, -1.0F, 0.0F, light);
                    vertex(vertices, matrices, x1, y1, z1, u0, v0, 0.0F, -1.0F, 0.0F, light);
                    vertex(vertices, matrices, x2, y1, z1, u1, v0, 0.0F, -1.0F, 0.0F, light);
                    vertex(vertices, matrices, x2, y1, z2, u1, v1, 0.0F, -1.0F, 0.0F, light);
                }
            }
        }

        private static void vertex(VertexConsumer vertices, MatrixStack matrices, float x, float y, float z,
                                   float u, float v, float normalX, float normalY, float normalZ, int light) {
            MatrixStack.Entry entry = matrices.peek();
            vertices.vertex(entry.getPositionMatrix(), x, y, z)
                    .color(255, 255, 255, 255)
                    .texture(u, v)
                    .overlay(OverlayTexture.DEFAULT_UV)
                    .light(light)
                    .normal(entry.getNormalMatrix(), normalX, normalY, normalZ)
                    .next();
        }

        private static void applyAnimatedTransform(Cube cube, MatrixStack matrices, float age, float limbAngle,
                                                   float limbDistance, float headYaw, float headPitch,
                                                   float spawnProgress) {
            String group = cube.groupName();
            float spawnKick = 1.0F - spawnProgress;

            if (isHeadGroup(group)) {
                float idleNod = MathHelper.sin(age * 0.08F) * 1.2F;
                applyRotation(matrices, cube.origin(), headPitch * 0.18F + idleNod, headYaw * 0.28F, 0.0F);
            }

            if ("Bein".equals(group)) {
                float frontBackPhase = cube.from()[2] < 8.0F ? 0.0F : (float) Math.PI;
                float sidePhase = cube.from()[0] < 8.0F ? 0.0F : (float) Math.PI;
                float walk = MathHelper.cos(limbAngle * 0.72F + frontBackPhase + sidePhase) * limbDistance * 18.0F;
                applyRotation(matrices, cube.origin(), walk, 0.0F, 0.0F);
            } else if ("Nase".equals(group)) {
                float sway = MathHelper.sin(age * 0.15F) * 4.5F + spawnKick * 18.0F;
                applyRotation(matrices, cube.origin(), sway, 0.0F, 0.0F);
            } else if ("OhrL".equals(group)) {
                float flap = 9.0F + MathHelper.sin(age * 0.19F) * 6.0F + spawnKick * 30.0F;
                applyRotation(matrices, cube.origin(), 0.0F, 0.0F, flap);
            } else if ("OhrR".equals(group)) {
                float flap = -9.0F - MathHelper.sin(age * 0.19F) * 6.0F - spawnKick * 30.0F;
                applyRotation(matrices, cube.origin(), 0.0F, 0.0F, flap);
            } else if ("Schwanz".equals(group)) {
                float tail = MathHelper.sin(age * 0.13F) * 10.0F;
                applyRotation(matrices, cube.origin(), 0.0F, tail, 0.0F);
            } else if ("Koeper".equals(group)) {
                float breathing = MathHelper.sin(age * 0.07F) * 0.01F;
                matrices.translate(0.0F, breathing, 0.0F);
            }
        }

        private static boolean isHeadGroup(String group) {
            return "Kopf".equals(group) || "Nase".equals(group) || "Zaehne".equals(group)
                    || "OhrL".equals(group) || "OhrR".equals(group);
        }

        private static void applyRotation(MatrixStack matrices, float[] origin, float xDegrees, float yDegrees, float zDegrees) {
            if (xDegrees == 0.0F && yDegrees == 0.0F && zDegrees == 0.0F) {
                return;
            }

            matrices.translate(origin[0] * PIXEL, origin[1] * PIXEL, origin[2] * PIXEL);
            if (xDegrees != 0.0F) {
                matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(xDegrees));
            }
            if (yDegrees != 0.0F) {
                matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(yDegrees));
            }
            if (zDegrees != 0.0F) {
                matrices.multiply(RotationAxis.POSITIVE_Z.rotationDegrees(zDegrees));
            }
            matrices.translate(-origin[0] * PIXEL, -origin[1] * PIXEL, -origin[2] * PIXEL);
        }

        private static TextureRef[] parseTextures(JsonObject root) {
            JsonArray textureArray = root.getAsJsonArray("textures");
            if (textureArray == null || textureArray.isEmpty()) {
                return new TextureRef[]{new TextureRef(FALLBACK_TEXTURE)};
            }

            TextureRef[] textureRefs = new TextureRef[textureArray.size()];
            for (int i = 0; i < textureArray.size(); i++) {
                JsonObject texture = textureArray.get(i).getAsJsonObject();
                String name = texture.get("name").getAsString();
                String fileName = name.substring(0, name.lastIndexOf('.')).toLowerCase(Locale.ROOT);
                textureRefs[i] = new TextureRef(new Identifier(Maik.MOD_ID, "textures/entity/elephant/" + fileName + ".png"));
            }

            return textureRefs;
        }

        private static Map<String, JsonObject> objectsByUuid(JsonArray array) {
            Map<String, JsonObject> result = new HashMap<>();
            if (array == null) {
                return result;
            }

            for (JsonElement element : array) {
                JsonObject object = element.getAsJsonObject();
                if (object.has("uuid")) {
                    result.put(object.get("uuid").getAsString(), object);
                }
            }

            return result;
        }

        private static void collectCubes(JsonElement outlinerEntry, String currentGroup,
                                         Map<String, JsonObject> elements, Map<String, JsonObject> groups,
                                         List<Cube> cubes) {
            if (outlinerEntry.isJsonPrimitive()) {
                JsonObject element = elements.get(outlinerEntry.getAsString());
                if (element != null) {
                    cubes.add(parseCube(element, currentGroup));
                }
                return;
            }

            JsonObject groupEntry = outlinerEntry.getAsJsonObject();
            String uuid = groupEntry.get("uuid").getAsString();
            JsonObject group = groups.get(uuid);
            String groupName = group != null && group.has("name") ? group.get("name").getAsString() : currentGroup;
            JsonArray children = groupEntry.getAsJsonArray("children");
            if (children == null) {
                return;
            }

            for (JsonElement child : children) {
                collectCubes(child, groupName, elements, groups, cubes);
            }
        }

        private static Cube parseCube(JsonObject element, String groupName) {
            EnumMap<CubeFace, Face> faces = new EnumMap<>(CubeFace.class);
            JsonObject faceObject = element.getAsJsonObject("faces");
            if (faceObject != null) {
                for (CubeFace cubeFace : CubeFace.values()) {
                    JsonElement faceElement = faceObject.get(cubeFace.jsonName());
                    if (faceElement == null || !faceElement.isJsonObject()) {
                        continue;
                    }

                    JsonObject face = faceElement.getAsJsonObject();
                    if (!face.has("texture")) {
                        continue;
                    }

                    faces.put(cubeFace, new Face(face.get("texture").getAsInt(), vec(face.getAsJsonArray("uv"), 4)));
                }
            }

            return new Cube(
                    element.has("name") ? element.get("name").getAsString() : "cube",
                    groupName,
                    vec(element.getAsJsonArray("from"), 3),
                    vec(element.getAsJsonArray("to"), 3),
                    vec(element.getAsJsonArray("origin"), 3),
                    vec(element.getAsJsonArray("rotation"), 3),
                    faces
            );
        }

        private static float[] vec(JsonArray array, int expectedSize) {
            float[] values = new float[expectedSize];
            if (array == null) {
                return values;
            }

            for (int i = 0; i < expectedSize && i < array.size(); i++) {
                values[i] = array.get(i).getAsFloat();
            }

            return values;
        }

        private static float getResolution(JsonObject root, String key, float fallback) {
            if (!root.has("resolution")) {
                return fallback;
            }

            JsonObject resolution = root.getAsJsonObject("resolution");
            return resolution.has(key) ? resolution.get(key).getAsFloat() : fallback;
        }
    }

    private enum CubeFace {
        NORTH("north"),
        EAST("east"),
        SOUTH("south"),
        WEST("west"),
        UP("up"),
        DOWN("down");

        private final String jsonName;

        CubeFace(String jsonName) {
            this.jsonName = jsonName;
        }

        private String jsonName() {
            return this.jsonName;
        }
    }

    private record TextureRef(Identifier id) {
    }

    private record Face(int textureIndex, float[] uv) {
    }

    private record Cube(String name, String groupName, float[] from, float[] to, float[] origin, float[] rotation,
                        EnumMap<CubeFace, Face> faces) {
    }
}
zh_cn.json + en_us.json
{
  "entity.maik.elephant": "大象",
  "item.maik.elephant_spawn_egg": "大象刷怪蛋",
  "item.maik.mynewaxe": "MyNewAxe"
}
{
  "entity.maik.elephant": "Elephant",
  "item.maik.elephant_spawn_egg": "Elephant Spawn Egg",
  "item.maik.mynewaxe": "MyNewAxe"
}

为什么渲染器这么长?

因为这个 bbmodel 不是一张简单贴图,而是多张贴图、逐面 UV、多个身体部件组合出来的模型。渲染器要负责读取模型文件、解析方块、选择贴图、发出顶点,并在绘制前给不同 group 加旋转动画。课堂上可以先讲服务端实体,再把渲染器拆成“读取数据、绘制方块、添加动画”三段理解。

Animation logic

动画设计:没有动画轨道,也能让大象有生命感。

我们给大象设计了四类动作。第一类是出生动画:刚生成的 42 tick 内,大象从地面升起,并且脚边出现 `POOF` 粒子。第二类是行走动画:四条腿根据 `limbAnimator` 交替摆动。第三类是闲置动画:耳朵轻轻扇动、鼻子上下摆、尾巴左右摇。第四类是看向玩家:头部根据 `headYaw` 和 `pitch` 做轻微转动。

  • 出生动画:`SPAWN_ANIMATION_TICKS = 42`,用进度控制位移和缩放。
  • 腿部动画:根据腿在模型里的前后左右位置,给不同相位。
  • 耳朵动画:左耳和右耳方向相反,出生时扇得更明显。
  • 鼻子动画:用正弦函数做轻微摆动,生成时幅度更大。
大象生物实现流程图局部,用于解释动画步骤
动画不一定非要来自 Blockbench。只要知道每个部件属于哪个 group,就可以在渲染前对它做矩阵旋转。

Debug board

常见问题排查。

看不到大象

检查实体和渲染注册

服务端要注册 `ModEntities.ELEPHANT`,客户端要在 `MaikClient` 中注册 `ElephantEntityRenderer::new`。

模型是紫黑色

检查贴图路径

贴图必须在 `assets/maik/textures/entity/elephant/`,文件名要和 bbmodel 中的贴图名小写对应。

生成后卡住

检查碰撞箱和出生位置

大象比小鸡高很多,安全位置需要检测 `pos`、`pos.up()` 和 `pos.up(2)` 都为空气。

动作太夸张

降低角度数值

耳朵、鼻子、腿部动画都是角度控制,把 18、30 这类数字调小,动作就会更温和。

刷怪蛋没出现

检查创造物品栏

`ItemGroupEvents.modifyEntriesEvent(ItemGroups.SPAWN_EGGS)` 负责把大象刷怪蛋加入栏目。

编译失败

先读第一条报错

Fabric/Yarn 版本不同会导致类名变化,本教程对应的是项目里的 Fabric 1.20 和 Yarn `1.20+build.1`。

Classroom tasks

给学生的扩展任务。

完成基础大象以后,可以把任务分成三档。第一档只改数字,第二档改行为,第三档改渲染和动画。这样不同进度的学生都有可完成的目标。

  • 入门:把大象血量改成 80,移动速度改成 0.12,让它更像慢慢走的大型动物。
  • 进阶:让大象喜欢苹果、甘蔗和西瓜,学生自己查 `Items.MELON_SLICE`。
  • 挑战:把出生动画改成“从空中缓慢落下”,并加入云朵粒子。
  • 创意:复制一份实体,做成小象,碰撞箱和模型缩放都变小。
大象教程思维导图,用于课堂扩展任务复习
扩展时仍然沿着同一张图思考:实体属性、AI、渲染、动画、资源路径分别改哪里。

What students learn

这节课真正学习的是“把美术资源变成游戏对象”的完整链路。

资源意识

模型、贴图、语言文件、Java 类都必须放在正确路径,游戏才能找到。

客户端/服务端意识

实体注册和 AI 在服务端,渲染和动画在客户端,这是 MOD 开发的核心分界线。

调试意识

先编译,再检查资源入包,最后进游戏验证。遇到问题按模块排查,而不是乱改。