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(如 ModelPartModelLayerLocationLayerDefinition),以及修正一些语法问题)。大部分在 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)
    )
}