# 探索实体的资源控制

在本节中,我们将一起学习实体的客户端相关的各种文件、概念及其编写方式。

# 什么是实体定义文件

每个实体都需要通过“定义”才能被注册到游戏中供我们游玩,实体定义文件便是供开发者们在自定义实体过程中进行“定义”操作的一个JSON文件。如果你已经阅读了前面的章节,你便可以意识到实体定义文件是成对出现的,在资源包中存在一个客户端定义文件,而服务端中则有一个服务端定义文件。客户端定义文件中不存在实体的组件,所以所有的组件(代表着实体的各种行为)都位于实体的服务端定义中。而实体的客户端定义文件主要用于注册实体的各种资源控制Resource Control)功能,我们这里着重介绍实体的客户端定义文件

我们一起来看我们上两节中制作的鸭子实体的客户端定义文件。为了更好地说明文件中的功能,我们补充了一些空字段。

{
  "format_version": "1.10.0", // 格式版本
  "minecraft:client_entity": {
    "description": {
      "identifier": "tutorial_demo:teal", // 标识符
      "min_engine_version": "1.12.0", // 最低引擎版本
      "materials": {
        "default": "chicken",
        "legs": "chicken_legs"
      }, // 材质
      "textures": {
        "default": "textures/entity/teal"
      }, // 纹理
      "geometry": {
        "default": "geometry.teal"
      }, // 几何
      "animations": {
        "move": "animation.teal.move",
        "general": "animation.teal.general",
        "look_at_target": "animation.common.look_at_target",
        "baby_transform": "animation.teal.baby_transform"
      }, // 动画和动画控制器
      "scripts": {
        "animate": [
          "general",
          {
            "move": "query.modified_move_speed"
          },
          "look_at_target",
          {
            "baby_transform": "query.is_baby"
          }
        ]
      }, // 脚本
      "render_controllers": ["controller.render.chicken"], // 渲染控制器
      "particle_effects": {
      }, // 粒子效果
      "sound_effects": {
      }, // 音效
      "spawn_egg": {
        "base_color": "#62c287",
        "overlay_color": "#87692b"
      } // 刷怪蛋
    }
  }
}

我们来依次研习客户端实体定义中的各个部分。首先,我们要明确一点,客户端实体定义文件是用于控制该实体的资源Resource)的文件。这里的资源指的是广义的资源,即指代包括了纹理、模型、粒子、音效、动画在内的所有资源包内容。实体定义文件便是“统筹”这些内容的“司令官”。

  • format_version:这是该实体文件的格式版本Format Version)。一个JSON文件的格式版本往往是用于决定该文件的JSON架构的一个字段。对于实体客户端定义文件来说,它的格式有1.8.01.10.0两种版本。我们这里建议使用1.10.0版本,因为它包含了更多的功能,比如更简单的条件控制动画执行。
  • minecraft:client_entity:客户端实体的模式标识符,客户端实体定义文件中必须使用该标识符作为键名。其下有且仅有description对象。

我们可以看到,description对象是是文件的主体,所有的客户端实体功能都定义于description对象下,该对象被称为实体客户端定义的描述Description)。接下来,我们便想一起来学习description对象的各个字段的功能。不过在此之前,我们需要引入一对概念:短名称Short Name)与完整名称Full Name)。

短名称(又称易记名称Friendly Name,或实体内部名称Entity Internal Name)是实体客户端定义中使用的一种“简称”或“别名”。由于我们需要在动画控制器、渲染控制器等资源控制文件中频繁地引用一些资源,比如动画、粒子或纹理、几何模型,所以为了避免繁复地写入冗长的完整名称(如赋命名空间标识符),我们采用短名称来称呼各种资源。我们把将每种资源的标识符写在实体定义文件中的过程称为资源在实体上的挂接Attach),因此每个完整名称在挂接到实体上时都会被赋予一个短名称,例如上述的纹理挂接"default": "textures/entity/teal"中,textures/entity/teal便是纹理的完整名称,对于纹理来说是完整的资源路径,而default便是短名称,是其他资源文件中将会用到的“快捷”名称。

