模型检查
确认文件结构、贴图数量、方块数量和是否存在动画轨道。
Fabric 1.20 custom creature lesson
这节课根据一次真实实现过程整理:先研究 `Elefant.bbmodel`,再把贴图、实体注册、AI、刷怪蛋、客户端渲染器和生成动画一步步接进 Fabric 1.20 项目。学生学完以后,不只是会复制一只大象,也会明白“服务端负责生物,客户端负责看起来怎么动”。
Research recap
拿到 `Elefant.bbmodel` 后,第一步不是立刻写实体类,而是先看它到底是什么格式。检查结果很关键:它是 Blockbench 的 `java_block` 模型,里面有 24 个方块、7 张嵌入 PNG 贴图,但是没有自带动画轨道。
这意味着课程不能简单地“播放模型动画”。我们采用的路线是:服务端做一个真正的大象实体,客户端写一个自定义渲染器读取 bbmodel,再用 Java 代码给耳朵、鼻子、腿和出生过程补动画。
确认文件结构、贴图数量、方块数量和是否存在动画轨道。
不加 GeckoLib,改用 Fabric 原生实体 + 自定义渲染器。
进世界自动生成大象,也可以用“大象刷怪蛋”手动测试。
Mind map
课堂提醒:学生最容易把“实体逻辑”和“模型渲染”混在一起。可以反复强调:服务器知道大象在哪里、会不会走;客户端负责把它画成什么样、耳朵怎么动。
Build flow
File map
注册 `maik:elephant`,设置碰撞箱、跟踪距离和默认属性。
src/main/java/mls/maik/entity/ModEntities.java
设置血量、速度、AI 目标、繁殖食物和出生粒子。
src/main/java/mls/maik/entity/custom/ElephantEntity.java
读取 bbmodel,按贴图和 UV 画出模型,再给部件加动画。
src/client/java/mls/maik/client/render/entity/ElephantEntityRenderer.java
玩家进入世界后,在前方安全位置生成一只大象。
src/main/java/mls/maik/event/ModPlayerEvents.java
新增“大象刷怪蛋”,加入创造模式刷怪蛋栏目。
src/main/java/mls/maik/item/ModItems.java
保留原始模型文件,并把嵌入贴图解出到实体贴图目录。
assets/maik/models/entity/elephant.bbmodel
Complete code
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);
}
}
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
);
}
}
}
}
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;
}
}
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) {
}
}
{
"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` 做轻微转动。
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
完成基础大象以后,可以把任务分成三档。第一档只改数字,第二档改行为,第三档改渲染和动画。这样不同进度的学生都有可完成的目标。
What students learn
模型、贴图、语言文件、Java 类都必须放在正确路径,游戏才能找到。
实体注册和 AI 在服务端,渲染和动画在客户端,这是 MOD 开发的核心分界线。
先编译,再检查资源入包,最后进游戏验证。遇到问题按模块排查,而不是乱改。