545 lines
21 KiB
TypeScript
545 lines
21 KiB
TypeScript
import { isKindWith } from '../properties/kinds';
|
||
import { type PropertyDef, schemaFor, shaderParamRows } from '../properties/PropertySchema';
|
||
import { shaderOf } from '../properties/shaders';
|
||
import type { AnimationAsset, AnimationKey, AnimationTrack, Asset, KeyInterpolation } from './Asset';
|
||
import type { AssetType } from './AssetTypes';
|
||
import { treeOrder } from './hierarchy';
|
||
import { instanceChildId, resolveObjects } from './instance';
|
||
import { findAsset, type Project } from './Project';
|
||
import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties';
|
||
import type { SceneObject } from './SceneObject';
|
||
|
||
/** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */
|
||
export const PLAYBACKS = ['loop', 'once', 'once-reset', 'pingpong'] as const;
|
||
export type Playback = (typeof PLAYBACKS)[number];
|
||
|
||
/** What an animation can be built over: something placed, or a whole scene. */
|
||
export const ANIMATION_ROOT_TYPES: readonly AssetType[] = ['sprite', 'text', 'container', 'scene'];
|
||
|
||
/** Position lives on the object, not in its property bag, so the timeline offers it by name. */
|
||
const POSITION_KEYS = ['x', 'y'] as const;
|
||
|
||
function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] {
|
||
return (POSITION_KEYS as readonly string[]).includes(key);
|
||
}
|
||
|
||
export interface PlayheadAt {
|
||
/** Frames since the start, fractional: a value between two keys is blended at this point. */
|
||
frame: number;
|
||
/** Only the two `once` modes ever finish. */
|
||
finished: boolean;
|
||
/** On ping-pong's way back: a value that jumps holds the key the playhead passed last – the one above. */
|
||
reverse: boolean;
|
||
}
|
||
|
||
/** Slack for `time * fps`: six ticks of 1/60 s sum to 0.09999999999999999, which at 10 fps is frame 1. */
|
||
const STEP_EPSILON = 1e-6;
|
||
|
||
export function playbackOf(asset: AnimationAsset): Playback {
|
||
const value = textOf(asset.props.playback ?? 'loop');
|
||
return (PLAYBACKS as readonly string[]).includes(value) ? (value as Playback) : 'loop';
|
||
}
|
||
|
||
export function fpsOf(asset: AnimationAsset): number {
|
||
const fps = Number(asset.props.fps);
|
||
return Number.isFinite(fps) && fps > 0 ? fps : 0;
|
||
}
|
||
|
||
/** The frame of the last key over all rows; -1 for an animation without keys. */
|
||
function lastKeyFrame(asset: AnimationAsset): number {
|
||
let last = -1;
|
||
for (const track of asset.tracks) {
|
||
const key = track.keys[track.keys.length - 1];
|
||
if (key && key.frame > last) last = key.frame;
|
||
}
|
||
return last;
|
||
}
|
||
|
||
/**
|
||
* How many frames one pass lasts: the last key's frame is a frame of its own, so keys on 0…9 are
|
||
* ten frames – at 10 fps one second. A pause at the end is a last key that repeats the value.
|
||
*/
|
||
export function frameCount(asset: AnimationAsset): number {
|
||
return lastKeyFrame(asset) + 1;
|
||
}
|
||
|
||
export function animationDuration(asset: AnimationAsset): number {
|
||
const fps = fpsOf(asset);
|
||
return fps > 0 ? frameCount(asset) / fps : 0;
|
||
}
|
||
|
||
/** Where the playhead stands `time` seconds after the start, for a pass of `count` frames. */
|
||
export function playheadAt(count: number, fps: number, playback: Playback, time: number): PlayheadAt {
|
||
if (count <= 0)
|
||
return { frame: 0, finished: playback === 'once' || playback === 'once-reset', reverse: false };
|
||
if (!Number.isFinite(fps) || fps <= 0 || !(time > 0)) return { frame: 0, finished: false, reverse: false };
|
||
const at = time * fps + STEP_EPSILON;
|
||
switch (playback) {
|
||
case 'loop':
|
||
return { frame: at % count, finished: false, reverse: false };
|
||
case 'once':
|
||
case 'once-reset':
|
||
return at >= count
|
||
? { frame: count, finished: true, reverse: false }
|
||
: { frame: at, finished: false, reverse: false };
|
||
case 'pingpong': {
|
||
// Turns on the last key, so neither end is shown twice: keys on 0…9 play 0…9…1 in 18 frames (#112).
|
||
const last = count - 1;
|
||
if (last <= 0) return { frame: 0, finished: false, reverse: false };
|
||
const phase = at % (2 * last);
|
||
return phase < last
|
||
? { frame: phase, finished: false, reverse: false }
|
||
: { frame: 2 * last - phase, finished: false, reverse: true };
|
||
}
|
||
}
|
||
}
|
||
|
||
export function animationPlayheadAt(asset: AnimationAsset, time: number): PlayheadAt {
|
||
return playheadAt(frameCount(asset), fpsOf(asset), playbackOf(asset), time);
|
||
}
|
||
|
||
/** Only these blend between keys; every other kind holds its value until the next key. */
|
||
export function blends(kind: string): boolean {
|
||
return kind === 'number' || kind === 'color';
|
||
}
|
||
|
||
function lerpColor(a: number, b: number, t: number): number {
|
||
const channel = (shift: number) => {
|
||
const from = (a >> shift) & 0xff;
|
||
const to = (b >> shift) & 0xff;
|
||
return Math.round(from + (to - from) * t) << shift;
|
||
};
|
||
return channel(16) | channel(8) | channel(0);
|
||
}
|
||
|
||
/** In the order the inspector offers them; `linear` first, since it is what an absent field means. */
|
||
export const KEY_INTERPOLATIONS: readonly { value: KeyInterpolation; label: string }[] = [
|
||
{ value: 'linear', label: 'Linear' },
|
||
{ value: 'step', label: 'Step' },
|
||
{ value: 'ease-in', label: 'Ease in' },
|
||
{ value: 'ease-out', label: 'Ease out' },
|
||
{ value: 'ease-in-out', label: 'Ease in-out' },
|
||
];
|
||
|
||
export function isKeyInterpolation(value: unknown): value is KeyInterpolation {
|
||
return KEY_INTERPOLATIONS.some((i) => i.value === value);
|
||
}
|
||
|
||
export function interpolationOf(key: AnimationKey): KeyInterpolation {
|
||
return key.interpolation ?? 'linear';
|
||
}
|
||
|
||
/**
|
||
* How far along the way to the next key a value is, `t` the share of the time passed. The eases are
|
||
* quadratic: ease-in reaches 0.25 at half the time, ease-out 0.75, ease-in-out 0.5 but 0.125 at a quarter.
|
||
*/
|
||
function easedShare(interpolation: KeyInterpolation, t: number): number {
|
||
switch (interpolation) {
|
||
case 'linear':
|
||
return t;
|
||
case 'step':
|
||
return 0;
|
||
case 'ease-in':
|
||
return t * t;
|
||
case 'ease-out':
|
||
return 1 - (1 - t) * (1 - t);
|
||
case 'ease-in-out':
|
||
return t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t);
|
||
}
|
||
}
|
||
|
||
/** The value between two keys, `t` from 0 (at `a`) to 1 (at `b`). */
|
||
export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t: number): PropertyValue {
|
||
if (kind === 'number') {
|
||
const from = Number(a);
|
||
const to = Number(b);
|
||
return Number.isFinite(from) && Number.isFinite(to) ? from + (to - from) * t : a;
|
||
}
|
||
if (kind === 'color') return lerpColor(colorOf(a, 0), colorOf(b, 0), t);
|
||
return a;
|
||
}
|
||
|
||
/**
|
||
* A row's value at a (fractional) frame: the first value before the first key, the last after the
|
||
* last one. Between two keys the earlier one's interpolation decides the way. A value that jumps –
|
||
* one that cannot blend, or a `step` key – holds until the next key in the direction of play: played
|
||
* in `reverse`, that is the key above. `undefined` only for a row without keys.
|
||
*/
|
||
export function trackValueAt(
|
||
keys: readonly AnimationKey[],
|
||
kind: string,
|
||
frame: number,
|
||
reverse = false,
|
||
): PropertyValue | undefined {
|
||
const first = keys[0];
|
||
if (!first) return undefined;
|
||
if (frame <= first.frame) return first.value;
|
||
for (let i = 1; i < keys.length; i++) {
|
||
const next = keys[i]!;
|
||
if (frame >= next.frame) continue;
|
||
const prev = keys[i - 1]!;
|
||
const interpolation = interpolationOf(prev);
|
||
if (!blends(kind) || interpolation === 'step')
|
||
return reverse && frame > prev.frame ? next.value : prev.value;
|
||
const t = (frame - prev.frame) / (next.frame - prev.frame);
|
||
return blendValue(kind, prev.value, next.value, easedShare(interpolation, t));
|
||
}
|
||
return keys[keys.length - 1]!.value;
|
||
}
|
||
|
||
/**
|
||
* What a row writes at a frame: its value, or for a relative number row that value added to `base`,
|
||
* what the target held when the animation started. Shared by the runtime player and the tab's
|
||
* preview, so both show the same picture. `undefined` for a row without keys.
|
||
*/
|
||
export function trackWriteAt(
|
||
track: AnimationTrack,
|
||
kind: string,
|
||
frame: number,
|
||
base: PropertyValue | undefined,
|
||
reverse = false,
|
||
): PropertyValue | undefined {
|
||
const value = trackValueAt(track.keys, kind, frame, reverse);
|
||
if (value === undefined) return undefined;
|
||
const offset = track.relative && kind === 'number' && typeof base === 'number';
|
||
return offset ? base + Number(value) : value;
|
||
}
|
||
|
||
/** The kind a row's property has on an asset of that type; undefined for one the timeline cannot animate. */
|
||
export function animatedKind(type: AssetType, key: string): string | undefined {
|
||
return animatableDefs(type).find((d) => d.key === key)?.kind;
|
||
}
|
||
|
||
/** The kind `key` has on an asset holding `bag` – see `animatableDefsOf`. */
|
||
export function animatedKindOf(
|
||
project: Project,
|
||
asset: Asset,
|
||
bag: PropertyBag,
|
||
key: string,
|
||
): string | undefined {
|
||
return animatableDefsOf(project, asset, bag).find((d) => d.key === key)?.kind;
|
||
}
|
||
|
||
function hasPosition(type: AssetType): boolean {
|
||
return type !== 'scene' && type !== 'behavior';
|
||
}
|
||
|
||
/** Whether a row sets the object's position rather than a value in its bag – never a behavior's `x`. */
|
||
export function isPositionRow(type: AssetType, key: string): key is (typeof POSITION_KEYS)[number] {
|
||
return hasPosition(type) && isPositionKey(key);
|
||
}
|
||
|
||
function isFlat(def: PropertyDef): boolean {
|
||
return def.kind !== 'list' && def.kind !== 'struct';
|
||
}
|
||
|
||
/**
|
||
* The rows the timeline offers for an asset type: position, then the schema's flat properties. A
|
||
* list or struct has no single value to key. A scene is placed nowhere and a behavior drawn nowhere,
|
||
* so neither offers a position – a behavior's variable `x` is the variable.
|
||
*/
|
||
export function animatableDefs(type: AssetType): PropertyDef[] {
|
||
const own = schemaFor(type).filter(isFlat);
|
||
if (!hasPosition(type)) return own;
|
||
const position: PropertyDef[] = POSITION_KEYS.map((key) => ({
|
||
key,
|
||
label: key.toUpperCase(),
|
||
kind: 'number',
|
||
default: 0,
|
||
}));
|
||
return [...position, ...own];
|
||
}
|
||
|
||
/**
|
||
* The rows one asset offers when it holds `bag`: its type's, then what the asset itself declares (#104)
|
||
* – a behavior's variables, a sprite's parameters of the shader the bag names. Which shader that is
|
||
* can differ per object, and at runtime per frame, hence the bag. A declared row never shadows a row
|
||
* of the type.
|
||
*/
|
||
export function animatableDefsOf(
|
||
project: Project,
|
||
asset: Asset,
|
||
bag: PropertyBag = asset.props,
|
||
): PropertyDef[] {
|
||
const own = animatableDefs(asset.type);
|
||
const declared =
|
||
asset.type === 'behavior'
|
||
? asset.variables
|
||
: asset.type === 'sprite'
|
||
? shaderParamRows(project, shaderOf(bag))
|
||
: [];
|
||
const taken = new Set(own.map((d) => d.key));
|
||
return [...own, ...declared.filter((d) => isFlat(d) && !taken.has(d.key))];
|
||
}
|
||
|
||
/**
|
||
* The id of the one object a placed root stands on – the preview's holder, and the instance the
|
||
* targets inside a container are resolved below.
|
||
*/
|
||
export const PLACED_ROOT_ID = 'preview-root';
|
||
|
||
/** The root placed at the origin, its content following the template like any placed container's. */
|
||
export function placedRoot(root: Asset): SceneObject {
|
||
return {
|
||
id: PLACED_ROOT_ID,
|
||
assetId: root.id,
|
||
name: root.name,
|
||
position: { x: 0, y: 0, z: 0 },
|
||
editorOffset: { x: 0, y: 0 },
|
||
overrides: {},
|
||
parentId: '',
|
||
instanceEdits: {},
|
||
};
|
||
}
|
||
|
||
/** One object a row may aim at below the root, under the id the row stores. */
|
||
export interface AnimationTarget {
|
||
id: string;
|
||
object: SceneObject;
|
||
depth: number;
|
||
}
|
||
|
||
/**
|
||
* Everything a row may aim at below the root, in tree order – into placed containers, too (#109).
|
||
* A scene's rows name its objects by the ids the running scene gives them, which a baked instance
|
||
* keeps. A container's rows name a path of template ids, what the runtime appends to whichever
|
||
* object the animation plays on. A sprite or text has nothing below it.
|
||
*/
|
||
export function animationTargets(project: Project, root: Asset | undefined): AnimationTarget[] {
|
||
if (root?.type === 'scene')
|
||
return treeOrder({ objects: resolveObjects(project, root) }).map(({ object, depth }) => ({
|
||
id: object.id,
|
||
object,
|
||
depth,
|
||
}));
|
||
if (root?.type !== 'container') return [];
|
||
const prefix = instanceChildId(PLACED_ROOT_ID, '');
|
||
return treeOrder({ objects: resolveObjects(project, { objects: [placedRoot(root)] }) })
|
||
.filter(({ object }) => object.id.startsWith(prefix))
|
||
.map(({ object, depth }) => ({ id: object.id.slice(prefix.length), object, depth: depth - 1 }));
|
||
}
|
||
|
||
/** What a row aims at: the asset, the object placing it (none for the root itself) and the bag it holds. */
|
||
interface Aimed {
|
||
asset: Asset;
|
||
object?: SceneObject;
|
||
bag: PropertyBag;
|
||
}
|
||
|
||
function aimedAt(project: Project, animation: AnimationAsset, target: string): Aimed | undefined {
|
||
const root = findAsset(project, animation.root);
|
||
if (target === '') return root && { asset: root, bag: root.props };
|
||
const object = animationTargets(project, root).find((t) => t.id === target)?.object;
|
||
const asset = object && findAsset(project, object.assetId);
|
||
return object && asset && { asset, object, bag: { ...asset.props, ...object.overrides } };
|
||
}
|
||
|
||
/** The asset a row aims at: the root itself, or what the object inside it places. Undefined once gone. */
|
||
export function targetAssetOf(
|
||
project: Project,
|
||
animation: AnimationAsset,
|
||
target: string,
|
||
): Asset | undefined {
|
||
return aimedAt(project, animation, target)?.asset;
|
||
}
|
||
|
||
/** The rows the object a row may aim at offers; undefined once that object is gone. */
|
||
export function targetDefs(
|
||
project: Project,
|
||
animation: AnimationAsset,
|
||
target: string,
|
||
): PropertyDef[] | undefined {
|
||
const aimed = aimedAt(project, animation, target);
|
||
return aimed && animatableDefsOf(project, aimed.asset, aimed.bag);
|
||
}
|
||
|
||
/**
|
||
* The property a row sets. Undefined when its object is gone – or has lost the property: a behavior
|
||
* taken off, a variable renamed, a shader swapped for one without the parameter all read as missing,
|
||
* and the keys stay.
|
||
*/
|
||
export function trackDef(
|
||
project: Project,
|
||
animation: AnimationAsset,
|
||
track: AnimationTrack,
|
||
): PropertyDef | undefined {
|
||
return targetDefs(project, animation, track.target)?.find((d) => d.key === track.property);
|
||
}
|
||
|
||
/** How the timeline names what a row aims at: the object (or the root itself) and the property. */
|
||
export function trackLabel(project: Project, animation: AnimationAsset, track: AnimationTrack): string {
|
||
const aimed = aimedAt(project, animation, track.target);
|
||
const who = !aimed ? '(missing)' : aimed.object ? aimed.object.name || aimed.asset.name : aimed.asset.name;
|
||
const def =
|
||
aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property);
|
||
return `${who} · ${def ? def.label : aimed ? `${track.property} (missing)` : track.property}`;
|
||
}
|
||
|
||
/** Whether a value names something in the project – what deleting that something has to count. */
|
||
export function referencesAny(
|
||
track: AnimationTrack,
|
||
kind: string | undefined,
|
||
ids: ReadonlySet<string>,
|
||
): boolean {
|
||
if (!kind || !isKindWith('reference', kind)) return false;
|
||
return track.keys.some((k) => typeof k.value === 'string' && ids.has(k.value));
|
||
}
|
||
|
||
/**
|
||
* One animation playing on one object. It lives on the object, off its property bag, like the
|
||
* emit counter: what the world writes back must not grow an override per frame. `bases` is what
|
||
* each row's target held when the first tick after the start reached it – the zero of a relative
|
||
* row and what `once-reset` puts back – keyed `<entity id> <property>`.
|
||
*/
|
||
export interface AnimationPlayer {
|
||
animationId: string;
|
||
time: number;
|
||
playing: boolean;
|
||
/** Start order across the whole run: of two players setting one property, the later start wins. */
|
||
order: number;
|
||
bases?: Record<string, PropertyValue>;
|
||
blend?: AnimationBlend;
|
||
/** Seconds a `once-reset` pass takes to blend back to its starting values when its frames run out. */
|
||
blendOut?: number;
|
||
fade?: AnimationFade;
|
||
}
|
||
|
||
/**
|
||
* A crossfade into a player that just took over (#106): over `seconds`, whatever the animation's frame
|
||
* rate, a row that blends mixes from what the property held into the player's value. `from` is taken
|
||
* on the first tick of the blend; a property an earlier player writes in the same tick is mixed from
|
||
* that value instead, live.
|
||
*/
|
||
export interface AnimationBlend {
|
||
seconds: number;
|
||
elapsed: number;
|
||
from?: Record<string, PropertyValue>;
|
||
}
|
||
|
||
/**
|
||
* A player on its way out (#106): over `seconds` a row that blends mixes from the player's value back
|
||
* to what lies below it – what an earlier player writes in the same tick, live, or else the value the
|
||
* row found at the start. Everything else goes back at once. Then the player is gone. `ended` marks
|
||
* the fade of a `once-reset` pass whose frames ran out: playing it again starts it over.
|
||
*/
|
||
export interface AnimationFade {
|
||
seconds: number;
|
||
elapsed: number;
|
||
ended?: boolean;
|
||
}
|
||
|
||
/** How far a blend or a fade has come, 0…1. */
|
||
export function blendShare(blend: { seconds: number; elapsed: number }): number {
|
||
if (!(blend.seconds > 0)) return 1;
|
||
return Math.min(1, blend.elapsed / blend.seconds);
|
||
}
|
||
|
||
interface PlayerHolder {
|
||
animations?: AnimationPlayer[];
|
||
}
|
||
|
||
let startCount = 0;
|
||
|
||
/**
|
||
* Starts the animation on the object, or lets a paused one run on. One already running keeps its
|
||
* frame, so a graph may ask for it every frame without holding it at its first frame – but it counts
|
||
* as started last again: turning back to a walk that still runs has to show that walk.
|
||
* `blendSeconds` crossfades into it – but only when it takes over: asked for again while it already
|
||
* leads on the object, it keeps the blend it has, so a graph calling every frame does not restart it.
|
||
* One fading out turns round: it blends back in from the value it has reached, without a jump – or,
|
||
* when a `once-reset` pass already ran out, starts over. `blendOutSeconds` is the fade at the end of
|
||
* a `once-reset` pass. `restart` starts a known one over from its first frame in any case – blended in
|
||
* from where it stands when `blendSeconds` is set – and keeps its starting values, so a relative row
|
||
* does not add up.
|
||
*/
|
||
export function playAnimation(
|
||
holder: PlayerHolder,
|
||
animationId: string,
|
||
blendSeconds = 0,
|
||
blendOutSeconds = 0,
|
||
restart = false,
|
||
): void {
|
||
const players = (holder.animations ??= []);
|
||
const known = players.find((p) => p.animationId === animationId);
|
||
const blend = blendSeconds > 0 ? { seconds: blendSeconds, elapsed: 0 } : undefined;
|
||
const blendOut = blendOutSeconds > 0 ? blendOutSeconds : undefined;
|
||
if (known) {
|
||
const leads = players.every((p) => p.order <= known.order);
|
||
const fade = known.fade;
|
||
known.playing = true;
|
||
known.order = ++startCount;
|
||
known.blendOut = blendOut;
|
||
delete known.fade;
|
||
if (restart || fade?.ended) {
|
||
known.time = 0;
|
||
known.blend = blend;
|
||
} else if (fade && blend) {
|
||
// Mixed back from the starting values it stands where the fade left it: `share` = 1 - fade share.
|
||
blend.elapsed = blendSeconds * (1 - blendShare(fade));
|
||
known.blend = { ...blend, ...(known.bases && { from: { ...known.bases } }) };
|
||
} else if (fade || !leads) known.blend = blend;
|
||
return;
|
||
}
|
||
players.push({
|
||
animationId,
|
||
time: 0,
|
||
playing: true,
|
||
order: ++startCount,
|
||
...(blend && { blend }),
|
||
...(blendOut && { blendOut }),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ends it where it stands – the values stay; `''` ends every animation on the object. With
|
||
* `blendSeconds` it fades out instead, back to what lies below it (see `AnimationFade`). A player that
|
||
* has not reached anything yet ends at once either way.
|
||
*/
|
||
export function stopAnimation(holder: PlayerHolder, animationId: string, blendSeconds = 0): void {
|
||
if (!holder.animations) return;
|
||
const hit = (p: AnimationPlayer) => animationId === '' || p.animationId === animationId;
|
||
const fades = blendSeconds > 0;
|
||
holder.animations = holder.animations.filter((p) => !hit(p) || (fades && p.bases !== undefined));
|
||
if (fades)
|
||
for (const player of holder.animations)
|
||
if (hit(player) && !player.fade) player.fade = { seconds: blendSeconds, elapsed: 0 };
|
||
}
|
||
|
||
/** Holds it at its frame until `Play animation` lets it run on; `''` holds every one. */
|
||
export function pauseAnimation(holder: PlayerHolder, animationId: string): void {
|
||
for (const player of holder.animations ?? [])
|
||
if (animationId === '' || player.animationId === animationId) player.playing = false;
|
||
}
|
||
|
||
/** A key on one row, as the timeline selects it. */
|
||
export interface KeyRef {
|
||
trackId: string;
|
||
frame: number;
|
||
}
|
||
|
||
export function sameKey(a: KeyRef, b: KeyRef): boolean {
|
||
return a.trackId === b.trackId && a.frame === b.frame;
|
||
}
|
||
|
||
export function hasKey(keys: readonly KeyRef[], key: KeyRef): boolean {
|
||
return keys.some((k) => sameKey(k, key));
|
||
}
|
||
|
||
export function findTrack(asset: AnimationAsset, trackId: string): AnimationTrack | undefined {
|
||
return asset.tracks.find((t) => t.id === trackId);
|
||
}
|
||
|
||
export function findKey(asset: AnimationAsset, ref: KeyRef): AnimationKey | undefined {
|
||
return findTrack(asset, ref.trackId)?.keys.find((k) => k.frame === ref.frame);
|
||
}
|
||
|
||
/** Sorted by frame, at most one per frame (the later wins), frames whole and not negative. */
|
||
export function normalizeKeys(keys: readonly AnimationKey[]): AnimationKey[] {
|
||
const byFrame = new Map<number, AnimationKey>();
|
||
for (const key of keys) {
|
||
const frame = Math.max(0, Math.round(key.frame));
|
||
byFrame.set(frame, { ...key, frame });
|
||
}
|
||
return [...byFrame.values()].sort((a, b) => a.frame - b.frame);
|
||
}
|