Repository Wiki
isHarryh/Ark-Pets

窗口位置过渡动画

窗口位置过渡动画是 ArkPets 桌面宠物运行时中负责"窗口坐标平滑插值"的子系统:当宠物窗口被移动(如用户拖拽、多开排布)时,窗口并不瞬间跳变到新坐标,而是由 cn.harryh.arkpets.transitions 包中的过渡(Transition)框架按可配置的缓动函数与时长,从当前位置平滑滑向目标位置。

Purpose and Scope

本页完整覆盖窗口位置过渡动画能力的端到端实现,包括:

  • cn.harryh.arkpets.transitions 包的泛型过渡框架:抽象基类 Transition<E>、窗口坐标实现 TransitionVector2,以及同包的兄弟实现 TransitionVector3、TransitionFloat;
  • 缓动函数体系:函数式接口 TernaryFunction 与枚举 EasingFunction(LINEAR / EASE_OUT_SINE / EASE_OUT_CUBIC / EASE_OUT_QUINT);
  • 运行时接入点:ArkPets 实例中的 windowPosition 字段的构造与配置解析;
  • 用户可配置项:transition_duration(过渡时长)与 transition_type(缓动类型),以及默认配置与控制面板 UI 控件的对应关系。

以下相关主题有意留给兄弟页面,本页仅做边界提示:

  • 动画交叉过渡(configTransitionAnimation,即 Spine 动画 Clip 之间的淡入淡出)属于角色行为/动画系统,与本文的"属性/坐标插值"是两套机制,请参见动画与行为相关页面;
  • 窗口的创建、透明、置顶、点击穿透等窗口管理细节,请参见窗口管理相关页面;
  • 配置文件的加载、校验与持久化全流程,请参见配置系统页面。

Overview

在桌宠场景中,窗口位置变更十分频繁:用户用鼠标拖拽宠物、多开时窗口排布调整等。若窗口坐标直接跳变,视觉上会显得生硬。ArkPets 的解法是一个可中断、可插值的数值过渡框架:

  • 核心抽象:Transition<E> 用"起点值 start + 终点值 end + 当前进度 currentProgress + 总进度 totalProgress"四元组描述一次过渡。进度以时间增量推进(addProgress),任意时刻可取插值结果(now() → atProgress())。
  • 坐标实现:TransitionVector2 将 Transition<E> 落到 libGDX 的 Vector2(即窗口的 x/y 坐标),对 x、y 两个轴分别套用同一个缓动函数。
  • 缓动函数:EasingFunction 枚举以 lambda 形式内置 4 条"ease-out"系曲线,让过渡"先快后慢",模拟物理惯性感,默认 EASE_OUT_CUBIC、默认时长 0.3(秒)。
  • 可中断性:reset() 是该设计的关键——当目标值变化时,新起点不是旧起点而是当前插值位置 now(),因此连续拖拽时动画始终从"此刻画面所在处"继续,不会回跳。

该框架自 ArkPets 2.3 起引入(Transition 类 Javadoc 标注 @since ArkPets 2.3),除窗口位置外还供其它数值属性过渡复用(同包提供了 TransitionFloat、TransitionVector3)。

Architecture

组件关系

Loading diagram...

图中各组件的职责与连接依据:

  • 配置层:默认配置文件提供 transition_duration: 0.3 与 transition_type: "EASE_OUT_CUBIC" 两个键;ArkConfig.getEasingFunctionFrom(config.transition_type) 负责把字符串解析为 EasingFunction 枚举实例。两者在 ArkPets 初始化第 4 步("Window position setup")一起注入 TransitionVector2 构造器。
  • transitions 包:Transition<E> 是纯状态机式的抽象基类(不感知窗口、不感知渲染线程);TransitionVector2 持有一个 EasingFunction 并在 atProgress() 中对 x/y 轴分别插值;EasingFunction 通过实现 TernaryFunction<Float, Float> 接口获得统一的 apply(begin, end, progress) 调用形态。
  • 运行时:ArkPets 实例持有唯一的 windowPosition 过渡对象;位置变更事件调用 reset() 重设目标;窗口定位消费 now() 的插值结果。

