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

+31 -10
View File
@@ -18,7 +18,8 @@ import {
} from '../graph';
import { isParticleNodeDef } from '../particles/nodes';
import { isShaderNodeDef } from '../shader/nodes';
import type { Asset, StateMachineAsset, TextureFrame } from '../model/Asset';
import { normalizeKeys } from '../model/animation';
import type { AnimationKey, AnimationTrack, Asset, StateMachineAsset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
import { isSoundFile } from '../model/sound';
@@ -168,15 +169,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const file = typeof a.file === 'string' && isSoundFile(a.file) ? a.file : '';
assets.push({ id, type, name, props, file });
} else if (type === 'animation') {
// Kept even when they dangle, like a sprite's `texture`: a frame reads as no picture.
const frames = Array.isArray(a.frames) ? a.frames : [];
assets.push({
id,
type,
name,
props,
frames: frames.filter((f): f is string => typeof f === 'string' && f !== ''),
});
// A root or row aiming at something gone is kept: it reads as missing and keeps its keys.
const root = typeof a.root === 'string' ? a.root : '';
assets.push({ id, type, name, props, root, tracks: normalizeTracks(a.tracks) });
} else {
assets.push({ id, type, name, props });
}
@@ -461,6 +456,32 @@ function normalizeTexture(
return { file, width, height, frames };
}
/**
* The rows of an animation. Keys hold flat values only – a row animates one property – and are
* sorted onto whole frames; a second row on the same target and property, or with an id already
* taken, is dropped like a doubled node.
*/
function normalizeTracks(raw: unknown): AnimationTrack[] {
const tracks: AnimationTrack[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const t = asRecord(item);
if (typeof t.id !== 'string' || !t.id || typeof t.property !== 'string' || !t.property) continue;
const target = typeof t.target === 'string' ? t.target : '';
if (tracks.some((o) => o.id === t.id || (o.target === target && o.property === t.property))) continue;
const keys: AnimationKey[] = [];
for (const k of Array.isArray(t.keys) ? t.keys : []) {
const key = asRecord(k);
const frame = Number(key.frame);
if (!Number.isFinite(frame) || !isPrimitive(key.value)) continue;
keys.push({ frame, value: key.value });
}
const track: AnimationTrack = { id: t.id, target, property: t.property, keys: normalizeKeys(keys) };
if (t.relative === true) track.relative = true;
tracks.push(track);
}
return tracks;
}
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
const r = asRecord(raw);
const bag: PropertyBag = {};