Files
feuerwerk/src/core/model/animation.ts
T
adminandClaude Opus 5.5 37779a2070
feuerwerk / gate (push) Successful in 5m21s
feuerwerk / suite (push) Successful in 20m7s
Fold object groups in the timeline, with summary keys (#414)
Long animations with many objects fill the timeline with rows. A
group per object can now fold away, and its header row still shows
where the group has keys, so they stay reachable while folded.

- A click on an object header or its triangle folds the group. The
  groups of the objects below it fold along.
- Which groups fold along follows the real parent in the hierarchy,
  not the indent. The root shows at the level of its children, so
  folding it hides only its own rows.
- Every header shows a summary diamond on each frame where a row of
  the group or of a group below has a key. Click selects all those
  keys, drag moves them, Ctrl+drag duplicates, a band catches them
  and Delete removes them, each through the existing commands.
- The fold state is a view of the session per animation. It is no
  undo step and is not saved.
- Selecting a row without keys inside a folded group unfolds it, so
  a fresh row from + Row does not stay hidden.
- The fold rules live in the new timelineFold.ts. keyAt became
  keysAt, which returns every key behind a summary diamond.

Found on the way: the triangle moved the object name to x 22 while
its rows stayed at x 20, left of the name (#408 check in e2e 25).
Rows now sit one step behind the name, as in the hierarchy.

Tests:
- Unit animation + timeline-view 114/114, 6 new
- 6 counter-checks, all red in the matching test
- e2e 25 (atx) ALL PASSED, 41 checks, 4 new (25k)
- Gate (atx) CHECK EXIT 0, 324 files, 5008 tests

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

933 lines
37 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 {
KEEP_ON_SCENE_LOAD_KEY,
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 { findVideo, type VideoRef } from './video';
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;
// A key reads the next key's own value once it is there, so for it a step only ever holds.
if (shape === 'step') return t >= 1 ? 1 : 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;
}
}
/**
* An easing as something that runs on its own clock, the audio one: the two shapes it has built in
* stay names, everything else is the share at each `t`, a gone curve already read as linear.
*/
export type EaseShare = 'linear' | 'step' | ((t: number) => number);
export function easeShareOf(interpolation: KeyInterpolation, curves: CurveLookup = NO_CURVES): EaseShare {
const { shape, curve } = easingOf(interpolation);
if (shape === 'linear' || shape === 'step') return shape;
if (shape === 'curve' && !curves(curve ?? '')) return 'linear';
return (t) => easedShare(interpolation, t, curves);
}
/** 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[] {
// Whether an object is kept through a scene swap is nothing a timeline moves (#165).
const own = schemaFor(type).filter((def) => isFlat(def) && def.key !== KEEP_ON_SCENE_LOAD_KEY);
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' || asset.type === 'camera'
? 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; `parent` is the one above it, '' at the top. */
export interface AnimationTarget {
id: string;
object: SceneObject;
depth: number;
parent: string;
}
/**
* 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,
parent: depth > 0 ? object.parentId : '',
}));
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,
parent: object.parentId.startsWith(prefix) ? object.parentId.slice(prefix.length) : '',
}));
}
/**
* The names from a container root down to the object a row aims at (`Sprite`, `Weapon/Hitbox`). An
* object built from another container finds its target by them when the template ids differ.
* Undefined for the root itself, and once the object is gone.
*/
export function targetNamePath(
project: Project,
root: Asset | undefined,
target: string,
): string[] | undefined {
if (root?.type !== 'container' || target === '') return undefined;
const objects = new Map(resolveObjects(project, { objects: [placedRoot(root)] }).map((o) => [o.id, o]));
const names: string[] = [];
let object = objects.get(instanceChildId(PLACED_ROOT_ID, target));
while (object && object.id !== PLACED_ROOT_ID) {
names.unshift(objectName(object, findAsset(project, object.assetId)));
object = objects.get(object.parentId);
}
return object && names.length > 0 ? names : undefined;
}
/** 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 video the object a row aims at wears at `frame`, for a row shown only with one (#407): what
* the texture row the def names holds there – keyed in this animation, or else on the object.
*/
export function rowVideoAt(
project: Project,
animation: AnimationAsset,
track: AnimationTrack,
frame: number,
): VideoRef | null {
const aimed = aimedAt(project, animation, track.target);
const def =
aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property);
const textureKey = def && 'shownForVideo' in def ? def.shownForVideo : undefined;
if (!aimed || !textureKey) return null;
const keyed = animation.tracks.find((t) => t.target === track.target && t.property === textureKey);
const value = (keyed && trackValueAt(keyed.keys, 'texture', frame)) ?? aimed.bag[textureKey];
return findVideo(project, textOf(value ?? ''));
}
/**
* 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}`;
}
/**
* A line of the timeline's header column (#408): the head of an object's group, or a row under it,
* named by its property alone. `depth` is the object's in the hierarchy, `above` the objects it sits
* under, the nearest last – the root not among them: its head stands level with its children's (#414).
* A row carries its property (none once that is gone) and `held`, what it shows before it has keys:
* it writes nothing then, so that is what the object holds – or no offset at all for a relative row.
*/
export type TimelineLine =
| { kind: 'group'; target: string; name: string; depth: number; above: readonly string[] }
| {
kind: 'row';
track: AnimationTrack;
label: string;
def: PropertyDef | undefined;
held: PropertyValue;
depth: number;
};
/**
* The rows grouped by the object they aim at, in the order the objects stand in the hierarchy – the
* root first – and under each object in the order it lists its properties. Rows aiming at something
* gone come last, as they were added. Only the view is sorted: the asset keeps its own order.
*/
export function timelineLines(project: Project, animation: AnimationAsset): TimelineLine[] {
const placed = new Map<string, { at: number; depth: number; parent: string }>([
['', { at: 0, depth: 0, parent: '' }],
]);
animationTargets(project, findAsset(project, animation.root)).forEach((t, i) =>
placed.set(t.id, { at: i + 1, depth: t.depth, parent: t.parent }),
);
const aboveOf = (target: string): string[] => {
const above: string[] = [];
let at = placed.get(target)?.parent ?? '';
while (at !== '' && !above.includes(at)) {
above.unshift(at);
at = placed.get(at)?.parent ?? '';
}
return above;
};
const targets = [...new Set(animation.tracks.map((t) => t.target))];
const rank = (target: string) => placed.get(target)?.at ?? placed.size + targets.indexOf(target);
const lines: TimelineLine[] = [];
for (const target of targets.sort((a, b) => rank(a) - rank(b))) {
const aimed = aimedAt(project, animation, target);
const defs = aimed ? animatableDefsOf(project, aimed.asset, aimed.bag) : [];
const depth = placed.get(target)?.depth ?? 0;
const name = !aimed
? '(missing)'
: aimed.object
? objectName(aimed.object, aimed.asset)
: aimed.asset.name;
lines.push({ kind: 'group', target, name, depth, above: aboveOf(target) });
const order = (track: AnimationTrack) => {
const i = defs.findIndex((d) => d.key === track.property);
return i < 0 ? defs.length : i;
};
const rows = animation.tracks.filter((t) => t.target === target).sort((a, b) => order(a) - order(b));
for (const track of rows) {
const def = defs.find((d) => d.key === track.property);
const label = def ? def.label : `${track.property} (missing)`;
lines.push({ kind: 'row', track, label, def, held: heldBy(aimed, def, track), depth });
}
}
return lines;
}
function heldBy(
aimed: Aimed | undefined,
def: PropertyDef | undefined,
track: AnimationTrack,
): PropertyValue {
if (!aimed || !def || track.relative) return 0;
if (isPositionRow(aimed.asset.type, def.key)) return aimed.object?.position[def.key] ?? 0;
return aimed.bag[def.key] ?? def.default;
}
/** What a row holds without keys (#412): the value its object has, no offset for a relative row. */
export function trackHeld(project: Project, animation: AnimationAsset, track: AnimationTrack): PropertyValue {
return heldBy(aimedAt(project, animation, track.target), trackDef(project, animation, track), track);
}
/** What a row's header shows at `frame` (#408): the row's value there, else what it holds without keys. */
export function lineValueAt(
line: TimelineLine & { kind: 'row' },
frame: number,
reverse = false,
curves: CurveLookup = NO_CURVES,
): PropertyValue | undefined {
if (!line.def) return undefined;
return trackValueAt(line.track.keys, line.def.kind, frame, reverse, curves) ?? line.held;
}
/** 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;
}
/**
* Who asked for a player – the behaviors that played it, which hear its end wherever the object
* stands. Kept beside the player, not on it: a player is plain data, read as JSON by the check
* scripts, and what asks is an entity of the run with its parents and children.
*/
const starters = new WeakMap<AnimationPlayer, object[]>();
export function startersOf(player: AnimationPlayer): readonly object[] {
return starters.get(player) ?? [];
}
function noteStarter(player: AnimationPlayer, startedBy: object | undefined): void {
if (!startedBy || startersOf(player).includes(startedBy)) return;
starters.set(player, [...startersOf(player), startedBy]);
}
/**
* 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. `startedBy` is noted for the player next to whoever asked before, so each of them
* hears it finish.
*/
export function playAnimation(
holder: PlayerHolder,
animationId: string,
blendSeconds = 0,
blendOutSeconds = 0,
restart = false,
startedBy?: object,
): 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) {
noteStarter(known, startedBy);
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;
}
const player: AnimationPlayer = {
animationId,
time: 0,
playing: true,
order: ++startCount,
...(blend && { blend }),
...(blendOut && { blendOut }),
};
noteStarter(player, startedBy);
players.push(player);
}
/**
* 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);
}