下面我们依次介绍描述对象minecraft:client_entity/description中的字段。

  • identifier:字符串,该实体的赋命名空间标识符,格式为<namespace>:<identifier>,需要和行为包中的服务端实体中的标识符相同。
  • min_engine_version:可选,字符串,该定义文件的最低引擎版本。最低引擎版本决定了引擎如何解析该定义文件。这个“如何解析”和格式无关,指的是引擎识别文件中字段的方法,比如使用何种Molang表达式的语法词法来解析文件中的Molang表达式。同时,如果定义了该字段,引擎解析时还会自动和清单文件中的最低引擎版本相比较,只有最低引擎版本比清单文件中的最低引擎版本低的实体才会被成功定义。当出现多个同identifier的实体定义文件时,只有比清单文件中的最低引擎版本低且最接近它的实体会被成功解析并定义(即,取比清单文件中的最低引擎版本低的里面最高的那个来解析)。
  • materials:可选,对象,其中每个字段都是"short_name": "full_name"的格式,该实体上挂接的所有材质Material)。其中完整名称是materials/entity.material文件中定义的材质名。
  • textures:可选,对象,其中每个字段都是"short_name": "full/name"的格式,该实体上挂接的所有纹理Texture)。其中完整名称是纹理的相对于资源包根目录的相对路径(不带有纹理的扩展名)。
  • geometry:可选,对象,其中每个字段都是"short_name": "full.name"的格式,该实体上挂接的所有模型的几何Geometry)。其中完整名称是models文件夹中的文件里定义的标识符。
  • animations:可选,对象,其中每个字段都是"short_name": "full.name"的格式,该实体上挂接的所有动画Animation)和动画控制器Animation Controller)。其中完整名称是animationsanimation_controllers文件夹中的文件里定义的标识符。
  • scripts:可选,对象,该实体定义中的Molang表达式的伪脚本部分,可以用于定义该实体的实体变量、初始化表达式、预动画表达式和最重要的动画播放条件表达式。示例中的animate数组便是动画播放条件表达式数组,对animations中定义的每个动画或动画控制器设定了播放条件。
  • render_controllers:可选,数组,其中每个元素都是"full.name"的格式,该实体上挂接的所有渲染控制器Render Controller),其完整名称是render_controllers文件夹中的文件里定义的标识符。注意,渲染控制器只有完整名称的挂接,没有短名称的定义。
  • particle_effects:可选,对象,其中每个字段都是"short_name": "my_namespace:full_name"的格式,该实体上挂接的所有粒子Particle)效果。其中完整名称是particles文件夹中的文件里定义的标识符。
  • sound_effects:可选,对象,其中每个字段都是"short_name": "full.name"的格式,该实体上挂接的所有音效Sound Effect)。其中完整名称是sounds/sound_definitions.json文件中定义的声音事件名。
  • spawn_egg:可选,刷怪蛋图标或自定义颜色,不再赘述。

# 初步了解动画格式

我们先一起学习动画Animation)文件。动画其实就是一个用于使实体“动起来”的功能,一个动画文件中可以定义多个动画。动画的基础格式如下:

{
  "format_version": "1.8.0", // 该动画的格式版本
  "animations": {
    "animation.some_entity.anim1": {
            // ...
    }, // 第一个动画
    "animation.some_entity.anim2": {
            // ...
    }, // 第二个动画
    "animation.some_entity.anim3": {
            // ...
    } // 第三个动画
  }
}

我们可以看到,用于定义动画的animations字段是一个对象,对象中每个字段的键(例如animation.some_entity.anim1)就是一个动画的标识符,它的值是该标识符所定义的动画。动画使用的格式版本为1.8.0,目前动画只有这么一种格式版本。

我们继续以我们第一节中生成的水鸭实体中的动画为例。

{
  "format_version": "1.8.0",
  "animations": {
    "animation.teal.baby_transform": {
      "loop": true,
      "bones": {
        "head": {
          "scale": 2
        }
      }
    },
    "animation.teal.general": {
      "loop": true,
      "bones": {
        "wing0": {
          "rotation": [0, 0, "variable.wing_flap-this"]
        },
        "wing1": {
          "rotation": [0, 0, "-variable.wing_flap-this"]
        }
      }
    },
    "animation.teal.move": {
      "loop": true,
      "anim_time_update": "query.modified_distance_moved",
      "bones": {
        "leg0": {
          "rotation": ["math.cos(query.anim_time*38.17)*80.0", 0, 0]
        },
        "leg1": {
          "rotation": ["math.cos(query.anim_time*38.17)*-80.0", 0, 0]
        }
      }
    }
  }
}

