An animation is a timeline of rows over one asset, played on an object rather than worn as a picture (#97)
Part (a) of the animation rework: model, commands, saving, runtime and nodes. - AnimationAsset holds a root (sprite, text, container or scene) and rows; a row keys one property of the root or of an object inside it, keys sit on whole frames. - The last key decides the length (its frame counts as a frame of its own). The playhead runs between frames, so numbers and colors blend every tick even at 10 fps; everything else holds. - Playback gains `once-reset`, which puts back what was there before the start. A row can be relative, adding to where its target stood. - Play / Pause / Stop animation act on an object; each instance plays on its own, the later start wins on a shared property, and a behavior writing the same property still writes last. - A sprite no longer wears an animation through `texture`. "Create animation from texture" builds a sprite plus a texture row keyed frame by frame instead. - The Animation tab shows a placeholder until the timeline editor (part b) lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d3563897d8
commit
193378e6a9
29 files changed
+1194
-1499
No files matched your search
+29
-4
@@ -1,7 +1,7 @@
|
||||
import type { Graph } from '../graph/Graph';
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import type { AssetType } from './AssetTypes';
|
||||
import type { PropertyBag } from './Properties';
|
||||
import type { PropertyBag, PropertyValue } from './Properties';
|
||||
import type { Scope } from './Scope';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
|
||||
@@ -101,13 +101,38 @@ export interface TextureAsset extends AssetBase {
|
||||
frames: TextureFrame[];
|
||||
}
|
||||
|
||||
/** How a key blends into the next one; absent is `linear`, the only one there is so far (#97). */
|
||||
export type KeyInterpolation = 'linear';
|
||||
|
||||
/** A value on a whole frame. A value that cannot blend (a texture, a switch) holds until the next key. */
|
||||
export interface AnimationKey {
|
||||
frame: number;
|
||||
value: PropertyValue;
|
||||
interpolation?: KeyInterpolation;
|
||||
}
|
||||
|
||||
/**
|
||||
* `frames` holds what a sprite's `texture` holds – a texture id or a frame id – in playing order.
|
||||
* A picture may appear more than once, which is how one frame is held longer.
|
||||
* One row of the timeline: a property of the root (`target` empty) or of an object inside it
|
||||
* (`target` = the template object's id). `keys` are sorted by frame, at most one per frame.
|
||||
* `relative` adds a number to the value the target had when the animation started.
|
||||
*/
|
||||
export interface AnimationTrack {
|
||||
id: string;
|
||||
target: string;
|
||||
property: string;
|
||||
relative?: boolean;
|
||||
keys: AnimationKey[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A timeline over one root asset – a sprite, text, container or scene. It is played on an object,
|
||||
* and its rows reach that object and the objects inside it, so every instance plays on its own.
|
||||
* The last key decides how long it runs (see `model/animation.ts`).
|
||||
*/
|
||||
export interface AnimationAsset extends AssetBase {
|
||||
type: 'animation';
|
||||
frames: string[];
|
||||
root: string;
|
||||
tracks: AnimationTrack[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+214
-32
@@ -1,38 +1,35 @@
|
||||
import type { AnimationAsset, TextureAsset } from './Asset';
|
||||
import { textOf } from './Properties';
|
||||
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';
|
||||
|
||||
export const PLAYBACKS = ['loop', 'once', 'pingpong'] as const;
|
||||
/** `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];
|
||||
|
||||
export interface FrameAt {
|
||||
/** -1 for an animation without frames. */
|
||||
index: number;
|
||||
/** Only `once` ever finishes; it then holds its last frame. */
|
||||
/** 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;
|
||||
|
||||
/** Which frame shows at `time` seconds after the animation started. */
|
||||
export function frameAt(count: number, fps: number, playback: Playback, time: number): FrameAt {
|
||||
if (count <= 0) return { index: -1, finished: false };
|
||||
if (!Number.isFinite(fps) || fps <= 0 || !(time > 0)) return { index: 0, finished: false };
|
||||
const step = Math.floor(time * fps + STEP_EPSILON);
|
||||
switch (playback) {
|
||||
case 'loop':
|
||||
return { index: step % count, finished: false };
|
||||
case 'once':
|
||||
return step >= count ? { index: count - 1, finished: true } : { index: step, finished: false };
|
||||
case 'pingpong': {
|
||||
if (count === 1) return { index: 0, finished: false };
|
||||
const period = 2 * count - 2;
|
||||
const at = step % period;
|
||||
return { index: at < count ? at : period - at, finished: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function playbackOf(asset: AnimationAsset): Playback {
|
||||
const value = textOf(asset.props.playback ?? 'loop');
|
||||
return (PLAYBACKS as readonly string[]).includes(value) ? (value as Playback) : 'loop';
|
||||
@@ -43,16 +40,201 @@ export function fpsOf(asset: AnimationAsset): number {
|
||||
return Number.isFinite(fps) && fps > 0 ? fps : 0;
|
||||
}
|
||||
|
||||
export function animationFrameAt(asset: AnimationAsset, time: number): FrameAt {
|
||||
return frameAt(asset.frames.length, fpsOf(asset), playbackOf(asset), time);
|
||||
/** 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 ? asset.frames.length / fps : 0;
|
||||
return fps > 0 ? frameCount(asset) / fps : 0;
|
||||
}
|
||||
|
||||
/** What an animation made from a texture plays: its frames in order, the whole picture when it has none. */
|
||||
export function framesOfTexture(texture: TextureAsset): string[] {
|
||||
return texture.frames.length > 0 ? texture.frames.map((f) => f.id) : [texture.id];
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { animationFrameAt } from './animation';
|
||||
import type { FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset';
|
||||
import { type PropertyBag, textOf } from './Properties';
|
||||
import { findAsset, type Project } from './Project';
|
||||
@@ -81,21 +80,12 @@ export function textureRefIds(asset: TextureAsset): string[] {
|
||||
return [asset.id, ...asset.frames.map((f) => f.id)];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a sprite wearing `props.texture` shows; an animation shows its frame at `time` seconds
|
||||
* since it started.
|
||||
*/
|
||||
export function resolveTexture(project: Project, props: PropertyBag, time = 0): TextureRef | null {
|
||||
const id = textOf(props.texture ?? '');
|
||||
const asset = id ? findAsset(project, id) : undefined;
|
||||
if (asset?.type === 'animation') {
|
||||
const index = animationFrameAt(asset, time).index;
|
||||
return index < 0 ? null : resolvePicture(project, asset.frames[index]!);
|
||||
}
|
||||
return resolvePicture(project, id);
|
||||
/** What a sprite wearing `props.texture` shows. */
|
||||
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
|
||||
return resolvePicture(project, textOf(props.texture ?? ''));
|
||||
}
|
||||
|
||||
/** A texture or frame id. An animation is not a picture, which keeps one inside another from resolving. */
|
||||
/** A texture or frame id; any other asset's id is no picture. */
|
||||
export function resolvePicture(project: Project, id: string): TextureRef | null {
|
||||
if (!id) return null;
|
||||
const asset = findAsset(project, id);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { isKindWith } from '../properties/kinds';
|
||||
import { schemaForAsset } from '../properties/PropertySchema';
|
||||
import { referencesAny, targetAssetOf } from './animation';
|
||||
import type { AnimationAsset, Asset } from './Asset';
|
||||
import { overrideSites } from './instance';
|
||||
import { type ObjectHolder, objectHolders, type Project } from './Project';
|
||||
@@ -62,10 +63,12 @@ export function findPropertyUsages(project: Project, ids: string | readonly stri
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The animations playing any of these pictures – intrinsic data, which `findPropertyUsages` does not see. */
|
||||
/** The animations keying any of these ids – intrinsic data, which `findPropertyUsages` does not see. */
|
||||
export function animationsUsing(project: Project, ids: readonly string[]): AnimationAsset[] {
|
||||
const wanted = new Set(ids);
|
||||
return project.assets.filter(
|
||||
(a): a is AnimationAsset => a.type === 'animation' && a.frames.some((f) => wanted.has(f)),
|
||||
(a): a is AnimationAsset =>
|
||||
a.type === 'animation' &&
|
||||
a.tracks.some((t) => referencesAny(t, targetAssetOf(project, a, t.target)?.type, wanted)),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user