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, ): 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 ` `. */ 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; } 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(); 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); }