我们可以看到,这里定义了三个动画,分别是animation.teal.baby_transformanimation.teal.generalanimation.teal.move,分别是水鸭的幼体成年时的缩放动画、常规的摆动翅膀动画和移动时的双脚摇摆动画。

首先我们可以看到,三个动画中的loop字段都为true,这代表他们是循环播放的。如果我们改为false,则该动画只会播放一次。当然,这个循环播放意味着动画会以其持续时间为周期反复播放,但是事实上,上述三个动画的持续时间都是“瞬间”,这是因为他们没有设置关键帧,所以他们的持续时间都是单帧。此时的循环意味着每帧都播放一次该动画,而动画中的Molang表达式也因此会每帧都重新计算一次,确定新的值。

三个动画中的都存在bones字段,这里便是针对每个骨骼Bone)的动画定义。这里的骨骼名称是和该实体的模型几何中的骨骼名一一对应的。不过值得注意的是,并不是每个骨骼都必须在这里分配一个动画,并且如果这里定义了某个不存在的骨骼的动画,这也无伤大雅,因为不存在的骨骼的动画会被默认忽视。

对于每个骨骼来说,比如animation.teal.baby_transform中的head骨骼,它们的位置Position)、旋转Rotation)和尺度Scale)动画都可以分别被定义。我们分别用positionrotationscale来定义这三种属性。位置便是该骨骼相对于轴心点的位置,旋转便是该骨骼相对于轴心点的旋转角,尺度便是该骨骼缩放的倍数。这是与模型几何中的这三种属性的意义相对应的,只不过通过这里的定义,这三种属性可以“动起来”。我们将位置、旋转和尺度称为实体的三种通道Channel),而这里的分别定义这三种通道的值的过程称为逐通道Channel-wise)的定义。同时,这里需要注意的是,和上面所说的骨骼一样,对于通道来说,也不是每种通道都必须得到定义。比如水鸭示例中,该动画就只定义了将其head骨骼的尺度通道更改为2。同时,由于这里没有定义关键帧,加之以存在动画循环,所以结果是只要该动画还在播放中,水鸭的头部的三个轴上的尺度就都会持续地(每帧都会)变为2倍的大小。

在上面,我们反复提到了一个词——关键帧Key Frame)。那么,关键帧又是什么呢?在水鸭的示例中,我们并没有看到有关关键帧的信息,这是因为Blockbench中导出的水鸭的三个动画都没有定义关键帧。我们现在将水鸭的第一个动画改为已定义了关键帧的形式再将其作为示例来考察。

"animation.teal.baby_transform": {
  "loop": true,
  "bones": {
    "head": {
      "scale": {
        "0.0": 2,
        "0.5": 1,
        "1.0": 2
      }
    }
  }
}

现在我们可以看到,尺度通道不再是一个值,而是一个对象。对象的键名都是一个小数,而值则是一个尺度值。此时,我们便得到了一个带有关键帧的尺度通道。关键帧是一个在动画播放时间轴上以秒为单位的关键节点。我们可以定义数个关键帧来指定动画的某个骨骼的某个通道在某些时间点的状态,而两个关键帧之间则默认采用线性插值的方式取值,比如上述示例中0.25和0.75处的值都应该为2和1的中点值1.5。当然,如果定义了lerp_mode字段,还可以采用其他插值方法,比如Catmull-Rom平滑插值,比如在0.5处采用平滑插值:"0.5": {"post": 1, "lerp_mode": "catmullrom"}。当定义了动画的关键帧之后,动画的时长也不再是一帧的瞬间,而是变为最大关键帧所代表的时间,比如上述示例动画的时长为1.0s。

此时,我们再回头看那些没有定义关键帧的动画。这些动画其实也是有关键帧的。引擎会在0.0处为这些动画定义长度为一帧的关键帧,因此这些动画自己的长度也就是一帧。但是只要配合上loop循环运行,便可以使其时刻保持动画的这一帧的状态。

