Skip to content
On this page

实体教程 ​

此教程将会带你从零开始构建一个简单的自定义动画实体。

总览 ​

创建自定义实体需要四个部分:

部分位置格式
模型BlockBench 导出 → .javaEntityModel 子类
动画BlockBench 导出 → .javaAnimationDefinition 定义
实体类脚本中Monster 子类 + AnimationState 字段
纹理资源包.png 文件

BlockBench 创建模型 ​

  1. 在 BlockBench 中设计模型
  2. 文件 → 导出 → 导出 Java 实体模型
  3. 选 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 必须和实体注册时一致。

导出动画 ​

  1. BlockBench 切换到 动画 模式
  2. 创建动画(idle、walk、attack 等)
  3. 导出 → 导出 Java 动画
  4. 保存为 .java 文件。
  5. 修正 Java 导出的语法问题。

CAUTION

骨骼名不匹配可能是常见的渲染崩溃原因。动画里引用的骨骼名如果模型里不存在(如 BlockBench 定位器导出的 "item_display"),bake() 会抛异常。要么在模型里加空占位骨骼,要么从动画文件删掉对应的 channel。

实体类 ​

kotlin
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 共享数据。

kotlin
// 实体侧(服务端或客户端脚本):
KattonBridge["anim:${entity.id}:idle"] = idleAnimationState

// 渲染侧(registerAnimatedEntityRenderer 自动处理):
val state = KattonBridge["anim:${entityId}:idle"] as? AnimationState

注册实体(服务端 + 客户端) ​

kotlin
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 能快速注册模型层、渲染器和动画:

kotlin
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 回调:

kotlin
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 ​

"我看不到实体" ​

  1. 客户端脚本没有正确触发 — 试试 /katton reload(或重启等自动触发)
  2. ClassLoader crash — 用 registerAnimatedEntityRenderer 自动规避;手写渲染器用 LivingEntity 而非实体类做泛型
  3. 纹理缺失 — 检查 assets/<ns>/textures/entity/<name>.png
  4. 模型层 namespace 不匹配

"动画乱七八糟" ​

  1. 缺 resetPose() — 每次应用动画前都要调用 model.resetPose()。
  2. 叠加动画 — 每帧只应用一个动画。同时播放 idle 和 walk 会导致变换累积。
  3. bake() 静默失败 — 如果动画引用了模型中不存在的骨骼,bake() 会抛异常。用 runCatching 捕获它。

"重载崩溃" ​

  1. 删 <游戏目录>/.katton/compiled-script-cache/ 缓存
  2. 重载后杀旧实体重生

"动画不播放" ​

  1. AnimationState 没调用 start()
  2. Bridge key 不一致

完整示例 ​

kotlin
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)
    )
}