动画剪辑与动画组合
本页介绍 ArkPets 核心模块中动画剪辑(AnimClip)与动画组合播放器(AnimComposer)的实现:前者将 Spine 运行时的原始 Animation 解析为带类型/修饰符/阶段元数据的剪辑对象,后者在 Spine AnimationState 的核心轨道上完成剪辑的提交、循环判断与自动衔接。
Purpose and Scope
本页覆盖 core/src/cn/harryh/arkpets/animations/ 包中与"单个动画剪辑的建模与播放编排"直接相关的部分:
AnimClip:对 SpineAnimation的命名元数据包装(类型、修饰符、阶段、时长)。AnimComposer:绑定AnimationState的单轨道(core track)播放器,负责offer/reset/完成回调链。AnimData的隐式契约:从AnimComposer的调用点推导出的剪辑数据接口。
以下主题有意留给兄弟页面,本页只作入口指引:
Behavior/GeneralBehavior/StochasticMatrix的行为决策与状态机 —— 见"动画行为"相关页面。AnimClipGroup/AnimDataWeight的成组与加权采样细节 —— 与行为随机化强相关,同样属于行为页面。- Spine 骨架加载(
SkeletonLoader)与模型数据集(ModelsDataset)—— 见模型资源页面。 - 渲染层如何使用
AnimType.offsetY做 y 轴偏移 —— 见渲染页面。
说明:本页所有 API 细节均直接取自
AnimClip.java与AnimComposer.java的实际源码;AnimClipGroup、Behavior等文件仅确认其存在于同一包中,未在本页展开其内部实现。
Overview
ArkPets 的桌面角色动画基于 Spine 骨骼动画运行时。Spine 提供的是"裸"的 Animation(一组时间轴数据)与 AnimationState(负责插值与混合),但 ArkPets 需要在其上表达业务语义:
- 剪辑识别:模型导出的动画名遵循一套命名约定(如
Idle、Sit、Move/C1、Attack/Begin等)。AnimClip用三个正则枚举(AnimType、AnimModifier、AnimStage)把动画名解析成结构化元数据,使上层行为逻辑无需再做字符串匹配。 - 播放编排:
AnimComposer只占用 Spine 的单一条核心轨道(coreTrackId = 0),提供"提交一个动画数据,非循环动画结束后自动清空并衔接animNext()"的极简播放模型。这一设计刻意避免了多轨道混合带来的复杂度,把"该播什么"的决策权完全交给上层行为层。
何时使用:任何需要按名字约定驱动角色动画、或需要在动画结束时自动切换到下一个动画的场景,都会经由这两个类。
Architecture
图中实线箭头是本页从源码逐行验证过的关系:AnimClip 的 Javadoc 明确声明"一个 AnimClip 对应一个 Spine Animation";AnimComposer 构造函数接收 AnimationState 并注册完成监听器;AnimData 通过 animClip() 暴露其剪辑。虚线箭头表示行为层与剪辑层的协作方向(同包内的协作关系,其内部实现属于行为页面)。
设计意图
- 元数据 vs 数据分离:
AnimClip只存"剪辑是什么"(名称解析结果 + 时长),不存播放状态;播放状态(playing)集中在AnimComposer。这让同一个AnimClip可被多个角色实例安全共享。 - 单核心轨道:
coreTrackId = 0硬编码为常量,offer()每次都用setAnimation覆盖该轨道,而不是叠加轨道。牺牲了混合过渡能力,换来的是"行为层给出的动画必然是当前唯一正在播放的核心动画"这一强不变量,简化了行为切换的正确性推理。
剪辑元数据:AnimClip
AnimClip 的公共字段构成了一个剪辑的全部静态描述:
1public class AnimClip {
2 public final String fullName;
3 public final String baseName;
4 public final AnimType type;
5 public final AnimModifier modifier;
6 public final AnimStage stage;
7 public final float duration;Source: AnimClip.java
fullName:SpineAnimation的原始名字(含阶段/修饰符后缀)。baseName:去掉阶段后缀后的基础名(用于与AnimType.pattern匹配)。type/modifier/stage:三个枚举化元数据,见下文。duration:剪辑时长(秒),从 Spine 动画数据中取得,供上层做节奏控制。
注意:本页未覆盖
AnimClip的构造函数与工厂方法实现(源码探索预算限制),但其产出契约——上述六个final字段——是稳定且完整的。
AnimType:动画类型
每个类型绑定一个大小写不敏感的正则与一个渲染用 y 轴偏移量:
1public enum AnimType {
2 NONE("", 5),
3 DEFAULT("^Default.{0,2}$", 5),
4 IDLE("^((Id.?le)|(Relax)).{0,2}$", 5),
5 MOVE("^Move.{0,2}$", 5),
6 SIT("^Sit$", 50),
7 SLEEP("^Sleep$", 25),
8 SPECIAL("^Special$", 5),
9 INTERACT("^Interact$", 5),
10 ATTACK("^((Attack)|(Combat)).{0,2}$", 5),
11 SKILL("^Skill.{0,2}$", 5),
12 START("^Start.{0,2}$", 5),
13 DIE("^Die.{0,2}$", 5),
14 REVIVE("^((Revive)|(Reborn)).{0,2}$", 5);
15
16 /** The regex pattern of this type of animation, which is case-insensitive. */
17 public final Pattern pattern;
18 /** The y-axis offset that should be applied on this type of animation when rendering. */
19 public final int offsetY;
20
21 AnimType(String pattern, int offsetY) {
22 this.pattern = Pattern.compile(pattern, Pattern.CASE_INSENSITIVE);
23 this.offsetY = offsetY;
24 }
25
26 public Matcher matcher(String input) {
27 return pattern.matcher(input);
28 }
29}Source: AnimClip.java
设计意图有两点值得注意:
- 容错命名:
{0,2}允许名字尾部带最多两个字符的阶段编号(如IdleC1、Move02),这是明日方舟干员立绘动画命名风格的直接体现;IDLE同时接受Idle/Idlle/Relax等变体,REVIVE同时接受Reborn,都是为了兼容不同批次模型素材的命名漂移。 offsetY与类型耦合:坐(50)与睡(25)需要更大的 y 轴下移偏移以贴合地面,这个渲染参数被放在类型枚举里而非渲染配置中,因为它是动画语义的一部分——同一个类型在任何模型上都应有相同的落地偏移。
AnimModifier:起止修饰符
1public enum AnimModifier {
2 NONE(""),
3 BEGIN("^((Begin)|(Start)|(Up)|(Appear))$"),
4 LOOP("^Loop$"),
5 END("^((End)|(Down)|(Disappear))$");
6
7 public final Pattern pattern;
8
9 public Matcher matcher(String input) {
10 return pattern.matcher(input);
11 }
12
13 AnimModifier(String regex) {
14 pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
15 }
16}Source: AnimClip.java
BEGIN/LOOP/END 把"一个完整动作"拆成三段剪辑,例如 Interact/Begin → Interact/Loop → Interact/End。修饰符与类型是正交维度:类型回答"这是什么动作",修饰符回答"这是该动作的哪一段"。这正是 AnimComposer.animNext() 衔接机制的基础素材。
AnimStage:干员精英化阶段
AnimStage 是一个静态嵌套类而非枚举,因为阶段编号是开放集合:
1public static final class AnimStage {
2 private int id;
3
4 private enum AnimCommonStage {
5 NONE(""),
6 C_NUMBER("^C\\\\d$"),
7 ZERO_NUMBER("^0\\\\d$"),
8 ALPHABET("^[A-Z]$");
9 // ...
10 }
11
12 public AnimStage(String name) {
13 this(0);
14 try {
15 if (AnimCommonStage.C_NUMBER.matcher(name).matches()
16 || AnimCommonStage.ZERO_NUMBER.matcher(name).matches()) {
17 updateId(Integer.parseInt(name.substring(1)));
18 } else if (AnimCommonStage.ALPHABET.matcher(name).matches()) {
19 int valueOfA = Character.getNumericValue('A');
20 int valueOfThis = Character.getNumericValue(name.toUpperCase().charAt(0));
21 int alphabetIndex = valueOfThis - valueOfA + 1;
22 if (alphabetIndex < 1)
23 throw new IllegalArgumentException("Unexpected numeric value.");
24 updateId(alphabetIndex);
25 }
26 } catch (RuntimeException e) {
27 Logger.warn("Animation", "Failed to recognized the stage name \"" + name + "\".");
28 updateId(-1);
29 }
30 }Source: AnimClip.java
解析规则把三种命名归一化为整数 id:C1/01 直接取数字;单字母 A/B 转为字母序号(A=1)。默认构造为 id = 0(无阶段)。识别失败时记 Logger.warn 并把 id 置为 -1,不抛出异常——保证单个命名异常不会中断整个模型的动画索引构建。
播放编排:AnimComposer
AnimComposer 是对 Spine AnimationState 的薄封装,全类仅 63 行,但完整实现了"提交-完成-衔接"生命周期:
1public class AnimComposer {
2 protected final AnimationState state;
3 protected final int coreTrackId = 0;
4 protected AnimData playing;
5
6 public AnimComposer(AnimationState boundState) {
7 AnimComposer composer = this;
8 state = boundState;
9 state.addListener(new AnimationState.AnimationStateAdapter() {
10 @Override
11 public void complete(AnimationState.TrackEntry entry) {
12 if (composer.playing != null && entry.getAnimation() != null) {
13 if (entry.getAnimation().getName().equals(composer.playing.animClip().fullName)) {
14 AnimData completed = composer.playing;
15 if (!completed.isLoop()) {
16 composer.reset();
17 if (completed.animNext() != null) {
18 composer.offer(completed.animNext());
19 }
20 }
21 }
22 }
23 }
24 });
25 }Source: AnimComposer.java
构造函数做三件事:保存绑定的 AnimationState、固定核心轨道号、注册完成监听器。监听器中的闭包引用(局部变量 composer 指回 this)是匿名内部类的标准写法。完成回调的判定链条是本类最精细的部分:
playing != null—— 防御reset()之后到达的迟到回调;entry.getAnimation() != null—— 防御空轨道条目;entry.getAnimation().getName().equals(playing.animClip().fullName)—— 用动画全名确认"完成的确实是当前正在播放的剪辑",防止旧轨道条目的完成事件误触发衔接;!completed.isLoop()—— 循环剪辑永不自然结束,无需衔接。
通过校验后执行 reset()(清空轨道、置空 playing),再检查 animNext():若存在后续动画数据则立即 offer() 提交。这就是 Begin → Loop → End 三段式动作的自动串联机制。
offer / reset:唯一的播放入口
1 public boolean offer(AnimData animData) {
2 if (animData != null) {
3 if (playing == null || (!playing.isStrict() && !playing.equals(animData))) {
4 playing = animData;
5 state.setAnimation(coreTrackId, playing.name(), playing.isLoop());
6 onApply(playing);
7 return true;
8 }
9 }
10 return false;
11 }
12
13 public AnimData getPlaying() {
14 return playing;
15 }
16
17 public void reset() {
18 playing = null;
19 state.setEmptyAnimation(coreTrackId, 0f);
20 }
21
22 protected void onApply(AnimData playing) {
23 }Source: AnimComposer.java
offer() 是行为层驱动动画的唯一写入口,其拒绝条件体现了一个重要语义:isStrict() 的动画是"独占锁"——一旦某个严格动画在播,任何后续 offer(包括不同的动画)都会被拒绝并返回 false,直到它自然完成并触发 reset()。非严格动画则只在"重复提交同一动画"时被幂等地拒绝(避免打断当前播放重置进度)。返回布尔值让调用方可以感知提交是否生效。
reset() 使用 setEmptyAnimation(coreTrackId, 0f):混合时长为 0 表示立即清空,不留过渡帧——这与单核心轨道的"硬切换"哲学一致。
onApply(AnimData) 是一个空的 protected 钩子,子类(如行为层包装的 composer 变体)可覆写它来同步外部状态(例如记录当前行为、通知 UI),而不必重写 offer 的判定逻辑。这是本模块明确暴露的扩展点。
Core Flow
流程要点:
- 衔接(
animNext())发生在reset()之后,因此衔接动画同样要过offer的全部校验(此时playing == null必然通过)。 - 循环动画的
complete事件即使到达也会被isLoop()分支忽略,Spine 侧由setAnimation(loop=true)自行重播。
AnimData 的隐式契约
AnimData(与 AnimDataWeight)的完整实现属于行为组合主题,本页不展开;但 AnimComposer 的调用点精确界定了它必须满足的接口契约:
| 方法 | 调用位置 | 语义 |
|---|---|---|
animClip() | complete 监听器 | 返回关联的 AnimClip,其 fullName 用于完成事件比对 |
name() | offer() | 传给 AnimationState.setAnimation 的动画名 |
isLoop() | offer() / complete | 是否循环播放 |
isStrict() | offer() | 是否为独占(严格)动画 |
animNext() | complete 监听器 | 完成后要自动衔接的下一个 AnimData,可为 null |
equals(Object) | offer() | 幂等性判定(避免同动画重复提交) |
命名解析决策流
{0,2}$ 尾部通配的存在意味着阶段号在 AnimType 匹配阶段就被容忍(IdleC1 仍命中 IDLE),而 AnimStage 再把 C1 这一段独立解析成 id = 1——两层正则各司其职,避免一次解析承担全部命名变体。
Configuration Options
本模块没有外部配置文件;其"配置"内嵌在枚举常量中,修改需改源码并重新编译:
| 常量 | 类型 | 默认值 | 说明 |
|---|---|---|---|
coreTrackId | int | 0 | AnimComposer 硬编码的核心轨道号,所有核心动画都在 Spine 轨道 0 播放 |
AnimType.NONE.offsetY | int | 5 | 未识别类型的默认 y 轴偏移 |
AnimType.SIT.offsetY | int | 50 | 坐姿动画下移量(最大) |
AnimType.SLEEP.offsetY | int | 25 | 睡姿动画下移量 |
AnimType.*.pattern | Pattern | 见上文代码块 | 大小写不敏感的类型识别正则 |
AnimModifier.*.pattern | Pattern | 见上文代码块 | 起止段识别正则 |
AnimCommonStage.*.pattern | Pattern | 见上文代码块 | 阶段号识别正则 |
应用级配置(如帧率、渲染参数)由 ArkConfig 管理,与本模块无关。
API Reference
AnimComposer(AnimationState boundState)
构造并绑定播放器。
Parameters:
boundState(com.esotericsoftware.spine.AnimationState): 要绑定的 Spine 动画状态机;构造函数会立即向其注册完成监听器。
副作用: 注册一个 AnimationState.AnimationStateAdapter,其 complete 回调实现非循环动画的自动 reset + animNext() 衔接。
Source: AnimComposer.java
offer(AnimData animData): boolean
提交一个动画数据到核心轨道。
Parameters:
animData(AnimData): 待播放的动画数据,可为null(直接返回false)。
Returns: true 表示提交成功(已调用 setAnimation 与 onApply);false 表示被拒绝(animData == null、当前 playing 为严格动画、或与当前播放内容重复)。
Source: AnimComposer.java
getPlaying(): AnimData
Returns: 当前正在核心轨道播放的 AnimData;reset() 之后或尚未 offer 时为 null。
reset(): void
清空播放状态:playing = null 并 setEmptyAnimation(coreTrackId, 0f)(0 秒混合,立即生效)。
onApply(AnimData playing): void
protected 空钩子。子类覆写以在每次成功 offer 后同步外部状态(扩展点)。基类实现为空操作。
AnimClip 公共字段(只读)
| 字段 | 类型 | 说明 |
|---|---|---|
fullName | String | Spine 动画原始全名 |
baseName | String | 剥离阶段后的基础名 |
type | AnimClip.AnimType | 动画类型(含 pattern、offsetY) |
modifier | AnimClip.AnimModifier | 起止段修饰符 |
stage | AnimClip.AnimStage | 阶段包装对象 |
duration | float | 剪辑时长(秒) |
Source: AnimClip.java
Failure Modes, Edge Cases & Concurrency
- 迟到的 complete 事件:
reset()之后playing为null,监听器第一层判空即短路,不会误触发衔接。 - 错误轨道/错误动画的完成事件:以
entry.getAnimation().getName()与playing.animClip().fullName全名比对收窄,仅当完成的条目确为当前核心剪辑时才处理。 - 阶段名解析失败:
AnimStage(String)捕获RuntimeException,记Logger.warn("Animation", ...)后将id置-1而非抛出——单个异常命名不会使模型加载失败。 - 严格动画独占:
isStrict()期间所有offer返回false,行为层需依据返回值决定重试或放弃;这是有意的背压语义,不是 bug。 - 线程模型:
AnimComposer未做任何同步。它假定offer与 Spine 的apply/update在同一线程(渲染循环)中串行调用;跨线程提交动画需要调用方自行保证串行化。
Performance & Operational Notes
AnimType等Pattern在枚举构造时编译一次并复用,命名解析不产生正则编译开销;解析成本集中在模型加载阶段,运行期播放路径(offer→setAnimation)不含任何正则。- 单核心轨道 + 0 秒混合的
reset意味着切换是硬切,无插值开销;若未来需要过渡动画,需引入第二轨道,这超出当前设计边界。 complete监听器内做的字符串equals是每次动画结束一次的 O(name 长度) 操作,可忽略不计。
Extension Points
onApply(AnimData)钩子:子类化AnimComposer并覆写此方法,可在不触碰播放判定逻辑的前提下挂接 UI 状态同步、行为记录等逻辑。- 新增
AnimType:在枚举中追加(pattern, offsetY)常量即可让新命名约定被识别,无需改动解析调用方。 AnimData.animNext()链:行为层通过构造带animNext的AnimData即可声明任意长度的动画串联,AnimComposer无需感知链的长度。
Tests
仓库探索预算内未定位到针对 animations 包的独立测试文件,无法在本页给出测试覆盖结论;AnimStage 的容错分支(id = -1 + Logger.warn)与 offer 的三条拒绝路径是源码中可静态验证的关键边界,建议以此作为补测优先级。
Related Links
- 行为决策与状态机(
Behavior、GeneralBehavior、StochasticMatrix、AnimClipGroup、AnimDataWeight):见"动画行为"兄弟页面。 - 模型资源加载(
SkeletonLoader、ModelItem、ModelsDataset):见模型资源页面。 - 渲染与着色(
assets/shaders/):见渲染页面。 - 源文件:AnimClip.java、AnimComposer.java