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>
60 lines
2.3 KiB
TypeScript
60 lines
2.3 KiB
TypeScript
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];
|
|
}
|