Files
feuerwerk/src/core/model/animation.ts
T
adminandClaude Opus 5.5 c1cc80bb45 Add nodes that ask whether an animation still runs (#233)
A graph could start, pause and stop an animation and hear it finish,
but it could not ask in between whether one runs or how far it is.
Two data nodes answer that now.

- Animation playing takes an object and an animation and gives
  playing, progress and time left (s). It answers at the moment it is
  read: right after Play animation in the same pass it says on, and in
  the frame On animation finished fires it says off.
- With Animation at None it answers for the running animation started
  last on the object.
- Animation length gives the seconds of one pass, from the timeline
  and the frame rate alone.
- The rule is a pure function in src/core/model/animation.ts
  (playerStatus, leadingPlayer). The nodes reach an animation's
  length through a new field animation on GraphLookup.
- animationPort is exported so both nodes show the same picker as
  Play animation.

Decided in the issue: a Once (reset) counts as running while it blends
out, and progress and time left also hold for Loop and Ping-pong, at
the place of the playhead. On Ping-pong progress falls again on the
way back and time left counts to the next turn.

Decided here, the issue says nothing on it:
- A paused animation does not count as running. Progress and time
  left stand still.
- One that Stop animation fades out does not count as running, and
  its time left is 0.
- Time left of a Once (reset) includes its blend out. While it blends
  out progress is 1 and time left is the rest of the fade.

Finding, not fixed: on the node the name of the chosen animation
overlaps the output label progress in the same row. That comes from
the fixed node width in NodeView and was seen in the Fit view only.

Tests:
- Gate: 198 files, 2903 tests (2891 + 12), exit 0
- e2e 1-6: 282 checks passed, 06 has +2 for block 6z-ix
- Nine unit counter-probes fail, 1 to 4 tests each
- The browser counter-probe fails 6z-ix with ["false","false"]
- e2e total 1615 is computed, not measured in a full run. The smokes
  were not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:57:31 +02:00

751 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isKindWith } from '../properties/kinds';
import { type PropertyDef, schemaFor, shaderParamRows } from '../properties/PropertySchema';
import { shaderOf } from '../properties/shaders';
import {
type AnimationAsset,
type AnimationKey,
type AnimationTrack,
type Asset,
type EasingCurve,
type EasingDirection,
isPrivateVariable,
type KeyInterpolation,
} from './Asset';
import type { AssetType } from './AssetTypes';
import { type CurveLookup, curveValue, NO_CURVES } from './curve';
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 { objectName, type SceneObject } from './SceneObject';
import { mixColor } from '../util/color';
/** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */
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. `z` is
* the draw order, which is sorted on every render, so a keyed `z` moves the object in front or behind.
*/
const POSITION_KEYS = ['x', 'y', 'z'] 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';
}
export type EasingShape = 'linear' | 'step' | EasingCurve | 'curve';
/** The inspector's first easing field, in the order it offers them; `linear` is what an absent field means. */
export const EASING_SHAPES: readonly { value: EasingShape; label: string }[] = [
{ value: 'linear', label: 'Linear' },
{ value: 'step', label: 'Step' },
{ value: 'sine', label: 'Sine' },
{ value: 'quad', label: 'Quad' },
{ value: 'cubic', label: 'Cubic' },
{ value: 'quart', label: 'Quart' },
{ value: 'quint', label: 'Quint' },
{ value: 'expo', label: 'Expo' },
{ value: 'circ', label: 'Circ' },
{ value: 'back', label: 'Back' },
{ value: 'elastic', label: 'Elastic' },
{ value: 'bounce', label: 'Bounce' },
{ value: 'curve', label: 'Curve' },
];
/** The second field, for a curve only. */
export const EASING_DIRECTIONS: readonly { value: EasingDirection; label: string }[] = [
{ value: 'in', label: 'In' },
{ value: 'out', label: 'Out' },
{ value: 'in-out', label: 'In-out' },
];
const BACK = 1.70158;
const ELASTIC = (2 * Math.PI) / 3;
function bounceOut(t: number): number {
const n = 7.5625;
const d = 2.75;
if (t < 1 / d) return n * t * t;
if (t < 2 / d) return n * (t -= 1.5 / d) * t + 0.75;
if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375;
return n * (t -= 2.625 / d) * t + 0.984375;
}
/** Each curve's `in` form (Penner's); `out` is it mirrored, `in-out` both halves. Back and elastic leave 0…1. */
const EASE_IN: Record<EasingCurve, (t: number) => number> = {
sine: (t) => 1 - Math.cos((t * Math.PI) / 2),
quad: (t) => t * t,
cubic: (t) => t ** 3,
quart: (t) => t ** 4,
quint: (t) => t ** 5,
expo: (t) => (t <= 0 ? 0 : 2 ** (10 * t - 10)),
circ: (t) => 1 - Math.sqrt(1 - t * t),
back: (t) => (BACK + 1) * t ** 3 - BACK * t * t,
elastic: (t) => (t <= 0 ? 0 : t >= 1 ? 1 : -(2 ** (10 * t - 10)) * Math.sin((10 * t - 10.75) * ELASTIC)),
bounce: (t) => 1 - bounceOut(1 - t),
};
function isCurve(value: string): value is EasingCurve {
return Object.hasOwn(EASE_IN, value);
}
/**
* A stored interpolation split into the inspector's fields; unknown text reads as `linear`. `curve`
* is the id of the Curve asset (#247), `''` while none is picked.
*/
export function easingOf(interpolation: KeyInterpolation): {
shape: EasingShape;
direction?: EasingDirection;
curve?: string;
} {
if (interpolation === 'linear' || interpolation === 'step') return { shape: interpolation };
if (interpolation.startsWith(CURVE_PREFIX))
return { shape: 'curve', curve: interpolation.slice(CURVE_PREFIX.length) };
const match = /^([a-z]+)-(in|out|in-out)$/.exec(interpolation);
if (!match) return { shape: 'linear' };
const curve = match[1]!;
const direction = match[2] as EasingDirection;
if (curve === 'ease') return { shape: 'quad', direction };
return isCurve(curve) && curve !== 'quad' ? { shape: curve, direction } : { shape: 'linear' };
}
const CURVE_PREFIX = 'curve:';
/** The stored form of the fields; a curve without a direction eases both ways. */
export function easingValue(
shape: EasingShape,
direction: EasingDirection = 'in-out',
curve = '',
): KeyInterpolation {
if (shape === 'linear' || shape === 'step') return shape;
if (shape === 'curve') return `${CURVE_PREFIX}${curve}`;
return shape === 'quad' ? `ease-${direction}` : `${shape}-${direction}`;
}
export function isKeyInterpolation(value: unknown): value is KeyInterpolation {
if (typeof value !== 'string') return false;
const { shape, direction, curve } = easingOf(value as KeyInterpolation);
return easingValue(shape, direction, curve) === 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. Quad, the eases
* of old, reaches 0.25 at half the time going in, 0.75 going out, and 0.125 at a quarter going both.
* A Curve asset that is gone, or none picked, blends linearly.
*/
export function easedShare(
interpolation: KeyInterpolation,
t: number,
curves: CurveLookup = NO_CURVES,
): number {
const { shape, direction, curve } = easingOf(interpolation);
if (shape === 'linear') return t;
if (shape === 'step') return 0;
if (shape === 'curve') {
const points = curves(curve ?? '');
return points ? curveValue(points, t) : t;
}
const easeIn = EASE_IN[shape];
switch (direction ?? 'in-out') {
case 'in':
return easeIn(t);
case 'out':
return 1 - easeIn(1 - t);
case 'in-out':
return t < 0.5 ? easeIn(2 * t) / 2 : 1 - easeIn(2 - 2 * t) / 2;
}
}
/** 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 mixColor(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. `curves` finds the
* Curve assets a key may blend along.
*/
export function trackValueAt(
keys: readonly AnimationKey[],
kind: string,
frame: number,
reverse = false,
curves: CurveLookup = NO_CURVES,
): 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, curves));
}
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. A number is held to the property's `min`/`max`, which a
* back or elastic curve overshoots. `undefined` for a row without keys.
*/
export function trackWriteAt(
track: AnimationTrack,
def: RowDef,
frame: number,
base: PropertyValue | undefined,
reverse = false,
curves: CurveLookup = NO_CURVES,
): PropertyValue | undefined {
const value = trackValueAt(track.keys, def.kind, frame, reverse, curves);
if (value === undefined) return undefined;
if (def.kind !== 'number') return value;
const offset = track.relative && typeof base === 'number';
return heldTo(def, offset ? base + Number(value) : value);
}
/** What `trackWriteAt` needs of a property; a `PropertyDef` has it. */
export interface RowDef {
kind: string;
min?: number;
max?: number;
}
function heldTo(def: RowDef, value: PropertyValue): PropertyValue {
if (typeof value !== 'number') return value;
if (def.min !== undefined && value < def.min) return def.min;
if (def.max !== undefined && value > def.max) return def.max;
return value;
}
/** A row's property on an asset of that type; undefined for one the timeline cannot animate. */
export function animatedDef(type: AssetType, key: string): PropertyDef | undefined {
return animatableDefs(type).find((d) => d.key === key);
}
/** The property `key` is on an asset holding `bag` – see `animatableDefsOf`. */
export function animatedDefOf(
project: Project,
asset: Asset,
bag: PropertyBag,
key: string,
): PropertyDef | undefined {
return animatableDefsOf(project, asset, bag).find((d) => d.key === key);
}
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' || asset.type === 'text'
? 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);
}
/**
* The rows the dialog offers for the object `target` names: what it can animate, less a behavior's
* private variables (#270) and what another row animates already. A row on a private variable keeps
* playing – only a new one is not offered. `changing` is the row being aimed elsewhere: its keys
* stay, so only properties of its kind are offered, and its own property is, private or not.
*/
export function offeredDefs(
project: Project,
animation: AnimationAsset,
target: string,
changing: AnimationTrack | null = null,
): PropertyDef[] {
const aimed = aimedAt(project, animation, target);
if (!aimed) return [];
const kind = changing ? (trackDef(project, animation, changing)?.kind ?? null) : null;
return animatableDefsOf(project, aimed.asset, aimed.bag).filter((def) => {
if (kind !== null && def.kind !== kind) return false;
const own = changing?.target === target && changing.property === def.key;
if (isPrivateVariable(aimed.asset, def.key) && !own) return false;
return !animation.tracks.some(
(t) => t.id !== changing?.id && t.target === target && t.property === def.key,
);
});
}
/** 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 ? objectName(aimed.object, aimed.asset) : 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;
}
/** What a run needs of an animation to say where a player stands in it (#233). */
export interface AnimationClip {
frames: number;
fps: number;
playback: Playback;
}
export function clipOf(asset: AnimationAsset): AnimationClip {
return { frames: frameCount(asset), fps: fpsOf(asset), playback: playbackOf(asset) };
}
export function clipDuration(clip: AnimationClip): number {
return clip.fps > 0 ? clip.frames / clip.fps : 0;
}
/** What `Animation playing` answers (#233). */
export interface PlayerStatus {
playing: boolean;
/** Where the playhead stands on the timeline, 0…1 – on ping-pong's way back it falls again. */
progress: number;
/** Seconds until the playhead reaches the end of its pass, and a `once-reset` has blended out. */
timeLeft: number;
}
export const NOT_PLAYING: PlayerStatus = { playing: false, progress: 0, timeLeft: 0 };
/**
* A paused player is not running, and neither is one `Stop animation` fades out. A `once-reset` pass
* blending back is: it still shows.
*/
function isRunning(player: AnimationPlayer): boolean {
return player.fade ? player.fade.ended === true : player.playing;
}
/**
* The player `Animation playing` answers for: the one of that animation, or for `''` the one started
* last among those running – and when none runs, the one started last at all.
*/
export function leadingPlayer(holder: PlayerHolder, animationId: string): AnimationPlayer | undefined {
const players = (holder.animations ?? []).filter(
(p) => animationId === '' || p.animationId === animationId,
);
const running = players.filter(isRunning);
return (running.length > 0 ? running : players).sort((a, b) => b.order - a.order)[0];
}
export function playerStatus(clip: AnimationClip, player: AnimationPlayer): PlayerStatus {
const playing = isRunning(player);
const fade = player.fade;
if (fade?.ended) return { playing, progress: 1, timeLeft: Math.max(0, fade.seconds - fade.elapsed) };
if (clip.frames <= 0 || clip.fps <= 0) return { playing, progress: 0, timeLeft: 0 };
const head = playheadAt(clip.frames, clip.fps, clip.playback, player.time);
if (clip.playback === 'pingpong') {
const last = clip.frames - 1;
if (last <= 0) return { playing, progress: 0, timeLeft: 0 };
const ahead = head.reverse ? head.frame : last - head.frame;
return { playing, progress: head.frame / last, timeLeft: fade ? 0 : ahead / clip.fps };
}
const blendOut = clip.playback === 'once-reset' ? (player.blendOut ?? 0) : 0;
return {
playing,
progress: Math.min(1, head.frame / clip.frames),
timeLeft: fade ? 0 : Math.max(0, clip.frames - head.frame) / clip.fps + blendOut,
};
}
/** 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);
}