动画
Estella 有三套动画系统,各司其职:
- 精灵动画 —— 逐帧的精灵表播放(
SpriteAnimator)。 - 补间(Tween) —— 用缓动随时间插值任意属性(
Tween)。 - 动画状态机 —— 参数驱动的角色动画(
Animator)。
它们可以组合:状态机可以驱动精灵片段,补间可以在精灵循环的同时给 UI 元素加“手感”。

翻页书编辑器:一个 4 帧行走片段、逐帧时长,以及带 FPS / 循环 / 洋葱皮的实时预览。
SpriteAnimator 组件播放命名的精灵表片段(作为 anim-clip 资源编辑)。通过 Mut 查询
写组件来切换片段:
import { defineSystem, Query, Mut, SpriteAnimator } from 'esengine';
const setClip = defineSystem([Query(Mut(SpriteAnimator))], (q) => { for (const [entity, animator] of q) { animator.clip = 'walk'; animator.speed = 1.5; animator.playing = true; }});| 属性 | 类型 | 默认 | 说明 |
|---|---|---|---|
clip |
asset | '' |
当前片段(一个 anim-clip 资源名)。 |
speed |
number | 1 |
播放速度倍率。 |
playing |
boolean | true |
播放是否在推进。 |
loop |
boolean | true |
循环片段 vs 停在最后一帧。 |
enabled |
boolean | true |
关掉即冻结动画器。 |
finished |
boolean | false |
只读:一次性片段播完时锁存。在 finished 状态抬起 playing 会从第 0 帧重播。 |
currentFrame |
number | 0 |
只读:正在显示的帧索引。 |
frameTimer |
number | 0 |
只读:当前帧已累积的时间。 |
要更精细地控制,SpriteAnimation 资源可跳到指定帧或命名标签,并在运行时注册片段:
import { defineSystem, Query, Mut, Res, SpriteAnimator, SpriteAnimation } from 'esengine';
const jump = defineSystem([Query(Mut(SpriteAnimator)), Res(SpriteAnimation)], (q, sprites) => { for (const [entity, animator] of q) { sprites.gotoFrame(animator, 0, true); // frame index, and play sprites.gotoLabel(animator, 'attack', true); // named label, and play }});| 方法 | 说明 |
|---|---|
gotoFrame(animator, index, andPlay?) |
跳到某帧索引(可选是否开始播放)。 |
gotoLabel(animator, label, andPlay?) |
跳到片段里的命名标签。 |
registerClip(clip) |
在运行时注册一个 SpriteAnimClip。 |
getClip(name) |
查找一个已注册的片段。 |
片段携带帧事件(SpriteAnimEvent——{ frame, name, data? }),播放到某帧时触发:
脚步声、攻击的命中帧、金币翻面的那一刻。在 Flipbook 编辑器的 Events 轨道上编辑它们——
选中一帧,添加事件,起个名字;帧条会标出哪些帧带事件。然后订阅:
import { defineSystem, Res, SpriteAnimation } from 'esengine';
const listen = defineSystem([Res(SpriteAnimation)], (anim) => { anim.onEvent(playerEntity, (e) => { if (e.name === 'footstep') audio.playSFX('assets/sfx/step.wav'); });});| 方法 | 说明 |
|---|---|
onEvent(entity, handler) |
监听某个实体的帧事件。返回一个取消订阅的函数。 |
onEventGlobal(handler) |
监听所有实体的帧事件——回调还会收到实体本身。 |
在编辑器中创作剪辑
Section titled “在编辑器中创作剪辑”剪辑是 .esanim 资产,全程可视化创作——不用手写 JSON:
- 两条路:右键一张纹理选创建精灵动画——Flipbook 编辑器打开,精灵表上
覆盖切片网格(格子尺寸按图片猜测;在工具栏调整格宽/格高/边距/间距);或者
选中多张图片,选用 N 张图片创建精灵动画,按文件名顺序排好
(按数字比较,所以
run_2排在run_10前面)。 - 点击或拖选格子追加帧,或把图片拖到时间轴上插入。时间轴上一个块有多宽 就代表这帧有多长:拖它的右边缘改时长,拖块本身重排。视口把当前帧按真实尺寸 画在剪辑锚点上,相邻帧则各就各位地叠印在后面。
- 保存,然后右键
.esanim→ 创建动画精灵(或直接拖进视口)。 得到一个定格在第 0 帧的Sprite+SpriteAnimator实体——进 Play 零代码播放。
选中动画精灵时,翻页动画在编辑态视口中循环播放(用 Preview FX 显示开关控制)。 若该剪辑同时在 Flipbook 编辑器中打开,改帧即时反映在视口里。
重新切片时所有帧一致跟随——帧引用的是网格格子,不是像素矩形。 超出当前网格的帧会在帧条中标红。
播放头所在的那一帧在检视器里有自己的一节——时长、尺寸、偏移、锚点, 和剪辑的帧率、循环模式以及下面这两个开关并排。
从 Aseprite 文件导入
Section titled “从 Aseprite 文件导入”把 .aseprite(或 .ase)拖进项目,导入会替你写好产物:合成后的精灵表 .png 落在
它旁边,每个 tag 一个 .esanim —— hero_walk.esanim、hero_jump.esanim ——
带着每帧自己的时长与 tag 声明的播放方向。没有重复次数的 tag 会循环,数着播放次数的
不循环。没有 tag 的文件得到一个覆盖全部帧的剪辑。
合成出来的就是画师看到的那一张:所有他们留着可见的图层,连同各自的混合模式与不透明度, 而被关掉的组里的东西一概不进来。tilemap 图层是从 tileset 画出来的而不是像素,导入会 明确报出来,而不是让它悄悄变成空白。
精灵表的纹理导入设置到达时就是 nearest 过滤 + 关闭压缩 —— 像素画永远想要、
却从来不是默认值的两项 —— 并且格子尺寸已经填好,Sprite 可以直接选一帧。这两项改了
就保持你改的样子;格子则每次导入都重新推导,因为它是源文件的画布尺寸而不是一项偏好。
编辑源文件并保存会原地重新导入:uuid 不变、引用不变、像素是新的。右键 → 重新导入可以随时手动触发。
逐帧尺寸与偏移
Section titled “逐帧尺寸与偏移”精灵表的格子都一样大,所以整段剪辑共用一个 Sprite.size 是对的。多张独立
图片不是:96×64 的攻击帧和 48×96 的待机帧按同一个尺寸画,两个都是错的。
勾上逐帧尺寸,播放期间 Sprite.size 就归剪辑管——每帧按自己的自然尺寸
(纹理像素,或所在格子)绘制,也可以给某帧写一个 size 覆盖。
这同时解锁了偏移。偏移把某帧的画面挪开,单位与尺寸相同,加载时被折进 该帧的锚点——所以下游没有任何一层需要认识这个字段。用单位而不是直接改锚点 来表达位移,正是关键:同一个偏移在任意尺寸的帧上都是同样的视觉位移, 归一化的锚点做不到这点。在视口里拖动这一帧即可设定,也可以在检视器里输入。
// step.esanim —— 三张尺寸不同的图片,其中一张往右挪{ "version": "1.5", "fps": 6, "frameSizing": "frame", "pivot": { "x": 0.5, "y": 0 }, "frames": [ { "texture": "@uuid:…" }, { "texture": "@uuid:…", "offset": { "x": 20, "y": 0 } }, { "texture": "@uuid:…", "duration": 0.35 } ]}两者都没声明的剪辑会原封不动地保留实体自己设的 Sprite.size,所以在这个能力
出现之前写的剪辑播放起来完全一样。取消勾选会清除该模式以及所有逐帧尺寸与偏移,
回到这种「不插手」的行为。
锚点就是精灵的落点、旋转与缩放中心——也就是 Sprite.pivot,在帧内归一化,
(0,0) 为左下角、(0.5,0.5) 为居中。当画面在格子里逐帧位移时,只有一个
实体级锚点会让角色跟着滑来滑去;逐帧设锚点则能把必须钉住的部位钉住——
脚踩在地面上、手握在武器上。
在 Flipbook 编辑器预览条勾上锚点,当前帧上会出现一个十字准星:拖动它 (或在旁边直接输入 X / Y)即可为该帧设锚点。准星的辅助线横贯整个预览台, 配合洋葱皮就能把每一帧的脚与相邻帧对齐。单独设过锚点的帧在帧条上带一个 琥珀色圆点,× 清除该覆盖、让此帧回落到剪辑锚点——剪辑锚点在检视器里 就是默认锚点。
解析顺序:帧自己的锚点 → 剪辑锚点 → 居中。完全没有声明锚点的剪辑会
原封不动地保留实体自己设的 Sprite.pivot,所以在你勾上这个开关之前,
老项目一律不受影响;取消勾选会清除剪辑锚点和所有逐帧覆盖,回到这种
「不插手」的行为。
// walk.esanim —— 整段剪辑站在脚上,其中一帧向后仰{ "version": "1.4", "fps": 10, "pivot": { "x": 0.5, "y": 0 }, "frames": [ { "cell": 0 }, { "cell": 1, "pivot": { "x": 0.47, "y": 0 } } ]}锚点只在剪辑播放某一帧时归剪辑管:Transform 或补间照常移动实体,互不干扰。
用代码创建剪辑
Section titled “用代码创建剪辑”.esanim 文档就是普通数据,所以剪辑也可以在运行时构建:createAnimClip 建一个
按网格切片的剪辑,追加 { cell } 帧,parseAnimClipData 把它解析成运行时的
SpriteAnimClip,再用 registerClip 注册,任何 SpriteAnimator 都能用这个名字:
import { defineSystem, Res, Assets, SpriteAnimation, createAnimClip, parseAnimClipData,} from 'esengine';
const registerRun = defineSystem([Res(Assets), Res(SpriteAnimation)], async (assets, sprites) => { const tex = await assets.loadTexture('assets/textures/run-sheet.png');
// A 32×32 grid over the sheet; frames reference grid cells (row-major). const data = createAnimClip('assets/textures/run-sheet.png', 32, 32, tex.width, tex.height); data.fps = 10; for (let cell = 0; cell < 6; cell++) data.frames.push({ cell });
const handles = new Map([['assets/textures/run-sheet.png', tex.handle]]); sprites.registerClip(parseAnimClipData('run', data, handles)); // Any SpriteAnimator can now set clip = 'run'.});| 辅助函数 | 说明 |
|---|---|
createAnimClip(texture, cellWidth, cellHeight, pageWidth, pageHeight) |
一个全新的按网格切片的 AnimClipAssetData(fps 12、loop: true、边距/间距 0、暂无帧)。 |
createAnimClipFromTextures(textures) |
一个全新的逐帧贴图剪辑——没有精灵表,且一开始就接管尺寸。 |
parseAnimClipData(name, data, textureHandles, textureSizes?) |
把资产数据解析成运行时 SpriteAnimClip——每帧得到纹理句柄、UV 窗口,以及(剪辑接管尺寸时)它的绘制尺寸。传 textureSizes 好让逐帧贴图有自然尺寸可回落。 |
parseAnimClipAsset(json) / serializeAnimClip(data) |
解析 / 写出 .esanim JSON 文档(两者可往返)。 |
animClipCellRect(sheet, cell) / animClipCellUv(sheet, cell) |
网格格子的像素矩形 / UV 窗口——即该帧 Sprite 显示的内容。 |
animClipFramePivot(data, frame, size?) |
该帧实际渲染用的锚点——帧自己的 → 剪辑的 → 居中;给了 size 时把该帧的偏移折进去。剪辑没有声明锚点时返回 null(表示不要动 Sprite.pivot)。 |
animClipDrivesPivot(data) |
该剪辑是否声明了锚点。 |
animClipFrameSize(data, frame, natural?) |
该帧的绘制尺寸——帧自己的 → 你传入的自然尺寸。剪辑不接管尺寸时返回 null(表示不要动 Sprite.size)。 |
animClipDrivesSize(data) |
该剪辑是否接管 Sprite.size——直接设定,或由任一帧的尺寸/偏移隐含。 |
用状态机驱动剪辑——零代码
Section titled “用状态机驱动剪辑——零代码”FSM/BT 内置以数据驱动翻页动画。动作的可选参数
携带剪辑,所以 idle/run/attack 切换是纯 .esfsm 数据,画在现有状态机画布上:
| 名称 | 类型 | 说明 |
|---|---|---|
spriteAnim.play |
动作 | 播放。带剪辑参数时切换到该剪辑(回卷到第 0 帧)。可安全放在 onUpdate——同剪辑播放中为空操作。 |
spriteAnim.restart |
动作 | 无条件回卷 + 播放(中途重触发一次性动画)。 |
spriteAnim.stop |
动作 | 暂停播放。 |
spriteAnim.finished |
条件 | 一次性剪辑播完后为真——onEnter: spriteAnim.play + spriteAnim.finished 过渡即是自包含的攻击状态。 |
补间(Tween)
Section titled “补间(Tween)”Tween 资源在一段时长内把属性从一个值插值到另一个值。to(entity, target, from, to, duration, options?) 返回一个句柄:
import { defineSystem, Res, Tween, TweenTarget, EasingType, LoopMode } from 'esengine';
const animate = defineSystem([Res(Tween)], (tween) => { tween.to(entity, TweenTarget.PositionX, 0, 200, 1.0, { easing: EasingType.EaseInOutQuad, delay: 0.2, loop: LoopMode.PingPong, loopCount: 3, // -1 = forever });});TweenTarget 选择要驱动的属性:
| 分组 | 取值 |
|---|---|
| 位置 | PositionX、PositionY、PositionZ |
| 缩放 | ScaleX、ScaleY |
| 旋转 | RotationZ |
| 颜色 | ColorR、ColorG、ColorB、ColorA |
| 尺寸 | SizeX、SizeY |
| 相机 | CameraOrthoSize |
| 选项 | 类型 | 默认 | 说明 |
|---|---|---|---|
easing |
EasingType |
Linear |
缓动曲线(见下)。 |
delay |
number | 0 |
开始前等待的秒数。 |
loop |
LoopMode |
None |
None / Restart / PingPong。 |
loopCount |
number | 0 |
循环次数;-1 = 永远。 |
EasingType 覆盖 Linear、quad/cubic/back/elastic 系列(EaseIn* / EaseOut* /
EaseInOut*)、EaseOutBounce、Step 和 CubicBezier——自定义贝塞尔用句柄的
.bezier(p1x, p1y, p2x, p2y)。
每条缓动曲线(进度 t 从左到右,缓动后的值从下到上)——这就是引擎的精确曲线。Back 和 Elastic 会冲出 0–1 区间;Step 一直保持到末尾才跳变。
返回的 TweenHandle 可链式串接并控制播放:
// Chain: run one after another (a squash-and-stretch pop).tween.to(e, TweenTarget.ScaleX, 1, 1.4, 0.15) .then(tween.to(e, TweenTarget.ScaleX, 1.4, 1, 0.15)) .bezier(0.2, 0, 0, 1);
tween.parallel([...]); // run several at once (a group)tween.sequence([...]); // run tween factories in ordertween.delay(0.5); // a timed gap
tween.cancel(handle); // stop onetween.cancelAll(entity); // stop everything on an entity| 方法 | 说明 |
|---|---|
to(entity, target, from, to, duration, opts?) |
补间一个组件属性;返回 TweenHandle。 |
value(from, to, duration, cb, opts?) |
补间一个普通数值,每帧交给 cb。 |
parallel(tweens) |
把多个补间作为一组同时运行。 |
sequence(factories) |
依次运行补间工厂。 |
delay(seconds) |
一段空的计时间隔(用于序列)。 |
cancel(handle) / cancelAll(entity) |
停止一个补间,或某实体上的全部。 |
TweenHandle 有 .then(next)、.bezier(...)、.pause()、.resume() 和 .cancel()。
tween.parallel 返回 TweenGroup,tween.sequence 返回 TweenSequence——它们是
覆盖全体成员的父级句柄。两者都有 state(一个 TweenState)、会扇出到每个成员的
pause() / resume() / cancel(),以及 onComplete(cb):
const popX = tween.to(e, TweenTarget.ScaleX, 1, 1.3, 0.2);const popY = tween.to(e, TweenTarget.ScaleY, 1, 1.3, 0.2);tween.parallel([popX, popY]).onComplete(() => spawnSparkles());
tween.sequence([ () => tween.to(e, TweenTarget.PositionY, 0, 60, 0.3), () => tween.delay(0.2), () => tween.to(e, TweenTarget.PositionY, 60, 0, 0.3),]).onComplete(() => landDust());组和序列一定要通过 Tween 资源创建——资源会把它们登记进内部轮询器
(TweenCompositionManager),onComplete 的触发和序列推进到下一个工厂都靠它。
手写 new TweenGroup(...) 不会被任何人轮询。
tween.value(...) 和 tween.delay(...) 返回 ValueTweenHandle——同一套控制面
(state、.pause() / .resume() / .cancel()、.bezier(...)),外加
.then(next):可以串接另一个数值补间,或任何可暂停/恢复的东西(一个组、一个序列)。
TweenHandle.then 同样接受 ValueTweenHandle,所以属性补间和数值补间可以在一条链里
自由混用。
优先用简写辅助(tween.parallel / tween.sequence / tween.delay / 句柄的
.then);这些类就是那些调用返回的东西——只有当你要存下句柄、稍后暂停/取消/观察
组合时,才需要点名 TweenGroup / TweenSequence / ValueTweenHandle。

动画控制器:状态(Idle / Move / Hop)、带条件的转移(speed>20、hop 触发器),以及驱动它们的参数面板。
角色动画用一个动画控制器:它持有状态、转移和由命名参数驱动的 1D 混合树。在编辑器
里编辑它(或用代码注册),给实体加一个指明控制器的 Animator 组件,然后每帧通过
Res(AnimatorController) 设置参数——控制器会解析出当前状态、片段与混合。
控制器是一份 .esanimator 资产:在内容浏览器的 新建 菜单里创建,双击打开上图的
图编辑器——带位置的状态、带条件的转移,以及参数面板——再用资产选择器把 Animator
组件的 controller 字段指向它。场景会预加载它,所以第一帧之前它就已注册好。若直接填一个
普通字符串,则解析为代码里 registerController(...) 注册的控制器;两种方式可以共存:
import { defineSystem, Query, Res, Animator, AnimatorController } from 'esengine';
const drive = defineSystem([Query(Animator), Res(AnimatorController)], (q, anim) => { for (const [entity] of q) { anim.setFloat(entity, 'speed', 4.2); // drives a 1D blend / transition anim.setBool(entity, 'grounded', true); anim.setTrigger(entity, 'jump'); // one-shot; resetTrigger to clear const speed = anim.getFloat(entity, 'speed'); }});Animator 字段 |
类型 | 默认 | 说明 |
|---|---|---|---|
controller |
string | '' |
一个 .esanimator 资产引用(编辑器写入的),或代码里传给 registerController 的名字。 |
currentState |
string | '' |
活动状态;嵌套状态机时是 / 分隔的路径(从控制器的初始状态种入)。 |
enabled |
boolean | true |
关掉即冻结状态机。 |
AnimatorController 方法 |
说明 |
|---|---|
setFloat(entity, name, value) |
设置 float 参数(混合 / 转移条件)。 |
setBool(entity, name, value) |
设置 bool 参数。 |
setTrigger(entity, name) |
触发一次性 trigger。 |
resetTrigger(entity, name) |
在被消费前清除一个 trigger。 |
getFloat(entity, name) / getBool(entity, name) |
读回一个参数。 |
控制器可以同时驱动精灵和 Spine 动画,所以一套状态机就能 管一整个角色。
参数有三种类型(AnimatorParamType):'float'、'bool'、'trigger'。条件用
gt / lt / eq / neq 比较数值(带 value),用 true / false 测试 bool,
trigger 触发一次即被消费。没有单独的 int 类型——整数走 float 参数配合
eq / neq。
一维 blend:float 参数挑出「阈值 ≤ 它」中最高的那个片段——这里随 speed 上升在 idle / walk / run 之间切换,彼此不做过渡。
状态可以携带一个 1D blend 来代替单个片段:由一个 float 参数在
AnimatorBlendThreshold 档位之间做选择。因为精灵通道一次只播一个片段,这里的混合是
按阈值的选择——value 不超过参数值的档位中取最大者(低于所有档位时播第一个)——
而不是加权姿态混合。每个档位是 { value, clip, speed?, loop? };档位级的
speed / loop 覆盖状态自己的。选择每帧重算,所以 speed 参数上升时
idle→walk→run 无需任何转移:
import { defineSystem, Res, AnimatorController, type AnimatorControllerDef } from 'esengine';
const movement: AnimatorControllerDef = { parameters: [{ name: 'speed', type: 'float', default: 0 }], initialState: 'move', states: [{ name: 'move', blend: { parameter: 'speed', thresholds: [ { value: 0, clip: 'idle' }, { value: 0.5, clip: 'walk' }, { value: 4, clip: 'run', speed: 1.2 }, ], }, transitions: [], }],};
const setup = defineSystem([Res(AnimatorController)], (anim) => { anim.registerController('movement', movement);});纯函数 selectBlendClip(blend, value) 返回某个值会选中的档位——方便做工具或测试。
骨骼角色的状态可以改为携带 spine 运动(AnimatorSpineMotion,
{ animation, loop? }):进入时控制器设置该 Spine
动画(loop 默认 true),轨道播放与混合交给 Spine 运行时——完全不碰
SpriteAnimator。状态互斥:clip、blend、spine、stateMachine 四者取一。
携带 stateMachine(AnimatorSubMachine)的状态是一个容器:它自己不播任何运动。
进入时状态机下钻到子机的 initialState;容器自己的 transitions 是退出边,
从其内部的每个叶子状态求值。子机可以声明自己的 anyStateTransitions,对它的所有
子状态生效。Animator.currentState 保存完整的 / 分隔路径(STATE_PATH_SEP),
例如 locomotion/run:
import { defineSystem, Res, AnimatorController, type AnimatorControllerDef } from 'esengine';
const player: AnimatorControllerDef = { parameters: [ { name: 'speed', type: 'float', default: 0 }, { name: 'jump', type: 'trigger' }, ], initialState: 'locomotion', states: [ { name: 'locomotion', // container: no motion of its own stateMachine: { initialState: 'idle', states: [ { name: 'idle', clip: 'idle', transitions: [ { to: 'run', conditions: [{ param: 'speed', op: 'gt', value: 0.1 }] }, ] }, { name: 'run', clip: 'run', transitions: [ { to: 'idle', conditions: [{ param: 'speed', op: 'lt', value: 0.1 }] }, ] }, ], }, // Exit edges — leave the whole machine from idle OR run. transitions: [{ to: 'jump', conditions: [{ param: 'jump', op: 'trigger' }] }], }, { name: 'jump', clip: 'jump', loop: false, transitions: [ { to: 'locomotion', conditions: [], hasExitTime: true }, // back in at idle ] }, ],};
const setup = defineSystem([Res(AnimatorController)], (anim) => { anim.registerController('player', player);});进入 locomotion 会落在 locomotion/idle;jump 触发器从任一叶子退出;一次性的 jump
片段播完后,hasExitTime 从容器的初始状态重新进入。转移优先级从高到低:顶层
any-state → 各层子机的 any-state(由外向内)→ 叶子自己的转移 → 容器退出边(由内向外)。
路径机制以纯函数形式暴露(AnimatorScope 是顶层控制器和每个子机共享的形状):
import { enterStatePath, leafStateOf, evaluateAnimatorPath } from 'esengine';
enterStatePath(player, 'locomotion'); // ['locomotion', 'idle']leafStateOf(player, 'locomotion/run')?.clip; // 'run'evaluateAnimatorPath(player, 'locomotion/idle', { speed: 3 }, new Set());// → { nextPath: 'locomotion/run', consumedTriggers: [] }| 辅助函数 | 说明 |
|---|---|
enterStatePath(scope, name) |
从某状态下钻到具体叶子;返回路径分段。 |
leafStateOf(def, path) |
路径的承载运动的叶子状态,或 null。 |
evaluateAnimatorPath(def, path, params, triggers, clipFinished?) |
对路径做一步纯求值 → { nextPath, consumedTriggers }。 |
STATE_PATH_SEP |
路径分隔符,'/'。 |
剪辑可以给自己内部的时刻命名。.estimeline 里的 customEvent 轨道携带
{ time, name, payload },动画器在跨过该时刻的那一帧报告它——而不是正好落在
它上面的那一帧,固定步长根本不会正好落上去。事件走 AnimatorEvent 总线:
import { defineSystem, EventReader, GetWorld, Res, AnimatorEvent, Particle, resolveChildEntity,} from 'esengine';
const footsteps = defineSystem( [EventReader(AnimatorEvent), GetWorld(), Res(Particle)], (events, world, particles) => { for (const event of events) { if (event.name !== 'footstep') continue; const dust = resolveChildEntity(world, event.entity, 'FootDust'); if (dust !== null) particles.play(dust); } }, { name: 'FootstepSystem' },);| 字段 | 说明 |
|---|---|
entity |
被播放动画的实体——事情发生在谁身上。 |
name |
剪辑声明的事件名。 |
value |
数值负载(payload.value);剪辑没写时为 0。 |
text |
字符串负载(payload.text);剪辑没写时为空串。 |
动画器给出的承诺:
- 事件按一步覆盖的区间
(上一时刻, 当前时刻]触发,所以再长的一帧也不会跨过去 漏掉,也不会为同一次跨越报两次。 - 循环回绕算两个区间——本圈的尾巴,再加下一圈的开头——所以贴着两端的事件每圈 正好触发一次。
- 一步内跨过多个事件,按剪辑声明的顺序触发。
- 交叉淡化期间只有正在进入的状态报告事件。正在淡出的那个仍在走时钟,但什么都 不说——所以被闪避打断的攻击不会继续把伤害判定打出来。
动画器不调用任何粒子、音频或玩法代码:它只陈述事件,由拥有这些东西的系统来回应。
一段动画如果动的是它被播放在其上的那个实体,它就不是在摆骨架的姿势——它是在说角色 往哪走。这件事由状态声明:
{ "name": "Dodge", "motion": { "kind": "timeline", "clip": "dodge.estimeline", "loop": false }, "rootMotion": true, "transitions": [{ "to": "Idle", "conditions": [], "hasExitTime": true }]}动画器于是把该根轨道的 position 与 rotation 排除在姿势之外——剪辑永远不会自己
移动实体——并把它请求的位移发布到实体的 AnimatorRootMotion 组件上:
| 字段 | 说明 |
|---|---|
enabled |
创作项:是否发布这个请求。 |
active |
本帧是否有声明了根运动的状态在驱动角色。 |
deltaPosition |
请求的位移,世界空间。 |
deltaRotation |
请求的转向,角色自身坐标系。 |
deltaTime |
这两个增量覆盖的秒数。 |
deltaTime 与增量一起给出,是因为动画器跑在帧时钟上,而角色控制器通常跑在固定步长
上:请用这一对算出速率。直接把位移加上去的消费者,会在有两个固定步的帧里走两倍,在
没有固定步的帧里一步不走。
判断依据请读 active,而不是「增量是否非零」——闪避中间有静止的一瞬,摇杆不能在那
一刻把控制权抢回去。实体上没有 AnimatorRootMotion 组件时,该状态依然拒绝去摆角色
自身的 transform,剪辑只是原地播放。