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:
adminandClaude Opus 5 committed 2026-09-16 20:02:25 +02:00
1 parent d3563897d8
commit 193378e6a9
29 files changed
+1194 -1499

No files matched your search

+29 -4
View File
@@ -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
View File
@@ -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);
}
+4 -14
View File
@@ -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 -2
View File
@@ -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)),
);
}