Play sprite animations from an animation asset worn through texture

An animation asset is a list of pictures (whole textures or frames) with
frames per second and Loop/Once/Ping-pong playback. A sprite wears it
through its texture property, so the picker, instance overrides and the
Properties node work unchanged; there is no dedicated play node.

A different animation starts from its first frame, the same one set again
runs on, so a graph may choose Run or Idle every frame. The clock lives on
the entity, not in props, which the world writes back as overrides. The
AnimationSystem ticks before behaviors, and On animation finished fires
once when a Once animation reaches its end.

The Animation tab plays a preview above a frame strip: reorder by dragging,
Delete, duplicate, + Frames, pause/resume, and wheel zoom about the pointer
with pan and Fit. Scene and Container tabs show the first frame; edit mode
has no clock. Right-clicking a texture creates an animation from its frames.

Button now measures its label with the bold font it draws in, which cut
the Animation tab and red dialog buttons short.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 23:52:34 +02:00
1 parent bf4191956c
commit 1135bf4562
39 files changed
+1935 -87

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
import type { AnimationAsset } from '../model/Asset';
import { findAsset, type Project } from '../model/Project';
import { type Removed, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireAsset } from './require';
function requireAnimation(project: Project, assetId: string): AnimationAsset {
const asset = requireAsset(project, assetId);
if (asset.type !== 'animation') throw new CommandError(`${asset.name} is not an animation`);
return asset;
}
function requireIndex(asset: AnimationAsset, index: number): void {
if (!Number.isInteger(index) || index < 0 || index >= asset.frames.length)
throw new CommandError(`${asset.name} has no frame ${index + 1}`);
}
/** Inserts pictures at `at`, or appends them when `at` is past the end or negative. */
export class AddAnimationFramesCommand implements Command {
readonly label: string;
private index = 0;
constructor(
private readonly assetId: string,
private readonly refs: readonly string[],
private readonly at = -1,
) {
this.label = refs.length === 1 ? 'Add frame' : `Add ${refs.length} frames`;
}
execute(project: Project): void {
const asset = requireAnimation(project, this.assetId);
if (this.refs.length === 0) throw new CommandError('No frames to add');
for (const ref of this.refs) {
if (!ref) throw new CommandError('An animation frame needs a picture');
// It would never resolve: a frame of an animation is a picture, not another animation.
if (findAsset(project, ref)?.type === 'animation')
throw new CommandError('An animation cannot play another animation');
}
this.index = this.at < 0 || this.at > asset.frames.length ? asset.frames.length : this.at;
asset.frames.splice(this.index, 0, ...this.refs);
}
undo(project: Project): void {
requireAnimation(project, this.assetId).frames.splice(this.index, this.refs.length);
}
}
export class RemoveAnimationFramesCommand implements Command {
readonly label: string;
private removed: Removed<string>[] = [];
constructor(
private readonly assetId: string,
private readonly indices: readonly number[],
) {
this.label = indices.length === 1 ? 'Remove frame' : `Remove ${indices.length} frames`;
}
execute(project: Project): void {
const asset = requireAnimation(project, this.assetId);
const indices = [...new Set(this.indices)].sort((a, b) => a - b);
if (indices.length === 0) throw new CommandError('No frames to remove');
for (const index of indices) requireIndex(asset, index);
this.removed = indices.map((index) => ({ item: asset.frames[index]!, index }));
for (let i = indices.length - 1; i >= 0; i--) asset.frames.splice(indices[i]!, 1);
}
undo(project: Project): void {
restoreAt(requireAnimation(project, this.assetId).frames, this.removed);
}
}
/** `to` is the index the frame ends up at. */
export class MoveAnimationFrameCommand implements Command {
readonly label = 'Move frame';
constructor(
private readonly assetId: string,
private readonly from: number,
private readonly to: number,
) {}
execute(project: Project): void {
const asset = requireAnimation(project, this.assetId);
requireIndex(asset, this.from);
requireIndex(asset, this.to);
move(asset.frames, this.from, this.to);
}
undo(project: Project): void {
move(requireAnimation(project, this.assetId).frames, this.to, this.from);
}
}
function move(list: string[], from: number, to: number): void {
const [item] = list.splice(from, 1);
list.splice(to, 0, item!);
}
+2
View File
@@ -32,6 +32,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'collider' };
case 'container':
return { ...base, type: 'container', objects: [] };
case 'animation':
return { ...base, type: 'animation', frames: [] };
}
}
@@ -0,0 +1,15 @@
import { textOf } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { exec, obj } from '../shared';
export const OnAnimationFinished: NodeDef = {
type: 'OnAnimationFinished',
label: 'On animation finished',
category: 'event',
description:
'Runs once when a sprite of the owner reaches the end of an animation that plays once. Leave Animation at None to hear every animation.',
inputs: [],
outputs: [exec(), obj('object')],
params: [{ key: 'animation', label: 'Animation', kind: 'asset', default: '', assetTypes: ['animation'] }],
hiddenParams: (params) => (textOf(params.animation ?? '') ? [] : ['animation']),
};
+2
View File
@@ -62,6 +62,7 @@ import { ItemAt } from './data/ItemAt';
import { ListLiteral } from './data/ListLiteral';
import { SetItem } from './data/SetItem';
import { SubList } from './data/SubList';
import { OnAnimationFinished } from './events/OnAnimationFinished';
import { OnClick } from './events/OnClick';
import { OnCollisionEnter } from './events/OnCollisionEnter';
import { OnCollisionExit } from './events/OnCollisionExit';
@@ -96,6 +97,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
OnCollisionStay,
OnCollisionExit,
OnClick,
OnAnimationFinished,
BehaviorStart,
// flow
Branch,
+11 -1
View File
@@ -73,6 +73,15 @@ export interface TextureAsset extends AssetBase {
frames: TextureFrame[];
}
/**
* `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.
*/
export interface AnimationAsset extends AssetBase {
type: 'animation';
frames: string[];
}
export type Asset =
| SpriteAsset
| CameraAsset
@@ -81,7 +90,8 @@ export type Asset =
| BehaviorAsset
| ColliderAsset
| ContainerAsset
| TextureAsset;
| TextureAsset
| AnimationAsset;
export function isSceneAsset(asset: Asset): asset is SceneAsset {
return asset.type === 'scene';
+5 -6
View File
@@ -7,6 +7,7 @@ export const ASSET_TYPES = [
'collider',
'container',
'texture',
'animation',
] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
@@ -20,16 +21,14 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
collider: 'Collider',
container: 'Container',
texture: 'Texture',
animation: 'Animation',
};
/**
* A texture is not placeable: it is picture data a sprite wears, so dropping one into the scene
* creates a sprite instead.
*/
export type PlaceableAssetType = Exclude<AssetType, 'scene' | 'texture'>;
/** Textures and animations are not placeable: they are pictures a sprite wears through `texture`. */
export type PlaceableAssetType = Exclude<AssetType, 'scene' | 'texture' | 'animation'>;
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
return type !== 'scene' && type !== 'texture';
return type !== 'scene' && type !== 'texture' && type !== 'animation';
}
export function isAssetType(value: unknown): value is AssetType {
+59
View File
@@ -0,0 +1,59 @@
import type { AnimationAsset, TextureAsset } from './Asset';
import { textOf } from './Properties';
export const PLAYBACKS = ['loop', 'once', '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. */
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';
}
export function fpsOf(asset: AnimationAsset): number {
const fps = Number(asset.props.fps);
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);
}
/** Seconds for one pass through the frames. */
export function animationDuration(asset: AnimationAsset): number {
const fps = fpsOf(asset);
return fps > 0 ? asset.frames.length / 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];
}
+16 -1
View File
@@ -1,3 +1,4 @@
import { animationFrameAt } from './animation';
import type { TextureAsset, TextureFrame } from './Asset';
import { type PropertyBag, textOf } from './Properties';
import { findAsset, type Project } from './Project';
@@ -55,8 +56,22 @@ export function textureRefIds(asset: TextureAsset): string[] {
return [asset.id, ...asset.frames.map((f) => f.id)];
}
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
/**
* 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);
}
/** A texture or frame id. An animation is not a picture, which keeps one inside another from resolving. */
export function resolvePicture(project: Project, id: string): TextureRef | null {
if (!id) return null;
const asset = findAsset(project, id);
if (asset) return asset.type === 'texture' ? refFor(asset, null) : null;
+9 -1
View File
@@ -4,7 +4,7 @@
* both of those already import it, and answering this question needs both.
*/
import { schemaForAsset } from '../properties/PropertySchema';
import type { Asset } from './Asset';
import type { AnimationAsset, Asset } from './Asset';
import { overrideSites } from './instance';
import { type ObjectHolder, objectHolders, type Project } from './Project';
import type { SceneObject } from './SceneObject';
@@ -60,3 +60,11 @@ 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. */
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)),
);
}
+15
View File
@@ -152,6 +152,21 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
},
],
// The pictures are intrinsic data on the asset (see AnimationAsset).
animation: [
{ key: 'fps', label: 'Frames per second', kind: 'number', default: 10, min: 0.1, max: 120, step: 1 },
{
key: 'playback',
label: 'Playback',
kind: 'enum',
default: 'loop',
options: [
{ value: 'loop', label: 'Loop' },
{ value: 'once', label: 'Once' },
{ value: 'pingpong', label: 'Ping-pong' },
],
},
],
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }],
collider: [
+9 -5
View File
@@ -1,6 +1,6 @@
import type { PropertyBag } from '../model/Properties';
import type { Project } from '../model/Project';
import { resolveTexture } from '../model/texture';
import { resolveTexture, type TextureRef } from '../model/texture';
export type SizeAxis = 'width' | 'height';
@@ -10,13 +10,17 @@ export interface PixelSize {
}
/**
* Pixel size of the picture a sprite wears: its frame's rectangle, else the whole image.
* Null while that is not known – an upload still running has no measured size yet, and a
* ratio taken from 0 × 0 would be invented.
* Pixel size of the picture a sprite wears – for an animation its first frame. Null while that
* is not known: an upload still running has no measured size yet, and a ratio taken from 0 × 0
* would be invented.
*/
export function texturePixelSize(project: Project, props: PropertyBag): PixelSize | null {
const ref = resolveTexture(project, props);
if (!ref) return null;
return ref ? pictureSize(ref) : null;
}
/** A frame's rectangle, else the whole image. */
export function pictureSize(ref: TextureRef): PixelSize | null {
const w = ref.rect ? ref.rect.w : ref.sourceWidth;
const h = ref.rect ? ref.rect.h : ref.sourceHeight;
return w > 0 && h > 0 ? { w, h } : null;
+19 -10
View File
@@ -1,4 +1,4 @@
import type { TextureAsset, TextureFrame } from '../model/Asset';
import type { AnimationAsset, TextureAsset, TextureFrame } from '../model/Asset';
import { findAsset, type Project } from '../model/Project';
import { findFrame } from '../model/texture';
@@ -7,11 +7,11 @@ export const NO_TEXTURE_LABEL = 'None';
const FRAME_SEPARATOR = ' / ';
export interface TextureChoice {
/** Asset id for the whole picture, frame id for one cut-out of it. */
/** Asset id for the whole picture or an animation, frame id for one cut-out of a picture. */
id: string;
label: string;
asset: TextureAsset;
/** Null for the whole-picture entry. */
asset: TextureAsset | AnimationAsset;
/** Null for the whole-picture entry and for an animation. */
frame: TextureFrame | null;
}
@@ -23,17 +23,26 @@ export function textureRefLabel(project: Project | null, id: string): string {
if (id === '') return NO_TEXTURE_LABEL;
if (!project) return `${id}?`;
const asset = findAsset(project, id);
if (asset) return asset.type === 'texture' ? asset.name : `${id}?`;
if (asset) return asset.type === 'texture' || asset.type === 'animation' ? asset.name : `${id}?`;
const found = findFrame(project, id);
return found ? `${found.asset.name}${FRAME_SEPARATOR}${found.frame.name}` : `${id}?`;
}
/**
* Everything a sprite may wear, in menu order: each texture followed by its own frames. Textures
* are sorted by name so the order does not depend on upload time; frames keep creation order,
* which for a sliced sheet is reading order.
*/
/** Everything a sprite may wear, in menu order: the animations first, a sheet's frames would bury them. */
export function textureRefChoices(project: Project): TextureChoice[] {
const animations = project.assets
.filter((a): a is AnimationAsset => a.type === 'animation')
.sort((a, b) => a.name.localeCompare(b.name))
.map((asset) => ({ id: asset.id, label: `${asset.name} (animation)`, asset, frame: null }));
return [...animations, ...pictureChoices(project)];
}
/**
* Every still picture: each texture followed by its own frames. Textures are sorted by name so the
* order does not depend on upload time; frames keep creation order, which for a sliced sheet is
* reading order.
*/
export function pictureChoices(project: Project): TextureChoice[] {
const textures = project.assets
.filter((a): a is TextureAsset => a.type === 'texture')
.sort((a, b) => a.name.localeCompare(b.name));
+10
View File
@@ -86,6 +86,16 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
});
} else if (type === 'texture') {
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
} 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 !== ''),
});
} else {
assets.push({ id, type, name, props });
}