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