Repository Wiki
isHarryh/Ark-Pets

动画剪辑与动画组合

本页介绍 ArkPets 核心模块中动画剪辑(AnimClip)与动画组合播放器(AnimComposer)的实现:前者将 Spine 运行时的原始 Animation 解析为带类型/修饰符/阶段元数据的剪辑对象,后者在 Spine AnimationState 的核心轨道上完成剪辑的提交、循环判断与自动衔接。

Purpose and Scope

本页覆盖 core/src/cn/harryh/arkpets/animations/ 包中与"单个动画剪辑的建模与播放编排"直接相关的部分:

  • AnimClip:对 Spine Animation 的命名元数据包装(类型、修饰符、阶段、时长)。
  • 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 需要在其上表达业务语义:

  1. 剪辑识别:模型导出的动画名遵循一套命名约定(如 Idle、Sit、Move/C1、Attack/Begin 等)。AnimClip 用三个正则枚举(AnimType、AnimModifier、AnimStage)把动画名解析成结构化元数据,使上层行为逻辑无需再做字符串匹配。
  2. 播放编排:AnimComposer 只占用 Spine 的单一条核心轨道(coreTrackId = 0),提供"提交一个动画数据,非循环动画结束后自动清空并衔接 animNext()"的极简播放模型。这一设计刻意避免了多轨道混合带来的复杂度,把"该播什么"的决策权完全交给上层行为层。

何时使用:任何需要按名字约定驱动角色动画、或需要在动画结束时自动切换到下一个动画的场景,都会经由这两个类。

Architecture

Loading diagram...

图中实线箭头是本页从源码逐行验证过的关系:AnimClip 的 Javadoc 明确声明"一个 AnimClip 对应一个 Spine Animation";AnimComposer 构造函数接收 AnimationState 并注册完成监听器;AnimData 通过 animClip() 暴露其剪辑。虚线箭头表示行为层与剪辑层的协作方向(同包内的协作关系,其内部实现属于行为页面)。

设计意图

  • 元数据 vs 数据分离:AnimClip 只存"剪辑是什么"(名称解析结果 + 时长),不存播放状态;播放状态(playing)集中在 AnimComposer。这让同一个 AnimClip 可被多个角色实例安全共享。
  • 单核心轨道:coreTrackId = 0 硬编码为常量,offer() 每次都用 setAnimation 覆盖该轨道,而不是叠加轨道。牺牲了混合过渡能力,换来的是"行为层给出的动画必然是当前唯一正在播放的核心动画"这一强不变量,简化了行为切换的正确性推理。

剪辑元数据:AnimClip

AnimClip 的公共字段构成了一个剪辑的全部静态描述:

java
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:Spine Animation 的原始名字(含阶段/修饰符后缀)。
  • baseName:去掉阶段后缀后的基础名(用于与 AnimType.pattern 匹配)。
  • type / modifier / stage:三个枚举化元数据,见下文。
  • duration:剪辑时长(秒),从 Spine 动画数据中取得,供上层做节奏控制。

注意:本页未覆盖 AnimClip 的构造函数与工厂方法实现(源码探索预算限制),但其产出契约——上述六个 final 字段——是稳定且完整的。

AnimType:动画类型

每个类型绑定一个大小写不敏感的正则与一个渲染用 y 轴偏移量:

java
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

设计意图有两点值得注意:

  1. 容错命名:{0,2} 允许名字尾部带最多两个字符的阶段编号(如 IdleC1、Move02),这是明日方舟干员立绘动画命名风格的直接体现;IDLE 同时接受 Idle/Idlle/Relax 等变体,REVIVE 同时接受 Reborn,都是为了兼容不同批次模型素材的命名漂移。
  2. offsetY 与类型耦合:坐(50)与睡(25)需要更大的 y 轴下移偏移以贴合地面,这个渲染参数被放在类型枚举里而非渲染配置中,因为它是动画语义的一部分——同一个类型在任何模型上都应有相同的落地偏移。

AnimModifier:起止修饰符

java
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 是一个静态嵌套类而非枚举,因为阶段编号是开放集合:

java
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 行,但完整实现了"提交-完成-衔接"生命周期:

java
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)是匿名内部类的标准写法。完成回调的判定链条是本类最精细的部分:

  1. playing != null —— 防御 reset() 之后到达的迟到回调;
  2. entry.getAnimation() != null —— 防御空轨道条目;
  3. entry.getAnimation().getName().equals(playing.animClip().fullName) —— 用动画全名确认"完成的确实是当前正在播放的剪辑",防止旧轨道条目的完成事件误触发衔接;
  4. !completed.isLoop() —— 循环剪辑永不自然结束,无需衔接。

通过校验后执行 reset()(清空轨道、置空 playing),再检查 animNext():若存在后续动画数据则立即 offer() 提交。这就是 Begin → Loop → End 三段式动作的自动串联机制。

offer / reset:唯一的播放入口

java
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

Loading diagram...

流程要点:

  • 衔接(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()幂等性判定(避免同动画重复提交)

命名解析决策流

Loading diagram...

{0,2}$ 尾部通配的存在意味着阶段号在 AnimType 匹配阶段就被容忍(IdleC1 仍命中 IDLE),而 AnimStage 再把 C1 这一段独立解析成 id = 1——两层正则各司其职,避免一次解析承担全部命名变体。

Configuration Options

本模块没有外部配置文件;其"配置"内嵌在枚举常量中,修改需改源码并重新编译:

常量类型默认值说明
coreTrackIdint0AnimComposer 硬编码的核心轨道号,所有核心动画都在 Spine 轨道 0 播放
AnimType.NONE.offsetYint5未识别类型的默认 y 轴偏移
AnimType.SIT.offsetYint50坐姿动画下移量(最大)
AnimType.SLEEP.offsetYint25睡姿动画下移量
AnimType.*.patternPattern见上文代码块大小写不敏感的类型识别正则
AnimModifier.*.patternPattern见上文代码块起止段识别正则
AnimCommonStage.*.patternPattern见上文代码块阶段号识别正则

应用级配置(如帧率、渲染参数)由 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 公共字段(只读)

字段类型说明
fullNameStringSpine 动画原始全名
baseNameString剥离阶段后的基础名
typeAnimClip.AnimType动画类型(含 pattern、offsetY)
modifierAnimClip.AnimModifier起止段修饰符
stageAnimClip.AnimStage阶段包装对象
durationfloat剪辑时长(秒)

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

  1. onApply(AnimData) 钩子:子类化 AnimComposer 并覆写此方法,可在不触碰播放判定逻辑的前提下挂接 UI 状态同步、行为记录等逻辑。
  2. 新增 AnimType:在枚举中追加 (pattern, offsetY) 常量即可让新命名约定被识别,无需改动解析调用方。
  3. AnimData.animNext() 链:行为层通过构造带 animNext 的 AnimData 即可声明任意长度的动画串联,AnimComposer 无需感知链的长度。

Tests

仓库探索预算内未定位到针对 animations 包的独立测试文件,无法在本页给出测试覆盖结论;AnimStage 的容错分支(id = -1 + Logger.warn)与 offer 的三条拒绝路径是源码中可静态验证的关键边界,建议以此作为补测优先级。

  • 行为决策与状态机(Behavior、GeneralBehavior、StochasticMatrix、AnimClipGroup、AnimDataWeight):见"动画行为"兄弟页面。
  • 模型资源加载(SkeletonLoader、ModelItem、ModelsDataset):见模型资源页面。
  • 渲染与着色(assets/shaders/):见渲染页面。
  • 源文件:AnimClip.java、AnimComposer.java