另外,不管是单个通道直接设置的值,还是某个关键帧中的值,不管是通过一个值来同时设置三个轴向的状态,还是写成一个三元数组来分别设置各个轴向,我们都可以使用Molang表达式Molang Expression)来指定这个值。比如上述的variable.wing_flap - thisquery.modified_distance_movedmath.cos(query.anim_time * 38.17) * 80.0都是Molang表达式。他们通过查询实体的成员或旗标、引用全局参数、读取实体变量的值再配合上各种数学运算和数学函数,来获取一个最终的输出值。这样的值可以脱离关键帧线性插值局限性的束缚,使动画更加平滑和“有曲度”。关于Molang的更多内容我们将在第十二章中详细讲解。

实体的动画是逐骨骼定义的,而骨骼是逐通道定义的,通道是逐关键帧定义的。实体的这种动画定义方式我们称之为层阶式Hierarchical)的。

EntityAnimation: 动画名
__BoneAnimation[]: 该动画使用的骨骼名
____AnimationChannel[]: 该动画播放的旋转、缩放和平移
______KeyFrame[]: 通道位于特定的时间点时位于的值

# 动画的播放

在实体的定义文件描述中的scripts/animate下定义了各个动画的播放条件。如果是直接写出了该动画的短名称,那么就意味着实体一旦出现在世界中便开始播放该动画。如果是使用了比如{ "baby_transform": "query.is_baby" }这种由Molang表达式控制的语句,则意味着只有当Molang表达式返回为真时才会触发该动画的播放。比如这句话的意思便是“如果实体为幼年”(query.is_baby返回1.0),则播放baby_transform动画。这种通过Molang表达式控制动画播放的功能称为动画的条件控制Conditional Control)。

# 初步了解动画控制器

我们在上面学习了动画的条件控制,了解到我们事实上是可以使某个动画在某种特定的条件下触发的。那么既然如此,动画控制器的意义又在哪里呢?还有什么其他的控制方法使动画的播放更加高级么?答案是有的,那便是状态机。

在详细说明状态机之前,我们先设想一种情形。我们设想有一扇门,如果一个变量flagopened且门关着,我们希望把门打开,如果flagclosed且门开着,我们希望把门关闭。我们可以将这个过程抽象为如下表述。

门有两个状态。状态1代表门关,状态2代表门开。当门位于状态1时,如果旗标flag更新为opened,我们希望将门转移到状态2,同时播放一个开门的动画。当门位于状态2时,如果旗标flag更新为closed,我们希望将门转移到状态1,同时播放一个关门的动画。

这个过程可以用一张图来表示:

这便是一个最简单的状态机State Machine)。而动画控制器Animation Controller)便是一种可以被挂接到我的世界实体上的状态机。状态机中每个情形都可以被称作一个状态State),而状态机的运作,便是从一个状态转移到另一个状态,再从另一个状态转移到第三个状态的过程。这种过程我们称之为状态转移State Transition)。在动画控制器中,每个状态都可以播放一个或多个动画,还可以播放粒子、特效和音效等。在服务端的动画控制器中,我们甚至可以在进入和离开状态时执行命令。实体的动画控制器通过状态机的运作模式给我们提供了无限的可能。

我们的水鸭实体并没有复杂到使用动画控制器的程度,因此我们使用原版铁傀儡的动画控制器作为示例:

{
  "format_version" : "1.10.0",
  "animation_controllers" : {
    "controller.animation.iron_golem.arm_movement" : {
      "initial_state" : "default",
      "states" : {
        "attack" : {
          "animations" : [ "attack" ],
          "transitions" : [
            {
              "default" : "variable.attack_animation_tick <= 0.0"
            }
          ]
        },
        "default" : {
          "animations" : [ "move" ],
          "transitions" : [
            {
              "attack" : "variable.attack_animation_tick > 0.0"
            },
            {
              "flower" : "variable.offer_flower_tick"
            }
          ]
        },
        "flower" : {
          "animations" : [ "flower" ],
          "transitions" : [
            {
              "attack" : "variable.attack_animation_tick > 0.0"
            },
            {
              "default" : "variable.offer_flower_tick <= 0.0"
            }
          ]
        }
      }
    },
    "controller.animation.iron_golem.move" : {
      "initial_state" : "default",
      "states" : {
        "default" : {
          "animations" : [
            {
              "walk" : "query.modified_move_speed"
            },
            "look_at_target"
          ]
        }
      }
    }
  }
}

