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 });
}
+10 -3
View File
@@ -11,6 +11,7 @@ import { EditorRun } from './EditorRun';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
import { ListEditor } from './list/ListEditor';
import { AnimationEditor } from './animation/AnimationEditor';
import { AssetPanel } from './panels/AssetPanel';
import { HierarchyPanel } from './panels/HierarchyPanel';
import { Toolbar } from './panels/Toolbar';
@@ -21,15 +22,16 @@ import type { EditorState } from './state';
import { TextureEditor } from './texture/TextureEditor';
import { installTextureImport } from './textureImport';
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture' | 'container' | 'list';
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture' | 'animation' | 'container' | 'list';
const TAB_ORDER = ['scene', 'game', 'graph', 'texture', 'container', 'list'] as const;
const TAB_ORDER = ['scene', 'game', 'graph', 'texture', 'animation', 'container', 'list'] as const;
const TAB_LABELS: Record<CentreTab, string> = {
scene: 'Scene',
game: 'Game',
graph: 'Graph',
texture: 'Texture',
animation: 'Animation',
container: 'Container',
list: 'List',
};
@@ -51,6 +53,7 @@ export class Editor implements PlayHost {
readonly gameView: GameViewport;
readonly graphEditor: GraphEditor;
readonly textureEditor: TextureEditor;
readonly animationEditor: AnimationEditor;
readonly containerEditor: ContainerEditor;
readonly listEditor: ListEditor;
readonly hierarchy: HierarchyPanel;
@@ -86,6 +89,7 @@ export class Editor implements PlayHost {
this.gameView = new GameViewport(state);
this.graphEditor = new GraphEditor(state);
this.textureEditor = new TextureEditor(state);
this.animationEditor = new AnimationEditor(state);
this.containerEditor = new ContainerEditor(state);
this.listEditor = new ListEditor(state);
this.hierarchy = new HierarchyPanel(state);
@@ -129,6 +133,7 @@ export class Editor implements PlayHost {
game: this.gameView,
graph: this.graphEditor,
texture: this.textureEditor,
animation: this.animationEditor,
container: this.containerEditor,
list: this.listEditor,
};
@@ -137,6 +142,7 @@ export class Editor implements PlayHost {
game: this.tabButton('game'),
graph: this.tabButton('graph'),
texture: this.tabButton('texture'),
animation: this.tabButton('animation'),
container: this.tabButton('container'),
list: this.tabButton('list'),
};
@@ -218,7 +224,7 @@ export class Editor implements PlayHost {
}
private tabButton(tab: CentreTab): Button {
return new Button(TAB_LABELS[tab], { variant: 'ghost', width: 90, onClick: () => this.showTab(tab) });
return new Button(TAB_LABELS[tab], { variant: 'ghost', minWidth: 90, onClick: () => this.showTab(tab) });
}
boot(): Promise<void> {
@@ -237,6 +243,7 @@ export class Editor implements PlayHost {
this.measureFps(dt);
this.play.tick(dt);
this.editorRun.tick(dt);
if (this.tab === 'animation') this.animationEditor.tick(dt);
}
private measureFps(dt: number): void {
+3
View File
@@ -10,6 +10,7 @@ import {
behaviorError,
type Trace,
} from '../runtime/systems/BehaviorSystem';
import type { AnimationClock } from '../runtime/Entity';
import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
import { ui } from '../ui';
@@ -29,6 +30,7 @@ export interface WorldSnapshot {
worldY: number;
parent: string;
props: Record<string, unknown>;
animation: AnimationClock | null;
}[];
log: string[];
}
@@ -158,6 +160,7 @@ export class PlaySession {
worldY: w.y,
parent: e.parent?.objectId ?? '',
props: { ...e.props },
animation: e.animation ? { ...e.animation } : null,
};
}),
log: [...world.log],
+13 -4
View File
@@ -12,7 +12,7 @@ import { isLiveInstance, isTemplateContent } from '../core/model/instance';
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import type { SceneObject } from '../core/model/SceneObject';
import { textureRefIds } from '../core/model/texture';
import { findPropertyUsages, findUsages } from '../core/model/usages';
import { animationsUsing, findPropertyUsages, findUsages } from '../core/model/usages';
import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state';
@@ -61,13 +61,14 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
const usages = findUsages(state.project, assetId).length;
// A texture is never placed but worn through a property, so counting placed instances would
// report "unused". Its frames count as the texture itself.
const ids = asset.type === 'texture' ? textureRefIds(asset) : assetId;
const ids = asset.type === 'texture' ? textureRefIds(asset) : [assetId];
const references = findPropertyUsages(state.project, ids).length;
const animations = asset.type === 'texture' ? animationsUsing(state.project, ids).length : 0;
const detail =
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
: references > 0
? `${references} sprite(s) reference it and fall back to their plain colour.`
: references > 0 || animations > 0
? wornDetail(references, animations)
: usages === 0
? 'This cannot be undone with Ctrl+Z after reload.'
: asset.type === 'container'
@@ -78,3 +79,11 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
]).openCentered();
}
function wornDetail(sprites: number, animations: number): string {
const who = [
sprites > 0 ? `${sprites} sprite(s)` : '',
animations > 0 ? `${animations} animation(s)` : '',
].filter(Boolean);
return `${who.join(' and ')} reference it and show the plain colour in its place.`;
}
+440
View File
@@ -0,0 +1,440 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import {
AddAnimationFramesCommand,
MoveAnimationFrameCommand,
RemoveAnimationFramesCommand,
} from '../../core/commands/animationCommands';
import { animationFrameAt, fpsOf, playbackOf } from '../../core/model/animation';
import type { AnimationAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { findFrame, resolvePicture } from '../../core/model/texture';
import { pictureSize } from '../../core/properties/aspect';
import { textureRefLabel } from '../../core/properties/textureRef';
import {
drawControlBox,
Menu,
type Size,
Theme,
textStyle,
truncate,
ui,
Widget,
zoomFactor,
} from '../../ui';
import { spriteFill } from '../../viewport/symbols';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
import { drawChecker } from '../texture/checker';
import { containRect, type Rect } from '../texture/imageGrid';
import { type SheetView, zoomAbout } from '../texture/sheetView';
import {
gapAt,
maxScroll,
moveTarget,
STRIP_HEIGHT,
STRIP_PAD,
TILE,
TILE_GAP,
tileIndexAt,
tileLeft,
} from './strip';
const PREVIEW_PAD = 16;
const THUMB_PAD = 4;
/** Pixels the pointer must travel before a press on a tile becomes a drag. */
const DRAG_THRESHOLD = 3;
/**
* The Animation tab: a playing preview above, the frames as a strip of tiles below. The selected
* tile is local to the tab rather than an editor selection, so the inspector keeps showing the
* animation's fps and playback while its frames are edited.
*/
export class AnimationCanvas extends Widget {
private readonly bg = new Graphics();
private readonly content = new Container();
private readonly preview = new Graphics();
private readonly strip = new Graphics();
private readonly marks = new Graphics();
private readonly captionLayer = new Container();
private readonly captions: Text[] = [];
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly clip = new Graphics();
private assetId = '';
private time = 0;
private scroll = 0;
private press: { index: number; x: number; moved: boolean } | null = null;
private dropGap = -1;
private previewRect: Rect | null = null;
/** Null fits each frame on its own; once zoomed, every frame is centred on the view's origin at one scale. */
private view: SheetView | null = null;
private panning: { x: number; y: number; view: SheetView } | null = null;
playing = true;
/** -1 for none. */
selected = -1;
onPlayingChange?: () => void;
constructor(private readonly state: EditorState) {
super();
this.content.addChild(this.preview, this.strip, this.captionLayer, this.marks, this.hint);
this.content.eventMode = 'none';
this.content.mask = this.clip;
this.addChild(this.bg, this.content, this.clip);
this.focusable = true;
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', () => this.onPointerUp());
this.on('pointerupoutside', () => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => this.redraw());
textureLibrary.onChange(() => this.redraw());
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, STRIP_HEIGHT + 120) };
}
get currentAssetId(): string {
return this.assetId;
}
/** A new subject starts playing from its first frame. */
show(assetId: string): void {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.time = 0;
this.selected = -1;
this.scroll = 0;
this.view = null;
this.setPlaying(true);
this.redraw();
}
fit(): void {
this.scroll = 0;
this.view = null;
this.redraw();
}
tick(dt: number): void {
const asset = this.asset();
if (!this.playing || !asset || asset.frames.length === 0) return;
const before = this.shownIndex(asset);
this.time += dt;
const now = animationFrameAt(asset, this.time);
if (now.finished) {
this.selected = now.index;
this.setPlaying(false);
}
if (now.index !== before || now.finished) this.drawDynamic(asset);
}
/** Pausing holds the frame on screen; playing resumes from the selected frame. */
togglePlaying(): void {
const asset = this.asset();
if (!asset) return;
if (this.playing) {
this.selected = this.shownIndex(asset);
this.setPlaying(false);
} else {
const last = asset.frames.length - 1;
const atEnd = playbackOf(asset) === 'once' && this.selected === last;
const from = this.selected >= 0 && !atEnd ? this.selected : 0;
const fps = fpsOf(asset);
this.time = fps > 0 ? from / fps : 0;
this.setPlaying(true);
}
this.redraw();
}
/** Inserted after the selected frame, else at the end; the last one inserted becomes selected. */
addFrames(refs: readonly string[]): void {
const asset = this.asset();
if (!asset || refs.length === 0) return;
const at = this.selected >= 0 ? this.selected + 1 : asset.frames.length;
if (!this.state.execute(new AddAnimationFramesCommand(asset.id, refs, at))) return;
this.selected = at + refs.length - 1;
this.revealSelected();
this.redraw();
}
/** For the check scripts. */
debugInfo(): {
assetId: string;
frames: number;
shown: number;
playing: boolean;
selected: number;
dropGap: number;
preview: Rect | null;
} {
const asset = this.asset();
const origin = this.getGlobalPosition();
const rect = this.previewRect;
return {
assetId: this.assetId,
frames: asset?.frames.length ?? 0,
shown: this.shownIndex(asset),
playing: this.playing,
selected: this.selected,
dropGap: this.dropGap,
preview: rect && { x: origin.x + rect.x, y: origin.y + rect.y, w: rect.w, h: rect.h },
};
}
/** Where a tile's centre is on screen, null while it is scrolled out of view. */
tileCentre(index: number): { x: number; y: number } | null {
const x = tileLeft(index, this.scroll);
if (x < 0 || x + TILE > this.w) return null;
const origin = this.getGlobalPosition();
return { x: origin.x + x + TILE / 2, y: origin.y + this.stripTop() + STRIP_PAD + TILE / 2 };
}
private asset(): AnimationAsset | null {
return (this.assetId && findAssetOf(this.state.project, this.assetId, 'animation')) || null;
}
private setPlaying(playing: boolean): void {
if (this.playing === playing) return;
this.playing = playing;
this.onPlayingChange?.();
}
private shownIndex(asset: AnimationAsset | null): number {
if (!asset || asset.frames.length === 0) return -1;
if (!this.playing && this.selected >= 0) return this.selected;
return animationFrameAt(asset, this.time).index;
}
private stripTop(): number {
return Math.max(0, this.h - STRIP_HEIGHT);
}
/** A tile picked by hand pauses on it, so what is under the pointer is what the preview shows. */
private pick(index: number): void {
this.selected = index;
if (index >= 0) this.setPlaying(false);
this.redraw();
}
private revealSelected(): void {
const x = tileLeft(this.selected, this.scroll);
if (x < STRIP_PAD) this.scroll += x - STRIP_PAD;
else if (x + TILE > this.w - STRIP_PAD) this.scroll += x + TILE - (this.w - STRIP_PAD);
}
private onPointerDown(e: FederatedPointerEvent): void {
ui().focus.focus(this);
const asset = this.asset();
const local = this.toLocal(e.global);
if (!asset) return;
if (local.y < this.stripTop()) {
const view = (e.button === 1 || (e.button === 0 && e.shiftKey)) && this.currentView(asset);
if (view) {
this.panning = { x: local.x, y: local.y, view };
e.stopPropagation();
}
return;
}
const index = tileIndexAt(local.x, asset.frames.length, this.scroll);
e.stopPropagation();
if (e.button === 2) {
if (index < 0) return;
this.pick(index);
this.openMenu(asset, index, e.global.x, e.global.y);
return;
}
if (e.button !== 0) return;
this.pick(index);
if (index >= 0) this.press = { index, x: local.x, moved: false };
}
private onPointerMove(e: FederatedPointerEvent): void {
const press = this.press;
const asset = this.asset();
const local = this.toLocal(e.global);
const panning = this.panning;
if (panning && asset) {
const { view } = panning;
this.view = { ...view, panX: view.panX + local.x - panning.x, panY: view.panY + local.y - panning.y };
this.drawDynamic(asset);
return;
}
if (!press || !asset) return;
if (!press.moved && Math.abs(local.x - press.x) < DRAG_THRESHOLD) return;
press.moved = true;
this.dropGap = gapAt(local.x, asset.frames.length, this.scroll);
this.drawDynamic(asset);
}
private onPointerUp(): void {
const press = this.press;
const gap = this.dropGap;
this.press = null;
this.panning = null;
this.dropGap = -1;
const asset = this.asset();
if (!press?.moved || !asset) return;
const to = moveTarget(press.index, gap);
if (to !== null && this.state.execute(new MoveAnimationFrameCommand(asset.id, press.index, to)))
this.selected = to;
this.redraw();
}
/** Over the preview the wheel zooms about the pointer, over the strip it scrolls the tiles. */
private onWheel(e: FederatedWheelEvent): void {
const asset = this.asset();
if (!asset) return;
const local = this.toLocal(e.global);
if (local.y < this.stripTop()) {
const view = this.currentView(asset);
if (!view) return;
this.view = zoomAbout(view, zoomFactor(e.deltaY), local.x, local.y);
this.drawDynamic(asset);
} else {
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
this.scroll = Math.max(0, Math.min(maxScroll(asset.frames.length, this.w), this.scroll + delta));
this.redraw();
}
e.preventDefault();
}
/** The manual view, or the one that reproduces the fit of the frame on screen; null without a picture. */
private currentView(asset: AnimationAsset): SheetView | null {
if (this.view) return this.view;
const shown = this.shownIndex(asset);
const picture = shown >= 0 ? resolvePicture(this.state.project, asset.frames[shown]!) : null;
const size = picture && pictureSize(picture);
const rect = this.previewRect;
if (!size || !rect) return null;
return { panX: rect.x + rect.w / 2, panY: rect.y + rect.h / 2, zoom: rect.w / size.w };
}
override onKeyDown(event: KeyboardEvent): boolean {
if ((event.key !== 'Delete' && event.key !== 'Backspace') || this.selected < 0) return false;
const asset = this.asset();
if (asset) this.state.execute(new RemoveAnimationFramesCommand(asset.id, [this.selected]));
return true;
}
private openMenu(asset: AnimationAsset, index: number, x: number, y: number): void {
const ref = asset.frames[index]!;
new Menu([
{
label: 'Duplicate frame',
onSelect: () => {
if (this.state.execute(new AddAnimationFramesCommand(asset.id, [ref], index + 1)))
this.pick(index + 1);
},
},
{
label: 'Remove frame',
danger: true,
onSelect: () => this.state.execute(new RemoveAnimationFramesCommand(asset.id, [index])),
},
]).open(x, y);
}
override redraw(): void {
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
const asset = this.asset();
const count = asset?.frames.length ?? 0;
this.selected = Math.min(this.selected, count - 1);
this.scroll = Math.max(0, Math.min(maxScroll(count, this.w), this.scroll));
this.drawStrip(asset);
this.drawDynamic(asset);
this.syncHint(asset);
}
/** What changes while playing or dragging: the preview, the playhead and the drop marker. */
private drawDynamic(asset: AnimationAsset | null): void {
this.preview.clear();
this.marks.clear();
this.previewRect = null;
const shown = this.shownIndex(asset);
if (!asset || shown < 0) return;
const box = {
x: PREVIEW_PAD,
y: PREVIEW_PAD,
w: this.w - PREVIEW_PAD * 2,
h: this.stripTop() - PREVIEW_PAD * 2,
};
this.previewRect = this.drawPicture(this.preview, asset.frames[shown]!, box, this.view);
const top = this.stripTop();
const x = tileLeft(shown, this.scroll);
this.marks.rect(x, top + 2, TILE, 3).fill(Theme.colors.hot);
if (this.dropGap >= 0) {
const gx = tileLeft(this.dropGap, this.scroll) - TILE_GAP / 2;
this.marks.rect(gx - 1, top + STRIP_PAD - 2, 2, TILE + 4).fill(Theme.colors.accent);
}
}
private drawStrip(asset: AnimationAsset | null): void {
this.strip.clear();
const top = this.stripTop();
this.strip.rect(0, top, this.w, STRIP_HEIGHT).fill(Theme.colors.panel);
this.strip.rect(0, top, this.w, 1).fill(Theme.colors.border);
const frames = asset?.frames ?? [];
while (this.captions.length < frames.length) {
const caption = new Text({ text: '', style: textStyle('small') });
this.captions.push(caption);
this.captionLayer.addChild(caption);
}
this.captions.forEach((caption, index) => {
const x = tileLeft(index, this.scroll);
const ref = frames[index];
caption.visible = ref !== undefined && x + TILE > 0 && x < this.w;
if (!caption.visible || ref === undefined) return;
const y = top + STRIP_PAD;
const selected = index === this.selected;
drawControlBox(this.strip, TILE, TILE, {
x,
y,
fill: selected ? Theme.colors.selection : Theme.colors.control,
border: selected ? Theme.colors.accent : Theme.colors.border,
});
const inset = { x: x + THUMB_PAD, y: y + THUMB_PAD, w: TILE - THUMB_PAD * 2, h: TILE - THUMB_PAD * 2 };
this.drawPicture(this.strip, ref, inset);
caption.text = truncate(`${index + 1} ${this.shortName(ref)}`, caption.style, TILE);
caption.position.set(x, y + TILE + 1);
});
}
/** The frame's own name – its sheet is usually the same for every tile. */
private shortName(ref: string): string {
return findFrame(this.state.project, ref)?.frame.name ?? textureRefLabel(this.state.project, ref);
}
/** Fitted into `box`, or placed by `view`, over a checkerboard; returns where it landed, null without a picture. */
private drawPicture(g: Graphics, ref: string, box: Rect, view: SheetView | null = null): Rect | null {
const picture = resolvePicture(this.state.project, ref);
const size = picture && pictureSize(picture);
if (!picture || !size || box.w <= 0 || box.h <= 0) return null;
let rect: Rect;
if (view) {
const w = size.w * view.zoom;
const h = size.h * view.zoom;
rect = { x: view.panX - w / 2, y: view.panY - h / 2, w, h };
} else {
const fitted = containRect(size.w, size.h, box.w, box.h);
rect = { x: box.x + fitted.x, y: box.y + fitted.y, w: fitted.w, h: fitted.h };
}
drawChecker(g, rect.x, rect.y, rect.w, rect.h, this.w, this.h);
const texture = textureLibrary.get(picture);
if (texture) g.rect(rect.x, rect.y, rect.w, rect.h).fill(spriteFill(texture, 0xffffff, picture));
return rect;
}
private syncHint(asset: AnimationAsset | null): void {
const text = !asset
? 'No animation selected – create one from a texture (right-click it in the Assets panel).'
: asset.frames.length === 0
? 'No frames yet – add pictures with + Frames.'
: '';
this.hint.text = text;
this.hint.visible = text !== '';
this.hint.position.set(Theme.padding * 2, Theme.padding * 2);
}
}
+70
View File
@@ -0,0 +1,70 @@
import { animationDuration, fpsOf, framesOfTexture } from '../../core/model/animation';
import type { AnimationAsset, Asset, TextureAsset } from '../../core/model/Asset';
import { pictureChoices } from '../../core/properties/textureRef';
import { Button, Dropdown, formatNumber, Menu, type MenuItem } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import type { EditorState } from '../state';
import { AnimationCanvas } from './AnimationCanvas';
/** Room kept for the "N frames · 10 fps · 0.8 s" readout. */
const INFO_WIDTH = 170;
export class AnimationEditor extends AssetTabEditor<AnimationAsset, AnimationCanvas> {
/** Public for the check scripts. */
readonly playButton: Button;
readonly addButton: Dropdown;
constructor(state: EditorState) {
super(state, { noun: 'Animation', infoWidth: INFO_WIDTH });
this.playButton = new Button('❚❚', { width: 36, onClick: () => this.canvas.togglePlaying() });
this.addButton = new Dropdown('+ Frames', { onClick: () => this.openAddMenu() });
this.mount(new AnimationCanvas(state), [this.playButton, this.addButton]);
this.canvas.onPlayingChange = () => this.syncExtras(this.asset());
}
/** Only called while the tab is visible: the preview has no clock of its own. */
tick(dt: number): void {
this.canvas.tick(dt);
}
protected override selectedId(): string {
return this.state.selectedAnimationId ?? '';
}
protected override isMine(asset: Asset): asset is AnimationAsset {
return asset.type === 'animation';
}
protected override infoText(asset: AnimationAsset): string {
const count = asset.frames.length;
const seconds = formatNumber(animationDuration(asset), 1);
const fps = Number(fpsOf(asset).toFixed(2));
return `${count} ${count === 1 ? 'frame' : 'frames'} · ${fps} fps · ${seconds} s`;
}
protected override syncExtras(asset: AnimationAsset | null): void {
this.playButton.setText(this.canvas.playing ? '❚❚' : '▶');
this.playButton.setEnabled(Boolean(asset && asset.frames.length > 0));
this.addButton.setEnabled(asset !== null);
}
/** Each sheet offers all its frames at once before the single pictures. */
private openAddMenu(): void {
const items: MenuItem[] = [];
const sheets = new Set<TextureAsset>();
for (const choice of pictureChoices(this.state.project)) {
const sheet = choice.asset as TextureAsset;
if (!sheets.has(sheet) && sheet.frames.length > 1) {
sheets.add(sheet);
items.push({
label: `${sheet.name} – all ${sheet.frames.length} frames`,
onSelect: () => this.canvas.addFrames(framesOfTexture(sheet)),
});
}
items.push({ label: choice.label, onSelect: () => this.canvas.addFrames([choice.id]) });
}
if (items.length === 0)
items.push({ label: 'No textures yet', disabled: true, onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.addButton);
}
}
+37
View File
@@ -0,0 +1,37 @@
/** Geometry of the Animation tab's frame strip, in strip-local pixels before scrolling. */
export const TILE = 72;
export const TILE_GAP = 6;
export const STRIP_PAD = 8;
export const CAPTION_HEIGHT = 16;
export const STRIP_HEIGHT = STRIP_PAD * 2 + TILE + CAPTION_HEIGHT;
export function tileLeft(index: number, scroll: number): number {
return STRIP_PAD + index * (TILE + TILE_GAP) - scroll;
}
/** The tile under `x`, or -1 for the gaps and everything past the last one. */
export function tileIndexAt(x: number, count: number, scroll: number): number {
const from = x + scroll - STRIP_PAD;
if (from < 0) return -1;
const index = Math.floor(from / (TILE + TILE_GAP));
if (index >= count || from - index * (TILE + TILE_GAP) > TILE) return -1;
return index;
}
/** The gap nearest to `x` a dragged tile would drop into: 0 before the first tile, `count` after the last. */
export function gapAt(x: number, count: number, scroll: number): number {
const from = x + scroll - STRIP_PAD + TILE_GAP / 2;
return Math.max(0, Math.min(count, Math.round(from / (TILE + TILE_GAP))));
}
/** Where a tile dropped into `gap` ends up; null when that leaves it where it is. */
export function moveTarget(from: number, gap: number): number | null {
const to = gap > from ? gap - 1 : gap;
return to === from ? null : to;
}
export function maxScroll(count: number, width: number): number {
const content = STRIP_PAD * 2 + count * TILE + Math.max(0, count - 1) * TILE_GAP;
return Math.max(0, content - width);
}
+19
View File
@@ -139,6 +139,17 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
walk(app.stage);
return out;
};
/** What the last visible button labelled `text` draws – shorter when it was truncated. */
const shownLabel = (text: string): string | null => {
let shown: string | null = null;
const walk = (node: Container): void => {
if (!node.visible) return;
if (node instanceof Button && node.text === text) shown = node.shownText;
for (const child of node.children) walk(child);
};
walk(app.stage);
return shown;
};
/** Screen rectangle of the open popup (the backdrop is not a widget), or null when none is. */
const overlayRect = (): { x: number; y: number; w: number; h: number } | null => {
const popups = ui().overlay.children.filter((node): node is Widget => node instanceof Widget);
@@ -168,6 +179,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
overlayRect,
textOverflows,
labelsDropped,
shownLabel,
viewportCenter: () => centreOf(viewport),
viewportInfo: () => viewport.debugInfo(),
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
@@ -228,6 +240,13 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
},
animation: {
/** Frames, the one on screen, play state and where the preview picture is drawn. */
current: () => editor.animationEditor.canvas.debugInfo(),
tileCentre: (index: number) => editor.animationEditor.canvas.tileCentre(index),
playButton: () => centreOf(editor.animationEditor.playButton),
playLabel: () => editor.animationEditor.playButton.text,
},
container: {
current: () => containerEditor.currentAssetId,
pickerWidth: () => containerEditor.picker.w,
+13
View File
@@ -1,4 +1,6 @@
import { AddAnimationFramesCommand } from '../../core/commands/animationCommands';
import { CreateAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands';
import { oneStep } from '../../core/commands/CompoundCommand';
import { nextAssetName } from '../../core/commands/naming';
import {
ASSET_TYPE_LABELS,
@@ -7,6 +9,8 @@ import {
hasColorSwatch,
isPlaceableType,
} from '../../core/model/AssetTypes';
import { framesOfTexture } from '../../core/model/animation';
import type { TextureAsset } from '../../core/model/Asset';
import type { PropertyBag } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { spriteFromTexture } from '../../core/properties/aspect';
@@ -84,6 +88,14 @@ export class AssetPanel extends Panel {
if (this.state.execute(command)) this.state.select({ kind: 'asset', assetId: command.asset.id });
}
/** One undo step for the asset and its frames. */
private createAnimation(texture: TextureAsset): void {
const create = new CreateAssetCommand('animation', nextAssetName(this.state.project, texture.name));
const frames = new AddAnimationFramesCommand(create.asset.id, framesOfTexture(texture));
if (this.state.execute(oneStep(create.label, [create, frames])))
this.state.select({ kind: 'asset', assetId: create.asset.id });
}
/**
* A texture is a picture, so its menu entry offers the ones the server already holds instead of
* creating an empty asset. Uploading a new one is a button inside that dialog.
@@ -135,6 +147,7 @@ export class AssetPanel extends Panel {
label: 'Create sprite from texture',
onSelect: () => this.createAsset('sprite', asset.name, spriteFromTexture(this.state.project, id)),
});
items.push({ label: 'Create animation from texture', onSelect: () => this.createAnimation(asset) });
}
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
const menu = new Menu(items);
+5
View File
@@ -339,6 +339,11 @@ export class EditorState {
return findAssetOf(this.project, s.assetId, 'texture')?.id ?? null;
}
get selectedAnimationId(): string | null {
const s = this.selection;
return s?.kind === 'asset' ? (findAssetOf(this.project, s.assetId, 'animation')?.id ?? null) : null;
}
/** The behavior asset the current selection refers to (asset, placed object or node), if any. */
get selectedBehaviorId(): string | null {
const s = this.selection;
+2 -26
View File
@@ -14,6 +14,7 @@ import { type FrameRect, MIN_FRAME_SIZE, normalizeFrameRect } from '../../core/m
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
import { drawChecker } from './checker';
import {
dragFrame,
fitView,
@@ -33,10 +34,6 @@ import {
const HANDLE_SIZE = 8;
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
const DRAG_THRESHOLD = 3;
/** Side of one square of the transparency checkerboard, in screen pixels. */
const CHECKER_SIZE = 8;
const CHECKER_DARK = 0x1a1c21;
const CHECKER_LIGHT = 0x24272e;
/** Below this on-screen frame size the name is illegible clutter and is left out. */
const MIN_LABEL_SIZE = 28;
/** Inset of a frame's name plate from the frame's top-left corner, and its own padding. */
@@ -349,28 +346,7 @@ export class TextureCanvas extends Widget {
const origin = imageToLocal(this.view, 0, 0);
const w = asset.width * this.view.zoom;
const h = asset.height * this.view.zoom;
this.checker.rect(origin.x, origin.y, w, h).fill(CHECKER_DARK);
// Only the squares inside the widget: a 4000 px sheet zoomed in would otherwise cost tens of
// thousands of rectangles per redraw.
const from = (start: number, limit: number) => Math.max(0, Math.floor((limit - start) / CHECKER_SIZE));
const x0 = from(origin.x, 0);
const y0 = from(origin.y, 0);
const x1 = Math.min(Math.ceil(w / CHECKER_SIZE), from(origin.x, this.w) + 1);
const y1 = Math.min(Math.ceil(h / CHECKER_SIZE), from(origin.y, this.h) + 1);
for (let cy = y0; cy < y1; cy++) {
for (let cx = x0; cx < x1; cx++) {
if ((cx + cy) % 2 === 0) continue;
const px = origin.x + cx * CHECKER_SIZE;
const py = origin.y + cy * CHECKER_SIZE;
this.checker.rect(
px,
py,
Math.min(CHECKER_SIZE, w - cx * CHECKER_SIZE),
Math.min(CHECKER_SIZE, h - cy * CHECKER_SIZE),
);
}
}
this.checker.fill(CHECKER_LIGHT);
drawChecker(this.checker, origin.x, origin.y, w, h, this.w, this.h);
}
private drawFrames(asset: TextureAsset | null): void {
+40
View File
@@ -0,0 +1,40 @@
import type { Graphics } from 'pixi.js';
/** Side of one square of the transparency checkerboard, in screen pixels. */
const CHECKER_SIZE = 8;
const CHECKER_DARK = 0x1a1c21;
const CHECKER_LIGHT = 0x24272e;
/**
* The checkerboard behind a picture at (x, y, w, h). Only squares inside `0..clipW × 0..clipH` are
* drawn: a 4000 px sheet zoomed in would otherwise cost tens of thousands of rectangles per redraw.
*/
export function drawChecker(
g: Graphics,
x: number,
y: number,
w: number,
h: number,
clipW: number,
clipH: number,
): void {
if (w <= 0 || h <= 0) return;
g.rect(x, y, w, h).fill(CHECKER_DARK);
const from = (start: number, limit: number) => Math.max(0, Math.floor((limit - start) / CHECKER_SIZE));
const x0 = from(x, 0);
const y0 = from(y, 0);
const x1 = Math.min(Math.ceil(w / CHECKER_SIZE), from(x, clipW) + 1);
const y1 = Math.min(Math.ceil(h / CHECKER_SIZE), from(y, clipH) + 1);
for (let cy = y0; cy < y1; cy++) {
for (let cx = x0; cx < x1; cx++) {
if ((cx + cy) % 2 === 0) continue;
g.rect(
x + cx * CHECKER_SIZE,
y + cy * CHECKER_SIZE,
Math.min(CHECKER_SIZE, w - cx * CHECKER_SIZE),
Math.min(CHECKER_SIZE, h - cy * CHECKER_SIZE),
);
}
}
g.fill(CHECKER_LIGHT);
}
+8
View File
@@ -21,4 +21,12 @@ 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;
}
export interface AnimationClock {
id: string;
time: number;
finished: boolean;
}
+55
View File
@@ -0,0 +1,55 @@
import { isHandleEnabled } from '../../core/graph';
import { animationFrameAt } from '../../core/model/animation';
import { textOf } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import type { Entity } from '../Entity';
import type { World } from '../World';
import type { System } from './System';
export interface AnimationFinish {
entity: Entity;
animationId: 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`.
*/
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');
if (!asset) {
delete entity.animation;
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 });
}
}
}
stop(): void {
this.finished = [];
}
}
/** 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;
}
+35 -5
View File
@@ -13,9 +13,11 @@ import { stepKeyRepeat } from '../../core/graph/keyRepeat';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import { type PropertyBag, textOf } from '../../core/model/Properties';
import { findAssetOf, type Project } from '../../core/model/Project';
import { parentChain } from '../../core/model/walk';
import type { Entity } from '../Entity';
import type { World } from '../World';
import { bodyOf, type CollisionPair, CollisionSystem } from './CollisionSystem';
import { AnimationSystem } from './AnimationSystem';
import type { Input } from './Input';
import type { System } from './System';
@@ -47,7 +49,7 @@ interface Instance {
*/
const ERROR_PREFIX = 'Error in behavior';
/** Collisions are detected first, so behaviors see this frame's enter/stay/exit events. */
/** Collisions and animations are ticked first, so behaviors see this frame's events. */
export function addGameSystems(
world: World,
project: Project,
@@ -55,12 +57,20 @@ export function addGameSystems(
shouldRun?: (entity: Entity) => boolean,
): BehaviorSystem {
const collisions = new CollisionSystem();
const behaviors = new BehaviorSystem(project, input, collisions, shouldRun);
const animations = new AnimationSystem();
const behaviors = new BehaviorSystem(project, input, { collisions, animations }, shouldRun);
world.addSystem(collisions);
world.addSystem(animations);
world.addSystem(behaviors);
return behaviors;
}
/** The systems whose per-frame results become events; a missing one means no such events. */
export interface EventSources {
collisions?: CollisionSystem | null;
animations?: AnimationSystem | null;
}
/** The behavior failure that stopped a run, if that is why it stopped. */
export function behaviorError(world: World): string | undefined {
return world.log.find((line) => line.startsWith(ERROR_PREFIX));
@@ -69,18 +79,22 @@ export function behaviorError(world: World): string | undefined {
export class BehaviorSystem implements System {
private instances: Instance[] = [];
private world: World | null = null;
private readonly collisions: CollisionSystem | null;
private readonly animations: AnimationSystem | null;
constructor(
private readonly project: Project,
private readonly input: Input,
/** Source of this frame's collision events; null = no collision detection. */
private readonly collisions: CollisionSystem | null = null,
sources: EventSources = {},
/**
* Which behaviors this system is responsible for. Playing runs them all; the editor run
* takes only those marked "run in editor" (see `properties/editorRun.ts`).
*/
private readonly shouldRun: (entity: Entity) => boolean = () => true,
) {}
) {
this.collisions = sources.collisions ?? null;
this.animations = sources.animations ?? null;
}
start(world: World): void {
this.world = world;
@@ -151,6 +165,7 @@ export class BehaviorSystem implements System {
}
}
this.fireCollisions(instance, i, ctx);
this.fireAnimationsFinished(instance, i, ctx);
// With an owner the click has to land on it; a behavior without one has no shape to
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
const pointer = world.pointer;
@@ -209,6 +224,21 @@ export class BehaviorSystem implements System {
fire('OnCollisionExit', this.collisions.exits);
}
/**
* A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below
* it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view.
*/
private fireAnimationsFinished(instance: Instance, interpreter: GraphInterpreter, ctx: NodeContext): void {
const owner = instance.entity.parent;
for (const { entity, animationId } of this.animations?.finished ?? []) {
if (owner && ![...parentChain(entity, (e) => e.parent)].includes(owner)) continue;
interpreter.fire('OnAnimationFinished', ctx, { object: entity }, (node) => {
const wanted = textOf(node.params.animation ?? '');
return wanted === '' || wanted === animationId;
});
}
}
runningIds(): string[] {
return this.instances.map((i) => i.entity.objectId);
}
+3 -1
View File
@@ -99,7 +99,9 @@ export class Button extends Widget {
if (this.fixedWidth) return { w: this.fixedWidth, h };
// From the full label, never the on-screen one: measuring truncated text would keep a button
// that was once too narrow too narrow for good.
const metrics = CanvasTextMetrics.measureText(this.fullText, this.labelText.style);
// Measured bold where it can be drawn bold (on an accent fill), or a danger or toggled tab truncates.
const style = textStyle(this.variant === 'default' ? this.style : 'accent');
const metrics = CanvasTextMetrics.measureText(this.fullText, style);
return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2), h };
}
+3 -2
View File
@@ -7,6 +7,7 @@ import { findAsset, type ObjectHolder, type Project } from '../core/model/Projec
import { resolveTexture, type TextureRef } from '../core/model/texture';
import { enabledAlongChain } from '../core/properties/enabled';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import { animationTime } from '../runtime/systems/AnimationSystem';
import type { World } from '../runtime/World';
/** What the viewport draws: the same shape whether it comes from the model or the world. */
@@ -82,8 +83,8 @@ export function collectWorldItems(world: World): DrawList {
props: e.props,
parentId: e.parent?.objectId ?? '',
enabled: true,
// Resolved per frame on purpose: a `Properties` node may swap `texture`.
texture: e.type === 'sprite' ? resolveTexture(world.project, e.props) : null,
// Resolved per frame on purpose: a `Properties` node may swap `texture`, an animation runs.
texture: e.type === 'sprite' ? resolveTexture(world.project, e.props, animationTime(e)) : null,
}));
return { items, background: world.backgroundColor };
}
+1 -1
View File
@@ -93,7 +93,7 @@ export function drawItem(
* Only a frame gets a matrix: an unmatrixed fill already spreads the whole image over the shape,
* and skipping it spares Pixi an inversion per redraw. See `textureFill.ts`.
*/
function spriteFill(
export function spriteFill(
texture: Texture | null,
color: number,
ref: TextureRef | null,