类型层次

Loading diagram...

设计意图说明:

  • 模板方法模式:Transition<E> 把"进度推进、钳制、重设、查询"等通用状态逻辑全部用 final 方法封死,只把 atProgress() 这一个"如何插值"的纯函数留给子类。这保证了任何新增类型(如 TransitionFloat、TransitionVector3)都无法破坏进度状态机的不变量(进度永远在 [0, totalProgress] 内)。
  • 策略模式(枚举即策略):缓动曲线被建模为枚举而非类层次,新增一条曲线只需加一行 lambda;EasingFunction 同时实现 TernaryFunction<Float, Float>,使其可以脱离枚举类型以函数形态被传递。
  • 泛型复用:窗口位置只是该框架的一个消费者;坐标(Vector2/Vector3)与标量(Float)共用同一套状态机。

核心机制:Transition 状态机与可中断插值

状态字段与不变量

Transition<E> 的全部状态由四个字段构成:起点 start、终点 end、当前进度 currentProgress、总进度 totalProgress。两个核心不变量由 final 方法保证:

  1. 进度非负、不越界:setCurrentProgress() 使用 Math.max(0, Math.min(totalProgress, currentProgress)) 双向钳制,totalProgress 本身也被 Math.max(0, totalProgress) 归一化为非负数。因此即使调用方传入超前的 delta,也只会停在终点而不会越界。
  2. totalProgress <= 0 时无动画:这是刻意的退化路径——当用户把 transition_duration 配置为 0(或负数被钳为 0)时,atProgress() 直接返回 end,窗口坐标立即等于目标值,等效于"关闭过渡动画"。

逐帧推进:addProgress → now → isEnded

运行时每帧以渲染循环的 delta 时间调用 addProgress(delta),再消费 now() 得到当前应显示的坐标。三者的协作如下:

java
1public final E now() { 2 return atProgress(currentProgress); 3} 4 5public final boolean isEnded() { 6 return Objects.equals(now(), end()); 7} 8 9public final void addProgress(float progress) { 10 setCurrentProgress(currentProgress + progress); 11}

Source: Transition.java

isEnded() 的实现值得一提:它比较的是 now() 与 end(),即"当前插值结果等于终点"。对于 TransitionVector2 而言,当进度达到 1 时所有枚举缓动函数都恰好返回 end,因此 isEnded() 自然成立;这也意味着调用方无须自己判断进度,直接语义化地判断"动画是否到位"即可。

可中断的关键:reset 以当前插值位置为新起点

reset() 是整个框架最有设计感的部分:

java
1public final void reset(E end) { 2 if (Objects.equals(this.end, end)) 3 return; 4 this.start = now(); 5 this.end = end; 6 currentProgress = 0; 7}

Source: Transition.java

三个细节决定了"连续拖拽不回跳、不抖动"的体验:

  • 幂等短路:若新目标与当前目标相同(Objects.equals),直接返回,不做任何状态改动——避免渲染循环里每帧重复 reset 引起的进度归零抖动。
  • 新起点 = now() 而非旧 start:这保证了过渡永远从"用户此刻看到的画面位置"继续。若新起点取旧 start,快速连续改变目标会导致窗口先向后退再前进。
  • 进度清零:每次目标变化都重开一段全新时长的过渡,配合 ease-out 曲线形成"追手"的跟手感。

TransitionVector2 额外提供了标量形式的便利重载:

java
public void reset(float x, float y) { reset(new Vector2(x, y)); }

Source: TransitionVector2.java

缓动函数:EasingFunction

EasingFunction 是一个实现 TernaryFunction<Float, Float> 的枚举,签名统一为 apply(begin, end, progress)。其全部四条曲线均为"ease-out"族(先快后慢),契合桌宠被"抛出"后减速停靠的直觉:

java
1public enum EasingFunction implements TernaryFunction<Float, Float> { 2 /** Linear function. */ 3 LINEAR((b, e, p) -> b + p * (e - b)), 4 5 /** <a href="https://easings.net/#easeOutSine">Sine easing out function</a> */ 6 EASE_OUT_SINE((b, e, p) -> b + (float) Math.sin(p * Math.PI / 2) * (e - b)), 7 8 /** <a href="https://easings.net/#easeOutCubic">Cubic easing out function</a> */ 9 EASE_OUT_CUBIC((b, e, p) -> b + (1 - (float) Math.pow(1 - p, 3)) * (e - b)), 10 11 /** <a href="https://easings.net/#easeOutQuint">Quint easing out function</a> */ 12 EASE_OUT_QUINT((b, e, p) -> b + (1 - (float) Math.pow(1 - p, 5)) * (e - b)); 13 14 private final TernaryFunction<Float, Float> function; 15 16 EasingFunction(TernaryFunction<TernaryFunction<Float, Float>... function) { /* 见下文说明 */ } 17}

Source: EasingFunction.java

(上段为节选示意,实际构造器签名为 EasingFunction(TernaryFunction<Float, Float> function),见源文件第 24–26 行;apply 委托给持有的 lambda,见第 28–31 行。)

数学上所有曲线都写成统一形式 b + f(p) * (e - b),其中 f(0) = 0、f(1) = 1:

枚举值f(p)手感
LINEARp匀速直线,无惯性感
EASE_OUT_SINEsin(p·π/2)前段极快,长尾极缓
EASE_OUT_CUBIC(默认)1-(1-p)³快出慢停的均衡曲线
EASE_OUT_QUINT1-(1-p)⁵起步更猛、收尾更拖

设计意图:把缓动建模为枚举 + 函数式接口,而非类继承——曲线集合是封闭的(运行期不需要第三方注入新曲线),而调用侧(TransitionVector2.atProgress)只依赖 TernaryFunction 的 apply(b, e, p) 形态,未来若要支持自定义曲线,只需提供别的 TernaryFunction 实现即可,无需改动过渡框架。

坐标插值实现:TransitionVector2

java
1public class TransitionVector2 extends Transition<Vector2> { 2 protected final EasingFunction easing; 3 4 public TransitionVector2(EasingFunction easingFunction, float totalProgress) { 5 super(totalProgress); 6 easing = easingFunction; 7 start = new Vector2(0, 0); 8 end = new Vector2(0, 0); 9 } 10 11 @Override 12 public Vector2 atProgress(float progress) { 13 if (totalProgress <= 0) 14 return end; 15 float ratio = currentProgress / totalProgress; 16 return new Vector2( 17 easing.apply(start.x, end.x, ratio), 18 easing.apply(start.y, end.y, ratio) 19 ); 20 } 21}

Source: TransitionVector2.java

实现要点:

  • 逐轴独立插值:x、y 使用同一进度比例 ratio = currentProgress / totalProgress,但分别代入缓动函数。这意味着二维轨迹在 ease-out 下是"先大步后小步"的对角收敛,而不是弧线。
  • 每次返回新对象:atProgress() 每次都 new Vector2(...),避免把内部 start/end 泄露给消费方(libGDX 的 Vector2 是可变对象,返回内部引用会破坏 Objects.equals 的幂等短路与状态一致性)。
  • 构造初值:起点/终点都初始化为 (0, 0);由于 reset() 的幂等短路,首次把目标设为 (0, 0) 时不会触发重置,这是无害的。
  • 注意一个微妙点:方法签名接收 progress 参数,但实现读取的是字段 currentProgress;框架内所有调用路径(now())传的就是 currentProgress,因此行为一致。阅读源码时不要被参数名误导。

核心流程:一次窗口移动的完整时序

Loading diagram...