和动画类似,一个动画控制器文件也可以定义多个动画控制器。比如,这里便定义了两个动画控制器:controller.animation.iron_golem.arm_movement控制手臂移动,而controller.animation.iron_golem.move控制整体的移动。动画控制器的格式版本有1.8.01.10.0,建议使用1.10.0格式的动画控制器。

initial_state代表了该控制器从播放开始的初始默认状态,在第一个控制器controller.animation.iron_golem.arm_movement中我们看到是default状态。default状态会播放动画move,从铁傀儡动画的定义文件中我们可以看到该动画是铁傀儡两条手臂对应的骨骼的移动。此处有两个状态转移条件,分别是variable.attack_animation_tick > 0.0variable.offer_flower_tick <= 0.0。动画控制器每帧都会自上而下地按照顺序检查各个条件是否符合,一旦发现符合的条件便会开始转移,此时后面没有检查的条件将被跳过。

假设我们第一个条件满足,此时我们便转移到了attack状态。default状态的move动画将停止播放,转而播放此处定义的attack动画。当某时刻出现variable.attack_animation_tick <= 0.0为真时,我们便会重新转移回default状态。

接下来我们再关注第二个控制器controller.animation.iron_golem.move。我们可以看到此控制器的default状态会播放两个动画,walklook_at_target。事实上,这两个动画是可以叠加的,而叠加的顺序是最上面的动画作为最底层,而最下面的状态作为最上层,即最先读取到的动画会被放在最底层,后续动画依次向上叠加,类似于计算机中的一个栈。如果上层的动画覆盖了下层同骨骼的动画,那么以上层动画为准进行播放。如果某些骨骼在上层没有动画,那么下层这些骨骼的动画就会播放。此时walk后面的Molang表达式将不再是条件控制的播放条件,而是播放时传入的修饰符数值,用于控制动画播放的速度和方向。我们可以通过这个值做到反向播放一个动画。

在动画控制器中我们可以将另一个动画控制器作为动画来播放,这因此扩展了动画的定义层阶。

# 动画控制器的播放

类似于动画,动画控制器也是需要放在实体定义文件描述中的scripts/animate下来播放。一个动画控制器一旦播放,将会自动进入它的默认状态,状态机便开始工作。和动画一样,动画控制器也可以使用条件控制。

# 初步了解渲染控制器

学习完毕动画和动画控制器,我们转向另一种类型的控制器,它控制的不是动画,而是模型、材质和纹理,它便是渲染控制器Render Controller)。事实上,除了广义的资源之外,我们还有狭义的资源。狭义的资源便专指渲染控制器所控制的三种资源——模型几何Geometry)、材质Material)和纹理Texture)。

由于我们的水鸭用到的渲染控制器是原版的控制器,并且极为简略,为了讲述方便,我们使用原版弩的附着物Attachable挂件)的控制器作为示例。由于附着物也是一种客户端实体,所以其渲染控制器和实体的控制器别无二致。

{
  "format_version": "1.10.0",
  "render_controllers": {
    "controller.render.crossbow": {
      "arrays": {
        "textures": {
          "array.crossbow_texture_frames": [
            "texture.default",
            "texture.crossbow_pulling_0",
            "texture.crossbow_pulling_1",
            "texture.crossbow_pulling_2",
            "texture.crossbow_arrow",
            "texture.crossbow_rocket"
          ]
        },
        "geometries": {
          "array.crossbow_geo_frames": [
            "geometry.default",
            "geometry.crossbow_pulling_0",
            "geometry.crossbow_pulling_1",
            "geometry.crossbow_pulling_2",
            "geometry.crossbow_arrow",
            "geometry.crossbow_rocket"
          ]
        }
      },
      "geometry": "array.crossbow_geo_frames[query.get_animation_frame]",
      "materials": [
        { "*": "variable.is_enchanted ? material.enchanted : material.default" }
      ],
      "textures": [
        "array.crossbow_texture_frames[query.get_animation_frame]",
        "texture.enchanted"
      ]
    }
  }
}

