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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user