动画
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('sfx/step.wav'); });});| 方法 | 说明 |
|---|---|
onEvent(entity, handler) |
监听某个实体的帧事件。返回一个取消订阅的函数。 |
onEventGlobal(handler) |
监听所有实体的帧事件——回调还会收到实体本身。 |
在编辑器中创作剪辑
Section titled “在编辑器中创作剪辑”剪辑是 .esanim 资产,全程可视化创作——不用手写 JSON:
- 在内容浏览器中右键纹理,选择创建精灵动画。Flipbook 编辑器打开, 精灵表上覆盖切片网格(格子尺寸按图片猜测;在工具栏调整格宽/格高/边距/间距)。
- 点击或拖选格子追加帧。下方帧条显示每帧缩略图与可编辑时长
(毫秒;留空 =
1000 / 帧率),可拖拽重排,并带实时循环预览。 - 保存,然后右键
.esanim→ 创建动画精灵(或直接拖进视口)。 得到一个定格在第 0 帧的Sprite+SpriteAnimator实体——进 Play 零代码播放。
选中动画精灵时,翻页动画在编辑态视口中循环播放(用 Preview FX 显示开关控制)。 若该剪辑同时在 Flipbook 编辑器中打开,改帧即时反映在视口里。
重新切片时所有帧一致跟随——帧引用的是网格格子,不是像素矩形。 超出当前网格的帧会在帧条中标红。
用代码创建剪辑
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('textures/run-sheet.png');
// A 32×32 grid over the sheet; frames reference grid cells (row-major). const data = createAnimClip('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([['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、暂无帧)。 |
parseAnimClipData(name, data, textureHandles) |
把资产数据解析成运行时 SpriteAnimClip——每个格子帧得到纹理句柄与 UV 窗口。 |
parseAnimClipAsset(json) / serializeAnimClip(data) |
解析 / 写出 .esanim JSON 文档(两者可往返)。 |
animClipCellRect(sheet, cell) / animClipCellUv(sheet, cell) |
网格格子的像素矩形 / UV 窗口——即该帧 Sprite 显示的内容。 |
用状态机驱动剪辑——零代码
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 |
路径分隔符,'/'。 |