我们可以看到,渲染控制器也有格式版本。同动画控制器一样,渲染控制器有1.8.01.10.0两种格式版本,且我们推荐使用1.10.0。一个渲染控制器文件也可以定义多个渲染控制器,只不过该文件只定义了一个。我们专注于考察这一个渲染控制器controller.render.crossbow。我们可以看到有arraysgeometrymaterialstextures四个字段。

arrays是一个可选字段,可以用于定义其余三种资源的资源数组。资源数组的好处是我们可以在下面这三种资源的调用时使用一个索引值来取得资源,这方便于我们进一步使用Molang表达式来进行资源控制。在arrays里我们可以再创建geometrymaterialstextures三个字段,每个字段下又可以定义多个数组。定义的数组的格式全部都为"array.<array_name>": [ /* some resource elements */ ]格式。比如geometry的数组,我们在示例中看到其定义了array.crossbow_geo_frames数组,数组中的元素的格式都为geometry.<short_name>,其中<short_name>是我们在实体的客户端定义文件中定义的短名称。其他的数组也是类似。在示例中,我们看到其定义了texturesgeometry数组,唯独没有定义materials数组,这是因为该附着物使用的材质非常简单,只有两种,无需使用数组大费周章。

下面便是几何、材质和纹理。对于几何,每个渲染控制器只能定义一个,或者说,每一帧每个渲染控制器只能存在一个几何。因为几何代表着实体的模型,而一个实体不可能同时具备多个模型。我们不存在“薛定谔的实体”。不过,我们可以通过Molang表达式来做到动态切换模型,比如上述例子中array.crossbow_geo_frames[query.get_animation_frame]便可以做到根据纹理动画的帧来切换对应的模型。

材质则不限制个数,因为一个模型具有不同的部位,而不同的部位则可能需要有多种渲染方式,自然就需要有多种材质。材质数组中每一个元素又都是一个对象,对象中只有一个键值对,那便是"<bone>": <material>格式的键值对。其中<bone>可以填写一个骨骼的名称,也可以填写一个骨骼的部分名称,其余部分使用通配符*来补充。比如bone*可以同时指bone1bone2等骨骼,以此为它们同时赋予某个材质。单独一个*则可以代表全部的骨骼。

纹理和材质一样,可以存在多个纹理。毕竟纹理文件可能存在叠加和分层,有些纹理的上层是透明的,便可以露出下层的纹理;而有些纹理在特定的时候会不显示,也可以露出下层的纹理。示例中我们可以看到存在两层纹理,而且其中一层纹理还会随着query.get_animation_frame取值不同而改变。事实上,和动画控制器中的动画叠加播放一样,这里的纹理也是按照最上面的在最下层,最下面的在最上层的方式叠加的。这种叠加方式被称为图层系统Layer System)。在我的世界开发中,很多地方都会用到这种图层系统。比如自定义模型的方块在物品栏中渲染时,各个骨骼的渲染顺序便遵循图层系统。再比如上面说的材质,其实当存在多个材质时,也是以图形系统的方式应用的。比如原版马的材质段落:

"materials": [
  { "*": "Material.default" },
  { "TailA": "Material.horse_hair" },
  { "Mane": "Material.horse_hair" },
  { "*Saddle*": "Material.horse_saddle" }
],

首先是全部骨骼被应用为default材质,然后TailA骨骼应用材质horse_hair。此时TailA骨骼便不再使用default,因为顺序越往下应用的优先级越高。最后是一切名字中含有Saddle的骨骼被应用为horse_saddle,该材质会覆盖掉之前应用的所有材质,因为它在最上层。

在了解了动画、动画控制器和渲染控制器后,我们的实体资源文件的探索便将告一段落。而如果你还想进一步了解实体客户端的定义和资源控制器的格式,可以分别参考微软官方的附加包文档中的活动对象资源定义模式 (opens new window)活动对象动画控制器模式 (opens new window)渲染控制器模式 (opens new window)。在下一节,我们一起来探索实体的行为文件,了解实体行为的作用机理。