流程解读:

  1. 初始化:ArkPets 启动流程的第 4 步 "Window position setup" 构造 windowPosition(见下节配置接入)。
  2. 目标变更:拖拽/排布逻辑调用 reset(x, y);若与上次目标相同则被短路,否则以当前插值位置为新起点、清零进度。
  3. 逐帧收敛:渲染循环以真实帧间隔推进进度并取插值结果应用到窗口坐标;由于进度被钳制,动画到 totalProgress 即自然结束。
  4. 再次变更:任意时刻再次 reset 都从"当前画面位置"继续,实现可中断的跟手动画。

运行时接入与配置

ArkPets 中的构造

java
1public TransitionVector2 windowPosition; // Window Position Easing 2 3// ... 初始化第 4 步 Window position setup: 4windowPosition = new TransitionVector2( 5 ArkConfig.getEasingFunctionFrom(config.transition_type), 6 Math.max(0, config.transition_duration) 7);

Source: ArkPets.java

(构造调用见第 105–108 行;Math.max(0, ...) 在传入前再次把时长归一化为非负,与 Transition.setTotalProgress 的钳制构成双重保险。)

默认配置

json
"transition_duration":0.3, "transition_type":"EASE_OUT_CUBIC",

Source: ArkPetsConfigDefault.json

用户界面入口

控制面板"行为模块"(BehaviorModule.fxml)中与本能力直接相关的三行控件为:configTransitionDuration(常规属性过渡时长)、configTransitionFunction(缓动函数)及其帮助按钮:

xml
1<HBox> 2 <Label fx:id="configTransitionDurationLabel" text="常规属性过渡"/> 3 <JFXComboBox fx:id="configTransitionDuration" prefWidth="120.0"/> 4 <JFXButton fx:id="configTransitionDurationHelp"/> 5</HBox> 6<HBox> 7 <Label fx:id="configTransitionFunctionLabel" text="缓动函数"/> 8 <JFXComboBox fx:id="configTransitionFunction" prefWidth="120.0"/> 9 <JFXButton fx:id="configTransitionFunctionHelp"/> 10</HBox>

Source: BehaviorModule.fxml

Configuration Options

配置项类型默认值说明
transition_durationnumber(float,秒)0.3过渡总时长;≤ 0 时钳制为 0,atProgress() 直接返回终点,等效关闭过渡
transition_typestring(枚举名)"EASE_OUT_CUBIC"缓动曲线,可选 LINEAR / EASE_OUT_SINE / EASE_OUT_CUBIC / EASE_OUT_QUINT;由 ArkConfig.getEasingFunctionFrom() 解析

Source: ArkPetsConfigDefault.json

注意区分:同一 UI 面板中的 configTransitionAnimation("动画交叉过渡")控制的是角色 Spine 动画 Clip 的交叉淡化,与本页的属性/坐标过渡是不同机制,本页不展开。

API Reference

Transition<E>(抽象基类)

构造器:Transition(float totalProgress) —— 初始化时调用 setTotalProgress,进度从 0 开始。

方法签名返回说明
atProgress(float progress)E(抽象)在给定进度处取插值值;约定 totalProgress ≤ 0 时必须返回 end
start() / end()E(final)读取起点 / 终点
now()E(final)atProgress(currentProgress) 的语义化封装
isEnded()boolean(final)Objects.equals(now(), end())
addProgress(float progress)void(final)推进进度并自动钳制到 [0, totalProgress]
reset(E end)void(final)更新终点、以 now() 为新起点、清零进度;目标相同则短路
setCurrentProgress(float) / setTotalProgress(float)void(final)直接设置进度 / 总进度(均做非负钳制;改总进度会清零当前进度)
setToEnd() / setToStart()void(final)跳到终点 / 起点

