Files
feuerwerk/src/core/model/animation.ts
T
2026-09-17 14:19:53 +02:00

545 lines
21 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, 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);
}