实体教程
此教程将会带你从零开始构建一个简单的自定义动画实体。
总览
创建自定义实体需要四个部分:
| 部分 | 位置 | 格式 |
|---|---|---|
| 模型 | BlockBench 导出 → .java | EntityModel 子类 |
| 动画 | BlockBench 导出 → .java | AnimationDefinition 定义 |
| 实体类 | 脚本中 | Monster 子类 + AnimationState 字段 |
| 纹理 | 资源包 | .png 文件 |
BlockBench 创建模型
- 在 BlockBench 中设计模型
- 文件 → 导出 → 导出 Java 实体模型
- 选 Mojang mappings,Minecraft 版本 1.19+
TIP
导入到项目中的Java代码可能需要手动补一些 import(如 ModelPart、ModelLayerLocation、LayerDefinition),以及修正一些语法问题)。大部分在 IDEA 中可以通过 Alt+Enter 快速修复。
TIP
Katton 中的 Java
Katton 可以编译脚本文件夹中的 .kt 和 .java 文件。这意味着你可以用 Kotlin 写主逻辑,同时保留 Java 文件(例如 BlockBench 导出,或某些任务你更喜欢用 Java)。Katton 会先通过 Java 编译器编译所有 java 文件,然后用 Kotlin 编译器编译 Kotlin 文件,且 Kotlin 可以访问已编译的 Java 类。也就是说 Kotlin 可以引用 Java 类,但反过来不行。
IMPORTANT
导出文件的 LAYER_LOCATION 里默认 "modid" 要改成你的实际命名空间(如 "test")。这个 ID 必须和实体注册时一致。
导出动画
- BlockBench 切换到 动画 模式
- 创建动画(idle、walk、attack 等)
- 导出 → 导出 Java 动画
- 保存为
.java文件。 - 修正 Java 导出的语法问题。
CAUTION
骨骼名不匹配可能是常见的渲染崩溃原因。动画里引用的骨骼名如果模型里不存在(如 BlockBench 定位器导出的 "item_display"),bake() 会抛异常。要么在模型里加空占位骨骼,要么从动画文件删掉对应的 channel。
实体类
class Zombie1Entity(type: EntityType<out Monster>, level: Level) : Monster(type, level) {
val idle = AnimationState()
val walk = AnimationState()
init { idle.start(tickCount) }
override fun tick() {
super.tick()
if (level().isClientSide) {
// 通过跨 ClassLoader 桥发布动画状态
KattonBridge["anim:${id}:idle"] = idle
KattonBridge["anim:${id}:walk"] = walk
if (deltaMovement.horizontalDistanceSqr() > 1.0e-7) {
walk.startIfStopped(tickCount); idle.stop()
} else {
idle.startIfStopped(tickCount); walk.stop()
}
}
}
}CAUTION
关键:ClassLoader 隔离问题
Katton 用不同的 ClassLoader 编译服务端和客户端脚本。这意味着:
- 不能跨端做类型转换(cast),会导致
ClassCastException。 - 脚本类里的静态字段 ClassLoader 隔离
解决方案:用 KattonBridge 共享数据。
// 实体侧(服务端或客户端脚本):
KattonBridge["anim:${entity.id}:idle"] = idleAnimationState
// 渲染侧(registerAnimatedEntityRenderer 自动处理):
val state = KattonBridge["anim:${entityId}:idle"] as? AnimationState注册实体(服务端 + 客户端)
import top.katton.api.ClientPhase
import top.katton.api.ServerPhase
@ServerScriptEntrypoint(ServerPhase.READY)
@ClientScriptEntrypoint(ClientPhase.REGISTRY_SETUP)
fun initZombie() {
registerNativeEntity("test:zombie1", RegisterMode.RELOADABLE,
configure = {
dimensions(0.6f, 1.95f); maxHealth(20.0); movementSpeed(0.23)
attackDamage(3.0); withSpawnEgg(); followRange(64.0)
}
) { props ->
EntityType.Builder.of(::Zombie1Entity, MobCategory.MONSTER)
.sized(props.dimensions.width, props.dimensions.height)
.build(ResourceKey.create(Registries.ENTITY_TYPE, props.id))
}
}注册渲染器 + 动画(客户端)
使用 registerAnimatedEntityRenderer 能快速注册模型层、渲染器和动画:
import top.katton.api.ClientPhase
@ClientScriptEntrypoint(ClientPhase.REGISTRY_SETUP)
fun initZombieRenderer() {
registerAnimatedEntityRenderer<LivingEntityRenderState, Zombie1Model<LivingEntityRenderState>>(
entityTypeId = "test:zombie1",
modelLayer = Zombie1Model.LAYER_LOCATION,
bodyLayer = { Zombie1Model.createBodyLayer() },
modelFactory = { root -> Zombie1Model(root) },
texture = id("test", "textures/entity/zombie1.png"),
animations = mapOf(
"idle" to Zombie1Animation.idle,
"walk" to Zombie1Animation.walkforward
)
)
}NOTE
animations 映射非常灵活,你可以向其中新增任何动画。默认逻辑会在实体移动时播放 "walk",静止时播放 "idle"。要自定义,传入 animate 回调:
animations = mapOf("idle" to idleDef, "walk" to walkDef, "attack" to attackDef),
animate = { model, entity, state, baked ->
model.resetPose()
// 在这里写你的自定义动画逻辑...
val state = KattonBridge["anim:${entity.id}:attack"] as? AnimationState
baked["attack"]?.apply(state, state.ageInTicks)
}纹理
纹理路径在注册时指定:id("test", "textures/entity/zombie1.png") ——对应资源包里的 assets/test/textures/entity/zombie1.png。
MC 26.1 动画 API 变化
| 旧 API (1.21.11) | 新 API (26.1) |
|---|---|
EntityModel.animate(state, def, age) | def.bake(model.root()).apply(animState, age) |
AnimationDefinition 直接用 | 必须先 bake() 成 KeyframeAnimation |
模型上调用 animate() | KeyframeAnimation 上调用 apply() |
每帧必调 model.resetPose() ——否则动画变换累积导致模型混乱。
常见 Q&A
"我看不到实体"
- 客户端脚本没有正确触发 — 试试
/katton reload(或重启等自动触发) - ClassLoader crash — 用
registerAnimatedEntityRenderer自动规避;手写渲染器用LivingEntity而非实体类做泛型 - 纹理缺失 — 检查
assets/<ns>/textures/entity/<name>.png - 模型层 namespace 不匹配
"动画乱七八糟"
- 缺
resetPose()— 每次应用动画前都要调用model.resetPose()。 - 叠加动画 — 每帧只应用一个动画。同时播放 idle 和 walk 会导致变换累积。
bake()静默失败 — 如果动画引用了模型中不存在的骨骼,bake()会抛异常。用runCatching捕获它。
"重载崩溃"
- 删
<游戏目录>/.katton/compiled-script-cache/缓存 - 重载后杀旧实体重生
"动画不播放"
AnimationState没调用start()- Bridge key 不一致
完整示例
package qwq
import model.Zombie1Animation
import model.Zombie1Model
import net.minecraft.client.renderer.entity.state.LivingEntityRenderState
import net.minecraft.core.registries.Registries
import net.minecraft.resources.ResourceKey
import net.minecraft.world.entity.AnimationState
import net.minecraft.world.entity.EntityType
import net.minecraft.world.entity.MobCategory
import net.minecraft.world.entity.monster.Monster
import net.minecraft.world.level.Level
import top.katton.api.ClientPhase
import top.katton.api.ClientScriptEntrypoint
import top.katton.api.ServerPhase
import top.katton.api.ServerScriptEntrypoint
import top.katton.api.registry.registerAnimatedEntityRenderer
import top.katton.api.registry.registerNativeEntity
import top.katton.bridge.KattonBridge
import top.katton.registry.RegisterMode
import top.katton.registry.id
class Zombie1Entity(type: EntityType<out Monster>, level: Level) : Monster(type, level) {
val idle = AnimationState()
val walk = AnimationState()
init { idle.start(tickCount) }
override fun tick() {
super.tick()
if (level().isClientSide) {
KattonBridge["anim:${id}:idle"] = idle
KattonBridge["anim:${id}:walk"] = walk
if (deltaMovement.horizontalDistanceSqr() > 1.0e-7) {
walk.startIfStopped(tickCount); idle.stop()
} else { idle.startIfStopped(tickCount); walk.stop() }
}
}
}
@ServerScriptEntrypoint(ServerPhase.READY)
@ClientScriptEntrypoint(ClientPhase.REGISTRY_SETUP)
fun initZombie() {
registerNativeEntity("test:zombie1", RegisterMode.RELOADABLE,
configure = { dimensions(0.6f, 1.95f); maxHealth(20.0); movementSpeed(0.23); attackDamage(3.0); withSpawnEgg(); followRange(64.0) }
) { p -> EntityType.Builder.of(::Zombie1Entity, MobCategory.MONSTER).sized(p.dimensions.width, p.dimensions.height).build(ResourceKey.create(Registries.ENTITY_TYPE, p.id)) }
}
@ClientScriptEntrypoint(ClientPhase.REGISTRY_SETUP)
fun initZombieRenderer() {
registerAnimatedEntityRenderer<LivingEntityRenderState, Zombie1Model<LivingEntityRenderState>>(
entityTypeId = "test:zombie1",
modelLayer = Zombie1Model.LAYER_LOCATION,
bodyLayer = { Zombie1Model.createBodyLayer() },
modelFactory = { root -> Zombie1Model(root) },
texture = id("test", "textures/entity/zombie1.png"),
animations = mapOf("idle" to Zombie1Animation.idle, "walk" to Zombie1Animation.walkforward)
)
}