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

+3 -8
View File
@@ -1,4 +1,5 @@
import type { ENTITY_HANDLE } from '../core/graph/NodeContext';
import type { AnimationPlayer } from '../core/model/animation';
import type { AssetType } from '../core/model/AssetTypes';
import type { PropertyBag } from '../core/model/Properties';
@@ -21,14 +22,8 @@ export interface Entity {
props: PropertyBag;
/** Parent entity (mirrors `SceneObject.parentId`); children move and die with it. */
parent: Entity | null;
/** The animation a sprite wears and how far it is; kept out of `props`, which the world writes back. */
animation?: AnimationClock;
/** The animations playing on this object; kept out of `props`, which the world writes back. */
animations?: AnimationPlayer[];
/** How often an `Emit` node has restarted this particle source; out of `props` for the same reason. */
emits?: number;
}
export interface AnimationClock {
id: string;
time: number;
finished: boolean;
}
+94 -24
View File
@@ -1,45 +1,76 @@
import { isHandleEnabled } from '../../core/graph';
import { animationFrameAt } from '../../core/model/animation';
import { textOf } from '../../core/model/Properties';
import {
type AnimationPlayer,
animatedKind,
animationPlayheadAt,
isPositionKey,
playbackOf,
trackValueAt,
} from '../../core/model/animation';
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
import { instanceChildId, splitInstanceId } from '../../core/model/instance';
import { findAssetOf } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import type { Entity } from '../Entity';
import type { World } from '../World';
import type { System } from './System';
export interface AnimationFinish {
/** The object the animation was played on. */
entity: Entity;
animationId: string;
}
/** A row that reaches something this tick: the entity it writes and the kind it blends as. */
interface Binding {
track: AnimationTrack;
entity: Entity;
kind: string;
base: string;
}
/**
* Advances every sprite wearing an animation. A different animation starts over; the same one set
* again runs on, so a graph may set it every frame. Runs before the BehaviorSystem, which reports
* this frame's `finished`.
* Plays what `Play animation` started. Players run in start order, so of two setting one property
* the later start wins. Runs before the BehaviorSystem: a behavior writing the same property writes
* last, and hears this frame's `finished`.
*/
export class AnimationSystem implements System {
finished: AnimationFinish[] = [];
update(world: World, dt: number): void {
this.finished = [];
for (const entity of world.entities) {
if (entity.type !== 'sprite') continue;
const id = textOf(entity.props.texture ?? '');
const asset = findAssetOf(world.project, id, 'animation');
const running: { entity: Entity; player: AnimationPlayer }[] = [];
for (const entity of world.entities)
for (const player of entity.animations ?? []) running.push({ entity, player });
running.sort((a, b) => a.player.order - b.player.order);
for (const { entity, player } of running) {
const asset = findAssetOf(world.project, player.animationId, 'animation');
if (!asset) {
delete entity.animation;
drop(entity, player);
continue;
}
const clock = entity.animation;
if (!clock || clock.id !== id) {
entity.animation = { id, time: 0, finished: false };
continue;
}
if (!isHandleEnabled(entity)) continue;
clock.time += dt;
if (!clock.finished && animationFrameAt(asset, clock.time).finished) {
clock.finished = true;
this.finished.push({ entity, animationId: id });
const bindings = bind(world, asset, entity);
if (!player.bases) {
// The first tick only takes the starting values and shows frame 0; the clock runs from the next.
player.bases = {};
for (const b of bindings) player.bases[b.base] = read(b.entity, b.track.property);
} else if (player.playing && isHandleEnabled(entity)) player.time += dt;
const head = animationPlayheadAt(asset, player.time);
for (const b of bindings) {
const value = trackValueAt(b.track.keys, b.kind, head.frame);
if (value === undefined) continue;
const base = player.bases[b.base];
const offset = b.track.relative && b.kind === 'number' && typeof base === 'number';
write(b.entity, b.track.property, offset ? base + Number(value) : value);
}
if (!head.finished) continue;
if (playbackOf(asset) === 'once-reset')
for (const b of bindings) {
const base = player.bases[b.base];
if (base !== undefined) write(b.entity, b.track.property, base);
}
drop(entity, player);
this.finished.push({ entity, animationId: asset.id });
}
}
@@ -48,8 +79,47 @@ export class AnimationSystem implements System {
}
}
/** Seconds into the animation the entity wears – 0 for one it was given since the last tick. */
export function animationTime(entity: Entity): number {
const clock = entity.animation;
return clock && clock.id === textOf(entity.props.texture ?? '') ? clock.time : 0;
function drop(entity: Entity, player: AnimationPlayer): void {
entity.animations = entity.animations?.filter((p) => p !== player);
}
/**
* The rows that reach something on this object. A scene's rows aim at its objects by id, and only
* while that scene runs; everything else aims at the object itself or at what its template placed
* inside it. A row whose target is gone, or lacks the property, is skipped.
*/
function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
const root = world.project.assets.find((a) => a.id === asset.root);
if (!root) return [];
const bindings: Binding[] = [];
for (const track of asset.tracks) {
let entity: Entity | undefined;
if (root.type === 'scene')
entity = world.sceneId === root.id && track.target ? world.findByObjectId(track.target) : undefined;
else if (track.target === '') entity = owner;
else entity = world.findByObjectId(childId(owner.objectId, track.target));
const kind = entity && animatedKind(entity.type, track.property);
if (entity && kind) bindings.push({ track, entity, kind, base: `${entity.id} ${track.property}` });
}
return bindings;
}
/** The id a template object gets below a placed container – nested one deeper when the container is content itself. */
function childId(ownerId: string, templateId: string): string {
const split = splitInstanceId(ownerId);
return split
? instanceChildId(split.instanceId, `${split.path}/${templateId}`)
: instanceChildId(ownerId, templateId);
}
function read(entity: Entity, key: string): PropertyValue {
if (isPositionKey(key)) return entity[key];
return entity.props[key] ?? 0;
}
function write(entity: Entity, key: string, value: PropertyValue): void {
if (isPositionKey(key)) {
const n = Number(value);
if (Number.isFinite(n)) entity[key] = n;
} else entity.props[key] = value;
}