Files
feuerwerk/src/core/model/animation.ts
T

256 lines
10 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 } from '../properties/PropertySchema';
import type { AnimationAsset, AnimationKey, AnimationTrack, Asset } from './Asset';
import type { AssetType } from './AssetTypes';
import { findAsset, type Project } from './Project';
import { colorOf, 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. */
export const POSITION_KEYS = ['x', 'y'] as const;
export 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;
}
/** 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. */
export 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' };
if (!Number.isFinite(fps) || fps <= 0 || !(time > 0)) return { frame: 0, finished: false };
const at = time * fps + STEP_EPSILON;
switch (playback) {
case 'loop':
return { frame: at % count, finished: false };
case 'once':
case 'once-reset':
return at >= count ? { frame: count, finished: true } : { frame: at, finished: false };
case 'pingpong': {
const phase = at % (2 * count);
return { frame: phase < count ? phase : 2 * count - phase, finished: false };
}
}
}
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);
}
/** 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. `undefined` only for a row without keys.
*/
export function trackValueAt(
keys: readonly AnimationKey[],
kind: string,
frame: number,
): 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]!;
if (!blends(kind)) return prev.value;
return blendValue(kind, prev.value, next.value, (frame - prev.frame) / (next.frame - prev.frame));
}
return keys[keys.length - 1]!.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 {
if (isPositionKey(key)) return 'number';
return animatableDefs(type).find((d) => d.key === key)?.kind;
}
/**
* 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 shader's own parameters come later (#97).
*/
export function animatableDefs(type: AssetType): PropertyDef[] {
if (type === 'scene') return [];
const position: PropertyDef[] = POSITION_KEYS.map((key) => ({
key,
label: key.toUpperCase(),
kind: 'number',
default: 0,
}));
const own = schemaFor(type).filter((d) => d.kind !== 'list' && d.kind !== 'struct');
return [...position, ...own];
}
/** The objects a row may aim at below the root: a container's or scene's whole tree, nothing for the rest. */
export function rootObjects(root: Asset | undefined): readonly SceneObject[] {
return root && (root.type === 'container' || root.type === 'scene') ? root.objects : [];
}
/** 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 {
const root = findAsset(project, animation.root);
if (target === '') return root;
const object = rootObjects(root).find((o) => o.id === target);
return object ? findAsset(project, object.assetId) : undefined;
}
/** 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 root = findAsset(project, animation.root);
const object = track.target === '' ? undefined : rootObjects(root).find((o) => o.id === track.target);
const aimed = targetAssetOf(project, animation, track.target);
const who =
track.target === ''
? (root?.name ?? '?')
: object
? object.name || findAsset(project, object.assetId)?.name || '?'
: '(missing)';
const def = aimed && animatableDefs(aimed.type).find((d) => d.key === track.property);
return `${who} · ${def?.label ?? track.property}`;
}
/** Whether a value names something in the project – what deleting that something has to count. */
export function referencesAny(
track: AnimationTrack,
type: AssetType | undefined,
ids: ReadonlySet<string>,
): boolean {
const kind = type ? animatedKind(type, track.property) : undefined;
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>;
}
interface PlayerHolder {
animations?: AnimationPlayer[];
}
let startCount = 0;
/**
* Starts the animation on the object, or lets a paused one run on. One already running is left
* alone, so a graph may ask for it every frame without holding it at its first frame.
*/
export function playAnimation(holder: PlayerHolder, animationId: string): void {
const players = (holder.animations ??= []);
const known = players.find((p) => p.animationId === animationId);
if (known) {
known.playing = true;
return;
}
players.push({ animationId, time: 0, playing: true, order: ++startCount });
}
/** Ends it where it stands – the values stay; `''` ends every animation on the object. */
export function stopAnimation(holder: PlayerHolder, animationId: string): void {
if (!holder.animations) return;
holder.animations = holder.animations.filter((p) => animationId !== '' && p.animationId !== animationId);
}
/** 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;
}
/** 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);
}