跳转到内容

动画

Estella 有三套动画系统,各司其职:

  • 精灵动画 —— 逐帧的精灵表播放(SpriteAnimator)。
  • 补间(Tween) —— 用缓动随时间插值任意属性(Tween)。
  • 动画状态机 —— 参数驱动的角色动画(Animator)。

它们可以组合:状态机可以驱动精灵片段,补间可以在精灵循环的同时给 UI 元素加“手感”。

翻页书编辑器——4 帧行走片段,带逐帧时长与实时预览

翻页书编辑器:一个 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) 监听所有实体的帧事件——回调还会收到实体本身。

剪辑是 .esanim 资产,全程可视化创作——不用手写 JSON:

  1. 在内容浏览器中右键纹理,选择创建精灵动画。Flipbook 编辑器打开, 精灵表上覆盖切片网格(格子尺寸按图片猜测;在工具栏调整格宽/格高/边距/间距)。
  2. 点击或拖选格子追加帧。下方帧条显示每帧缩略图与可编辑时长 (毫秒;留空 = 1000 / 帧率),可拖拽重排,并带实时循环预览。
  3. 保存,然后右键 .esanim → 创建动画精灵(或直接拖进视口)。 得到一个定格在第 0 帧的 Sprite + SpriteAnimator 实体——进 Play 零代码播放。

选中动画精灵时,翻页动画在编辑态视口中循环播放(用 Preview FX 显示开关控制)。 若该剪辑同时在 Flipbook 编辑器中打开,改帧即时反映在视口里。

重新切片时所有帧一致跟随——帧引用的是网格格子,不是像素矩形。 超出当前网格的帧会在帧条中标红。

.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 12loop: true、边距/间距 0、暂无帧)。
parseAnimClipData(name, data, textureHandles) 把资产数据解析成运行时 SpriteAnimClip——每个格子帧得到纹理句柄与 UV 窗口。
parseAnimClipAsset(json) / serializeAnimClip(data) 解析 / 写出 .esanim JSON 文档(两者可往返)。
animClipCellRect(sheet, cell) / animClipCellUv(sheet, cell) 网格格子的像素矩形 / UV 窗口——即该帧 Sprite 显示的内容。

FSM/BT 内置以数据驱动翻页动画。动作的可选参数 携带剪辑,所以 idle/run/attack 切换是纯 .esfsm 数据,画在现有状态机画布上:

名称 类型 说明
spriteAnim.play 动作 播放。带剪辑参数时切换到该剪辑(回卷到第 0 帧)。可安全放在 onUpdate——同剪辑播放中为空操作。
spriteAnim.restart 动作 无条件回卷 + 播放(中途重触发一次性动画)。
spriteAnim.stop 动作 暂停播放。
spriteAnim.finished 条件 一次性剪辑播完后为真——onEnter: spriteAnim.play + spriteAnim.finished 过渡即是自包含的攻击状态。

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 选择要驱动的属性:

分组 取值
位置 PositionXPositionYPositionZ
缩放 ScaleXScaleY
旋转 RotationZ
颜色 ColorRColorGColorBColorA
尺寸 SizeXSizeY
相机 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*)、EaseOutBounceStepCubicBezier——自定义贝塞尔用句柄的 .bezier(p1x, p1y, p2x, p2y)

每种 EasingType 从 0 到 1 的曲线

每条缓动曲线(进度 t 从左到右,缓动后的值从下到上)——这就是引擎的精确曲线。BackElastic 会冲出 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 order
tween.delay(0.5); // a timed gap
tween.cancel(handle); // stop one
tween.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 状态,带条件与参数面板

动画控制器:状态(Idle / Move / Hop)、带条件的转移(speed>20hop 触发器),以及驱动它们的参数面板。

角色动画用一个动画控制器:它持有状态、转移和由命名参数驱动的 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 按 speed 阈值在 idle / walk / run 之间选择

一维 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。状态互斥:clipblendspinestateMachine 四者取一。

携带 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 路径分隔符,'/'
  • Spine 动画 —— 骨骼动画,也能被状态机驱动。
  • 时间轴 —— 关键帧过场与序列化事件。
  • 相机 —— 补间 CameraOrthoSize 做平滑缩放。