TransitionVector2

  • 构造器:TransitionVector2(EasingFunction easingFunction, float totalProgress) —— 起止均初始化为 (0, 0)。
  • Vector2 atProgress(float progress):totalProgress ≤ 0 时返回 end;否则按 ratio = currentProgress / totalProgress 对 x、y 分别套用 easing.apply(start.axis, end.axis, ratio),返回新建的 Vector2。
  • void reset(float x, float y):标量重载,委托给 reset(new Vector2(x, y))。

EasingFunction implements TernaryFunction<Float, Float>

  • Float apply(Float a, Float b, Float c):即 apply(begin, end, progress),返回插值分量;无异常抛出,浮点精度范围内 apply(b, e, 0) = b、apply(b, e, 1) = e。

失败模式、边界情况与并发

  • 时长为 0 / 负数:Math.max(0, ...) 在 ArkPets 接入处与 setTotalProgress 内双重钳制;TransitionVector2.atProgress 的 totalProgress <= 0 分支直接返回 end,不会出现除以 0 产生 NaN 坐标的情况。
  • 进度越界:setCurrentProgress 的双向钳制保证 ratio ∈ [0, 1],缓动函数在该区间内输出有界,窗口不会越过目标点。
  • NaN / 非法坐标输入:源码未对 reset 的入参做数值合法性校验(如 NaN、超屏坐标),依赖上游窗口管理逻辑提供合法目标;属于已知边界,未做防御。
  • 连续高频 reset:reset 的 Objects.equals 幂等短路 + 以 now() 为新起点,保证了高频目标变更不会回跳或抖动;但每帧目标都不同(真拖拽)时,进度永远清零、曲线永远处于起步段,等效为"贴近直线的快速跟动",这是刻意的行为而非缺陷。
  • 并发:Transition 及其子类的字段均无 synchronized/volatile 保护,start/end 也没有锁。其线程安全前提是单线程(渲染循环)内推进与消费。若在非渲染线程调用 reset 而渲染线程同时在 addProgress,可能出现撕裂读(读到新 end 配旧 start)。就现有接入方式(ArkPets.windowPosition 由渲染循环驱动)而言是安全的;扩展复用该框架时必须保持"单一驱动线程"约定。
  • 对象分配压力:atProgress() 每次 new Vector2,在 60 FPS 下的每帧一次分配量级对 JavaFX/libGDX 场景可忽略,但这是为"可变性隔离"付出的有意识代价(见上文设计说明)。

性能与运维注意点

  • 过渡时长与曲线均由用户在控制面板调整并持久化到配置文件;排查"窗口跟手不顺滑/太拖沓"类问题时,优先检查 transition_duration(是否被设为 0 或过大)与 transition_type(EASE_OUT_QUINT 收尾明显比 LINEAR 长)。
  • setTotalProgress 会清零当前进度,运行期动态修改时长会打断进行中的过渡(画面停在当前插值位置,然后从该处重新起跳——因为下次 reset 仍以 now() 为起点)。若需要"热改时长不打断",需先取 now() 再手动恢复进度,当前 API 未提供该能力。
  • 该框架无 I/O、无系统调用,开销集中在每帧一次的浮点运算与一次 Vector2 分配,性能敏感度低。

Extension Points

  • 新增值类型:继承 Transition<E>,只需实现 atProgress();进度状态机由基类 final 方法统一保证。包内已有 TransitionFloat、TransitionVector3 作为范例。
  • 自定义缓动曲线:TransitionVector2 的字段类型是 EasingFunction(具体枚举而非接口),因此新增曲线需在枚举中添加一个 lambda 分支——一行改动即可全站生效;若要支持用户自定义函数对象,可将字段放宽为 TernaryFunction<Float, Float>(枚举本身已实现该接口,向后兼容)。
  • 新的窗口属性过渡:透明度、缩放等标量属性可复用 TransitionFloat,接入方式与 windowPosition 相同(构造 → reset 目标 → 每帧 addProgress/now)。

角色 Spine 动画之间的交叉过渡(动画交叉过渡)属于行为/动画系统,不在本页范围;窗口创建与样式管理请参见窗口管理相关页面。