256 lines
10 KiB
TypeScript
256 lines
10 KiB
TypeScript
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);
|
||
}
|