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

+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)),
);
}