From 1135bf456288c46d2b739df320b0516e504c6141 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 11 Sep 2026 23:52:34 +0200 Subject: [PATCH] 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 --- README.md | 69 ++- src/core/commands/animationCommands.ts | 99 ++++ src/core/commands/assetCommands.ts | 2 + .../graph/nodes/events/OnAnimationFinished.ts | 15 + src/core/graph/nodes/index.ts | 2 + src/core/model/Asset.ts | 12 +- src/core/model/AssetTypes.ts | 11 +- src/core/model/animation.ts | 59 +++ src/core/model/texture.ts | 17 +- src/core/model/usages.ts | 10 +- src/core/properties/PropertySchema.ts | 15 + src/core/properties/aspect.ts | 14 +- src/core/properties/textureRef.ts | 29 +- src/core/store/serialize.ts | 10 + src/editor/Editor.ts | 13 +- src/editor/PlaySession.ts | 3 + src/editor/actions.ts | 17 +- src/editor/animation/AnimationCanvas.ts | 440 ++++++++++++++++++ src/editor/animation/AnimationEditor.ts | 70 +++ src/editor/animation/strip.ts | 37 ++ src/editor/debugHook.ts | 19 + src/editor/panels/AssetPanel.ts | 13 + src/editor/state.ts | 5 + src/editor/texture/TextureCanvas.ts | 28 +- src/editor/texture/checker.ts | 40 ++ src/runtime/Entity.ts | 8 + src/runtime/systems/AnimationSystem.ts | 55 +++ src/runtime/systems/BehaviorSystem.ts | 40 +- src/ui/widgets/Button.ts | 4 +- src/viewport/items.ts | 5 +- src/viewport/symbols.ts | 2 +- tests/core/animation.test.ts | 353 ++++++++++++++ tests/core/node-catalog.test.ts | 1 + tests/editor/animation-strip.test.ts | 38 ++ tests/helpers.ts | 9 +- todo-notes/done/sprite-animation.md | 15 + todo-notes/sprite-animation.md | 8 - tools/e2e/25-animation.mjs | 433 +++++++++++++++++ tools/e2e/index.mjs | 2 + 39 files changed, 1935 insertions(+), 87 deletions(-) create mode 100644 src/core/commands/animationCommands.ts create mode 100644 src/core/graph/nodes/events/OnAnimationFinished.ts create mode 100644 src/core/model/animation.ts create mode 100644 src/editor/animation/AnimationCanvas.ts create mode 100644 src/editor/animation/AnimationEditor.ts create mode 100644 src/editor/animation/strip.ts create mode 100644 src/editor/texture/checker.ts create mode 100644 src/runtime/systems/AnimationSystem.ts create mode 100644 tests/core/animation.test.ts create mode 100644 tests/editor/animation-strip.test.ts create mode 100644 todo-notes/done/sprite-animation.md delete mode 100644 todo-notes/sprite-animation.md create mode 100644 tools/e2e/25-animation.mjs diff --git a/README.md b/README.md index 594341e9..7094a87b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ no movement): everything that happens is defined by behavior graphs. *Delete this project…*), the project name (type to rename), the **Scene** picker for the active scene, Undo/Redo, Play/Pause, and on the right the notice line, the save status and the frame rate. The **Assets** panel's `+` creates an asset; right-clicking a row offers *Set as active -scene* (double-click or `Enter` does the same), *Create sprite from texture* and *Delete…*. In +scene* (double-click or `Enter` does the same), *Create sprite from texture*, *Create animation +from texture* and *Delete…*. In **Scene objects** it offers *Select asset*, *Add collider*, *Create container*, *Unpack container*, *Unparent*, *Expand all* / *Collapse all* and *Remove*. @@ -165,7 +166,7 @@ shown on the node and editable in the inspector. deliberately not used: its delay and rate are an operating-system setting, not a game's. Both rows stay off the node body while the delay is 0, so a plain key node keeps its single line. - **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`, - `On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none), `Start` · flow `Branch`, `Sequence`, `Repeat`, `Run behavior`, `End` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`, + `On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none), `On animation finished`, `Start` · flow `Branch`, `Sequence`, `Repeat`, `Run behavior`, `End` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`, `Is asset` (is this object an instance of asset X – how you tell what you collided with), `Variables`, `Properties`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`, `Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `To number`, `Join text`, @@ -504,6 +505,50 @@ looked like before textures existed. So there is no placeholder, no flicker and Deleting a texture warns how many sprites reference it (a texture is never *placed*, so counting placed instances would report "unused" for a picture a dozen sprites are showing). +### Animations + +An **animation asset** is a list of pictures played one after another. Each entry is what a +sprite's `texture` can hold – a whole texture or one of its frames – so an animation may mix +frames of several sheets, and a picture listed twice is held twice as long; there is no +per-frame duration beyond that. Its two properties are **Frames per second** (default 10) and +**Playback**: *Loop*, *Once* (stops on the last frame) or *Ping-pong* (back and forth, without +showing the turning frames twice). + +**A sprite wears it through `texture`** – the same property, the same picker (animations are +listed first, marked "(animation)") and the same instance overrides as a texture or a frame. +The sprite stays what is drawn, sized, tinted and collided with; the animation is only a picture +that changes over time. *Set to W × H px* takes the size of the first frame. The fastest way in +is right-clicking a texture → **Create animation from texture**, which plays its frames in +order (the whole picture when it has none), named after the texture, in one undo step. + +**The Animation tab** plays the animation above a strip of its frames. `▶` / `❚❚` pauses on the +frame on screen, and resumes from the selected one. Clicking a tile pauses on it, dragging a +tile into another gap reorders the frames, `Delete` removes the selected one, right-click offers +*Duplicate frame* and *Remove frame*, and **+ Frames ▾** inserts after the selection: each +sheet's frames at once, or single pictures. The selected tile belongs to the tab, not to the +editor's selection, so the inspector keeps showing the animation's two properties meanwhile. +The preview fits each frame on its own until the wheel zooms it about the pointer; from then on +every frame is drawn at that one scale, centred, so frames of different sizes show their real +proportion. The middle button (or `Shift`+drag) pans, **Fit** returns to fitting, and over the +strip the wheel scrolls the tiles instead. + +**It runs in play only.** The Scene and Container tabs show the first frame: the edit mode has +no clock and redraws on changes only. The `AnimationSystem` gives every sprite its own clock, kept on the entity and +not in `props` (the world writes changed props back as overrides). The one rule that makes it +usable from a graph: **a different animation starts from its first frame, the same one set again +runs on** – so `On update → Properties (Sprite, texture on Set)` may choose Run or Idle every +frame without freezing the animation on frame one. The system ticks before the behaviors, so +**`On animation finished`** fires in the frame a *Once* animation reaches its end, exactly once, +with the sprite as `object`. Like collisions it is heard by the behaviors of the sprite's owner +subtree – the sprite itself, or a sprite placed next to the behavior below the same object – and +by a behavior without an owner for every sprite; its *Animation* setting narrows it to one. +There is no node to restart an animation that is already running; that and a state machine are +left for when a game needs them. + +Deleting a texture counts the animations that play its frames besides the sprites wearing it; +deleting an animation counts the sprites wearing it. Either leaves dangling references that draw +the plain colour and come back on undo. + **Limits.** nginx caps request bodies at 1 MB, so the editor shrinks images over 2048 px per side and re-encodes to WebP when they are still too big — but an image that already fits is uploaded byte for byte, because re-encoding pixel art would destroy it. Anything still too @@ -604,8 +649,9 @@ selected and switched back on. measures from it – only what is drawn is shortened – and a subclass that puts something else inside the control takes its room off `labelSlot()`, so a long value is cut rather than run underneath the chevron. Given a `width`, a button is exactly that wide and truncates instead: - the tab-bar buttons and the Graph, Texture and Container pickers keep their size whatever they - show. + the Graph, Texture, Animation and Container pickers keep their size whatever they show; the tab + bar uses 90 px as a floor. A button that can draw its label bold – on an accent fill: primary, + danger, a toggled tab – is measured bold, or a dialog's `Delete` comes out as `Del…`. The padding is an inset rather than a claim, so a 24 px icon button spends it on the glyph instead of truncating it away; a button that switches between label forms (`↺` and `↺ ▾`) asks for `minWidth` and grows. `__gm.textOverflows()` guards the whole class of bug: it reports any @@ -627,7 +673,7 @@ src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter, dropTarget, nested (behaviors as nodes), variablePorts, keyRepeat; nodes/{events,flow,data,action,variable}/.ts src/core/store/ serialize (untrusted JSON → Project), http (one API call), HttpProjectStore -src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem +src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, AnimationSystem, BehaviorSystem src/ui/ widget toolkit (generic, knows nothing about assets) src/viewport/ view (transforms), items (what to draw), symbols (how to draw it), textureFill, TextureLibrary – no editor concepts, so tests reach it freely @@ -644,7 +690,8 @@ src/editor/graph/ GraphEditor (header + picker), GraphCanvas, EdgeLayer (cu NodeView, NodePalette + paletteEntries, folding (hidden links, collapsed nodes) src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template) src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths), - ImagePicker + imageGrid (the "+ → Texture" dialog) + ImagePicker + imageGrid (the "+ → Texture" dialog), checker (the transparency board) +src/editor/animation/ AnimationEditor (header + picker), AnimationCanvas (preview + frame strip), strip (its geometry) src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back) api/ PHP backend data/projects/ persisted projects tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, remote/ @@ -677,7 +724,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru # code is lost (tail's is reported), so a red run reads as green npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # optional: run the browser elsewhere; see below -node tools/seed-e2e.mjs && node tools/e2e.mjs # 588 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 618 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step ./e2e.sh start [GM_STEPS] # the same run detached, see below ./e2e.sh status # live progress, or the last run's result @@ -748,7 +795,7 @@ to touch it. The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing, graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect, container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable, -variable nodes, property nodes) that build on each other's +variable nodes, property nodes, animations) that build on each other's scene state and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`, `object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it exercises. @@ -829,7 +876,7 @@ There is no authentication yet; `data/` is world-readable. Both are phase-2 topi ## Keyboard `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` undo/redo (`Cmd` works wherever `Ctrl` does) · `Delete` or -`Backspace` remove the selection (objects, nodes, frames; an asset asks first) · `Ctrl+G` create +`Backspace` remove the selection (objects, nodes, frames, a tile of the Animation tab; an asset asks first) · `Ctrl+G` create container from the selection · `Ctrl+Enter` play, and pause/resume while playing · `Esc` stop · `Ctrl+V` import an image from the clipboard as a texture (ignored while a text field has focus) · `Tab` / `Shift+Tab` cycle fields · lists: `↑`/`↓` move, @@ -847,7 +894,7 @@ object in the selection (hierarchy rows too). Several scenes in one run (a `Load scene` node – screens are containers today), sound, auth, touch-optimised layout, graph comments/groups. A frame is always stretched to -the sprite's `width` × `height`: no animation (nothing steps through a sheet's frames yet) and no -nine-slice. Slicing cuts a regular grid only — no detection of where the sprites actually +the sprite's `width` × `height`: no nine-slice. Animations have no state machine, no restart node +and no preview in the Scene tab. Slicing cuts a regular grid only — no detection of where the sprites actually sit on the sheet. Unreferenced image files are never collected – deliberately, since the image picker offers them again; nothing deletes a stored image except `DELETE ?r=textures/{file}`. diff --git a/src/core/commands/animationCommands.ts b/src/core/commands/animationCommands.ts new file mode 100644 index 00000000..1fe88855 --- /dev/null +++ b/src/core/commands/animationCommands.ts @@ -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[] = []; + + 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!); +} diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 83a8492b..f65b17b2 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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: [] }; } } diff --git a/src/core/graph/nodes/events/OnAnimationFinished.ts b/src/core/graph/nodes/events/OnAnimationFinished.ts new file mode 100644 index 00000000..b65987a2 --- /dev/null +++ b/src/core/graph/nodes/events/OnAnimationFinished.ts @@ -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']), +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 30fe61a0..3766c6a0 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index c3a5d7dc..9c418fd2 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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'; diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 52b1f09b..08346051 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -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 = { 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; +/** Textures and animations are not placeable: they are pictures a sprite wears through `texture`. */ +export type PlaceableAssetType = Exclude; 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 { diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts new file mode 100644 index 00000000..ed0b7375 --- /dev/null +++ b/src/core/model/animation.ts @@ -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]; +} diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 1c679939..aa8cea06 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -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; diff --git a/src/core/model/usages.ts b/src/core/model/usages.ts index 3e4223a4..948372bb 100644 --- a/src/core/model/usages.ts +++ b/src/core/model/usages.ts @@ -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)), + ); +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index cf5127d0..595635ae 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -152,6 +152,21 @@ const ASSET_SCHEMAS: Record = { ], }, ], + // 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: [ diff --git a/src/core/properties/aspect.ts b/src/core/properties/aspect.ts index 2dbe9993..0d14f980 100644 --- a/src/core/properties/aspect.ts +++ b/src/core/properties/aspect.ts @@ -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; diff --git a/src/core/properties/textureRef.ts b/src/core/properties/textureRef.ts index a0584dba..fd78b61c 100644 --- a/src/core/properties/textureRef.ts +++ b/src/core/properties/textureRef.ts @@ -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)); diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 71b5c4a7..9eb3451a 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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 }); } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 2d94277a..b1f6aa3c 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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 = { 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 { @@ -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 { diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 205005f2..64b62de2 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -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; + 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], diff --git a/src/editor/actions.ts b/src/editor/actions.ts index b72c78b0..fd70972d 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -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.`; +} diff --git a/src/editor/animation/AnimationCanvas.ts b/src/editor/animation/AnimationCanvas.ts new file mode 100644 index 00000000..028bf6e0 --- /dev/null +++ b/src/editor/animation/AnimationCanvas.ts @@ -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); + } +} diff --git a/src/editor/animation/AnimationEditor.ts b/src/editor/animation/AnimationEditor.ts new file mode 100644 index 00000000..be4902cc --- /dev/null +++ b/src/editor/animation/AnimationEditor.ts @@ -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 { + /** 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(); + 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); + } +} diff --git a/src/editor/animation/strip.ts b/src/editor/animation/strip.ts new file mode 100644 index 00000000..0fb0ecbe --- /dev/null +++ b/src/editor/animation/strip.ts @@ -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); +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 67bdc067..31879a31 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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, diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 1f7e8664..1858ea37 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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); diff --git a/src/editor/state.ts b/src/editor/state.ts index 88b58621..65bd4ab0 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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; diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 9ee7039f..e625345d 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -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 { diff --git a/src/editor/texture/checker.ts b/src/editor/texture/checker.ts new file mode 100644 index 00000000..cd08d456 --- /dev/null +++ b/src/editor/texture/checker.ts @@ -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); +} diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts index 37aa8840..8381921e 100644 --- a/src/runtime/Entity.ts +++ b/src/runtime/Entity.ts @@ -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; } diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts new file mode 100644 index 00000000..6b837494 --- /dev/null +++ b/src/runtime/systems/AnimationSystem.ts @@ -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; +} diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 29dcbf11..9d9dcb09 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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); } diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index 5935c040..f26f5060 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -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 }; } diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 7824de1a..88d367fc 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -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 }; } diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index b1bd7d04..1523f4d9 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -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, diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts new file mode 100644 index 00000000..d328d9cc --- /dev/null +++ b/tests/core/animation.test.ts @@ -0,0 +1,353 @@ +/** + * Animations: an asset listing pictures, worn by a sprite through `texture` like a texture or a + * frame. The rule picking a frame is pure; the AnimationSystem gives each sprite its own clock. + */ +import { describe, expect, it } from 'vitest'; +import { + AddAnimationFramesCommand, + MoveAnimationFrameCommand, + RemoveAnimationFramesCommand, +} from '../../src/core/commands/animationCommands'; +import { + CreateAssetCommand, + CreateTextureCommand, + SetAssetPropertyCommand, +} from '../../src/core/commands/assetCommands'; +import { CommandError } from '../../src/core/commands/Command'; +import { SliceFramesCommand } from '../../src/core/commands/frameCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { OBJECT_TYPE_PARAM } from '../../src/core/graph'; +import { frameAt, framesOfTexture } from '../../src/core/model/animation'; +import type { AnimationAsset, TextureAsset } from '../../src/core/model/Asset'; +import { findAssetOf } from '../../src/core/model/Project'; +import { resolveTexture } from '../../src/core/model/texture'; +import { deserializeProject } from '../../src/core/store/serialize'; +import { animationsUsing, findPropertyUsages } from '../../src/core/model/usages'; +import { texturePixelSize } from '../../src/core/properties/aspect'; +import { pictureChoices, textureRefChoices, textureRefLabel } from '../../src/core/properties/textureRef'; +import { AnimationSystem } from '../../src/runtime/systems/AnimationSystem'; +import { World } from '../../src/runtime/World'; +import { collectWorldItems } from '../../src/viewport/items'; +import { + graphEditor, + makeBehavior, + makeSpriteScene, + place, + roundTrip, + type SceneFixture, + startWorld, +} from '../helpers'; + +const FILE = `${'a1b2c3d4'.repeat(8)}.png`; + +/** A 64×48 sheet cut into four 32×24 frames in reading order. */ +function addSheet(f: SceneFixture, name = 'Hero'): TextureAsset { + const create = new CreateTextureCommand(name, { file: FILE, width: 64, height: 48 }); + f.stack.execute(create); + f.stack.execute( + new SliceFramesCommand( + create.asset.id, + { columns: 2, rows: 2, offsetX: 0, offsetY: 0, spacingX: 0, spacingY: 0 }, + false, + ), + ); + return create.asset; +} + +function addAnimation(f: SceneFixture, frames: readonly string[], props = {}, name = 'Walk'): AnimationAsset { + const create = new CreateAssetCommand('animation', name, props); + f.stack.execute(create); + if (frames.length > 0) f.stack.execute(new AddAnimationFramesCommand(create.asset.id, frames)); + return findAssetOf(f.project, create.asset.id, 'animation')!; +} + +/** Index of the sheet frame a rectangle is, in reading order: 0 1 / 2 3. */ +const cell = (rect: { x: number; y: number } | null | undefined) => + rect ? rect.x / 32 + (rect.y / 24) * 2 : -1; + +describe('which frame shows when', () => { + it('loops through the frames at the given rate', () => { + const at = (time: number) => frameAt(4, 10, 'loop', time).index; + expect([0, 0.05, 0.1, 0.25, 0.35, 0.4, 0.95].map(at)).toEqual([0, 0, 1, 2, 3, 0, 1]); + }); + + it('plays once, then holds the last frame and says so', () => { + expect(frameAt(3, 10, 'once', 0.25)).toEqual({ index: 2, finished: false }); + expect(frameAt(3, 10, 'once', 0.3)).toEqual({ index: 2, finished: true }); + expect(frameAt(3, 10, 'once', 5)).toEqual({ index: 2, finished: true }); + }); + + it('goes back and forth without repeating the turning frames', () => { + const steps = [0, 1, 2, 3, 4, 5, 6].map((step) => frameAt(3, 1, 'pingpong', step).index); + expect(steps).toEqual([0, 1, 2, 1, 0, 1, 2]); + expect(frameAt(1, 10, 'pingpong', 3).index).toBe(0); + }); + + it('lands on a frame boundary reached by summed ticks, which the float sum misses by a hair', () => { + let time = 0; + for (let i = 0; i < 6; i++) time += 1 / 60; + expect(time * 10).toBeLessThan(1); + expect(frameAt(4, 10, 'loop', time).index).toBe(1); + }); + + it('has no frame without frames, and stands still without a rate', () => { + expect(frameAt(0, 10, 'loop', 1)).toEqual({ index: -1, finished: false }); + expect(frameAt(3, 0, 'once', 100)).toEqual({ index: 0, finished: false }); + }); +}); + +describe('a sprite wearing an animation', () => { + it('shows the frame at the time asked for', () => { + const f = makeSpriteScene(); + const sheet = addSheet(f); + const walk = addAnimation(f, framesOfTexture(sheet)); + const at = (time: number) => cell(resolveTexture(f.project, { texture: walk.id }, time)?.rect); + expect([at(0), at(0.1), at(0.3), at(0.4)]).toEqual([0, 1, 3, 0]); + }); + + it('shows nothing for an empty animation, a frame that dangles, or an animation inside one', () => { + const f = makeSpriteScene(); + const sheet = addSheet(f); + const empty = addAnimation(f, [], {}, 'Empty'); + const dangling = addAnimation(f, ['gone'], {}, 'Dangling'); + const inner = addAnimation(f, [sheet.frames[1]!.id], {}, 'Inner'); + const nested = addAnimation(f, [sheet.id], {}, 'Outer'); + nested.frames.push(inner.id); + expect(resolveTexture(f.project, { texture: empty.id })).toBeNull(); + expect(resolveTexture(f.project, { texture: dangling.id })).toBeNull(); + expect(resolveTexture(f.project, { texture: nested.id }, 0.1)).toBeNull(); + expect(resolveTexture(f.project, { texture: nested.id }, 0)?.file).toBe(FILE); + }); + + it('is sized by its first frame when set to its pixel size', () => { + const f = makeSpriteScene(); + const sheet = addSheet(f); + const walk = addAnimation(f, [sheet.frames[0]!.id, sheet.id]); + expect(texturePixelSize(f.project, { texture: walk.id })).toEqual({ w: 32, h: 24 }); + }); + + it('is offered by the texture picker ahead of the pictures, and named by its name', () => { + const f = makeSpriteScene(); + const sheet = addSheet(f); + const walk = addAnimation(f, [sheet.id]); + const ids = textureRefChoices(f.project).map((c) => c.id); + expect(ids[0]).toBe(walk.id); + expect(ids.slice(1)).toEqual(pictureChoices(f.project).map((c) => c.id)); + expect(pictureChoices(f.project).some((c) => c.id === walk.id)).toBe(false); + expect(textureRefLabel(f.project, walk.id)).toBe('Walk'); + }); +}); + +describe('editing the frames', () => { + it('inserts where asked and takes exactly those back on undo', () => { + const f = makeSpriteScene(); + const sheet = addSheet(f); + const [a, b, c] = sheet.frames.map((fr) => fr.id); + const walk = addAnimation(f, [a!, c!]); + f.stack.execute(new AddAnimationFramesCommand(walk.id, [b!, b!], 1)); + expect(walk.frames).toEqual([a, b, b, c]); + f.stack.undo(); + expect(walk.frames).toEqual([a, c]); + }); + + it('removes several frames and puts each back at its old place', () => { + const f = makeSpriteScene(); + const sheet = addSheet(f); + const ids = sheet.frames.map((fr) => fr.id); + const walk = addAnimation(f, ids); + f.stack.execute(new RemoveAnimationFramesCommand(walk.id, [3, 0, 1])); + expect(walk.frames).toEqual([ids[2]]); + f.stack.undo(); + expect(walk.frames).toEqual(ids); + }); + + it('moves a frame to where it is dropped and back on undo', () => { + const f = makeSpriteScene(); + const ids = addSheet(f).frames.map((fr) => fr.id); + const walk = addAnimation(f, ids); + f.stack.execute(new MoveAnimationFrameCommand(walk.id, 0, 2)); + expect(walk.frames).toEqual([ids[1], ids[2], ids[0], ids[3]]); + f.stack.undo(); + expect(walk.frames).toEqual(ids); + }); + + it('refuses an animation as a frame, and indices that are not there', () => { + const f = makeSpriteScene(); + const walk = addAnimation(f, [addSheet(f).id]); + const run = addAnimation(f, [], {}, 'Run'); + expect(() => new AddAnimationFramesCommand(run.id, [walk.id]).execute(f.project)).toThrow(CommandError); + expect(() => new RemoveAnimationFramesCommand(walk.id, [1]).execute(f.project)).toThrow(CommandError); + expect(() => new MoveAnimationFrameCommand(walk.id, 0, 1).execute(f.project)).toThrow(CommandError); + expect(run.frames).toEqual([]); + expect(walk.frames).toHaveLength(1); + }); + + it('made from a texture plays its frames in order, or the whole picture when it has none', () => { + const f = makeSpriteScene(); + const sheet = addSheet(f); + expect(framesOfTexture(sheet)).toEqual(sheet.frames.map((fr) => fr.id)); + const plain = new CreateTextureCommand('Plain', { file: FILE, width: 8, height: 8 }); + f.stack.execute(plain); + expect(framesOfTexture(plain.asset)).toEqual([plain.asset.id]); + }); +}); + +describe('saving and deleting', () => { + it('keeps the frames through a round trip, dangling ones included, and coerces its settings', () => { + const f = makeSpriteScene(); + const sheet = addSheet(f); + const walk = addAnimation(f, [sheet.id, 'gone', sheet.id], { fps: 12, playback: 'pingpong' }); + const back = findAssetOf(roundTrip(f.project), walk.id, 'animation')!; + expect(back.frames).toEqual([sheet.id, 'gone', sheet.id]); + expect(back.props).toEqual({ fps: 12, playback: 'pingpong' }); + + const raw = structuredClone(f.project) as unknown as { assets: Record[] }; + const stored = raw.assets.find((a) => a.id === walk.id)!; + stored.frames = [sheet.id, 7, '', null]; + stored.props = { fps: -3, playback: 'backwards' }; + const coerced = findAssetOf(deserializeProject(raw), walk.id, 'animation')!; + expect(coerced.frames).toEqual([sheet.id]); + expect(coerced.props).toEqual({ fps: 0.1, playback: 'loop' }); + }); + + it('counts the animations playing a texture, and the sprites wearing an animation', () => { + const f = makeSpriteScene(); + const sheet = addSheet(f); + const walk = addAnimation(f, [sheet.frames[1]!.id]); + addAnimation(f, [], {}, 'Unrelated'); + expect(animationsUsing(f.project, [sheet.id, ...sheet.frames.map((fr) => fr.id)])).toEqual([walk]); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'texture', walk.id)); + expect(findPropertyUsages(f.project, [walk.id])).toHaveLength(1); + }); +}); + +/** A placed sprite wearing `walk`, in a world with the AnimationSystem. */ +function playing(frames = 4, props = {}) { + const f = makeSpriteScene(); + const sheet = addSheet(f); + const walk = addAnimation(f, framesOfTexture(sheet).slice(0, frames), props); + const run = addAnimation(f, [...framesOfTexture(sheet)].reverse(), {}, 'Run'); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'texture', walk.id)); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + return { f, walk, run, box }; +} + +describe('the AnimationSystem', () => { + const drawnCell = (world: ReturnType['world']) => + cell(collectWorldItems(world).items.find((i) => i.type === 'sprite')?.texture?.rect); + + it('runs a clock per sprite, which is what the drawn frame follows', () => { + const { f } = playing(); + const { world, frame } = startWorld(f, { animations: true }); + const seen = [drawnCell(world)]; + for (let i = 0; i < 4; i++) { + frame(0.1); + seen.push(drawnCell(world)); + } + expect(seen).toEqual([0, 0, 1, 2, 3]); + }); + + it('starts a different animation over, and lets the same one set again run on', () => { + const { f, walk, run } = playing(); + const { world, frame } = startWorld(f, { animations: true }); + const sprite = world.entities.find((e) => e.type === 'sprite')!; + frame(0.1); + frame(0.1); + frame(0.1); + sprite.props.texture = walk.id; + frame(0.1); + expect(sprite.animation?.time).toBeCloseTo(0.3); + sprite.props.texture = run.id; + expect(drawnCell(world)).toBe(3); + frame(0.1); + expect(sprite.animation).toEqual({ id: run.id, time: 0, finished: false }); + }); + + it('reports a once animation finished exactly once', () => { + const { f } = playing(2, { playback: 'once' }); + const world = World.instantiate(f.project, f.sceneId); + const system = new AnimationSystem(); + world.addSystem(system); + const reports: number[] = []; + for (let i = 0; i < 6; i++) { + world.tick(0.1); + reports.push(system.finished.length); + } + expect(reports).toEqual([0, 0, 1, 0, 0, 0]); + }); + + it('stands still while the sprite is switched off', () => { + const { f, box } = playing(); + f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'enabled', false)); + const { world, frame } = startWorld(f, { animations: true }); + frame(0.1); + frame(0.5); + expect(world.entities.find((e) => e.type === 'sprite')!.animation?.time).toBe(0); + }); +}); + +describe('On animation finished', () => { + /** The sprite plays `walk` once; a behavior below `holder` counts the event in `done`, the sprite's name in `who`. */ + function listening(options: { holder?: 'sprite' | 'other' | 'none'; filter?: 'walk' | 'run' } = {}) { + const { f, walk, run, box } = playing(2, { playback: 'once' }); + const behavior = makeBehavior(f, 'Logic', ['done']); + f.stack.execute(new AddVariableCommand(behavior.id, 'who', 'text')); + const other = options.holder === 'other' ? place(f, f.spriteId, { x: 300, y: 0 }) : ''; + if (other) f.stack.execute(new SetOverrideCommand(f.sceneId, other, 'texture', '')); + const parent = options.holder === 'none' ? '' : options.holder === 'other' ? other : box; + place(f, behavior.id, { x: 0, y: 0 }, parent); + const edit = graphEditor(f, behavior); + const filter = options.filter === 'walk' ? walk.id : options.filter === 'run' ? run.id : ''; + const event = edit.addNode('OnAnimationFinished', { animation: filter }); + const store = edit.variables(['done', 'who'], { write: true }); + const add = edit.addNode('Add', { b: 1 }); + const name = edit.addNode('NameOf'); + edit.wire([ + [event, 'exec', store, 'exec'], + [store, 'done', add, 'a'], + [add, 'result', store, 'done'], + [event, 'object', name, 'object'], + [name, 'name', store, 'who'], + ]); + const started = startWorld(f, { animations: true, behaviors: true }); + for (let i = 0; i < 8; i++) started.frame(0.1); + const logic = started.world.entities.find((e) => e.type === 'behavior')!; + return { done: logic.props.done, who: logic.props.who }; + } + + it('fires once for the sprite the behavior belongs to, handing the sprite out', () => { + expect(listening()).toEqual({ done: 1, who: 'Box' }); + }); + + it('fires only for the animation it names, when it names one', () => { + expect(listening({ filter: 'walk' }).done).toBe(1); + expect(listening({ filter: 'run' }).done).toBe(0); + }); + + it('is not heard below another sprite, and heard everywhere by a behavior without an owner', () => { + expect(listening({ holder: 'other' }).done).toBe(0); + expect(listening({ holder: 'none' }).done).toBe(1); + }); + + it('keeps a graph that sets the animation every frame from restarting it', () => { + const { f, walk } = playing(); + const behavior = makeBehavior(f, 'Logic'); + const box = f.sceneOf().objects.find((o) => o.assetId === f.spriteId)!.id; + place(f, behavior.id, { x: 0, y: 0 }, box); + const edit = graphEditor(f, behavior); + const update = edit.addNode('OnUpdate'); + const owner = edit.addNode('Owner'); + const wear = edit.properties([{ key: 'texture', kind: 'text', write: true }], { + [OBJECT_TYPE_PARAM]: 'sprite', + texture: walk.id, + }); + edit.wire([ + [update, 'exec', wear, 'exec'], + [owner, 'object', wear, 'object'], + ]); + const { world, frame } = startWorld(f, { animations: true, behaviors: true }); + for (let i = 0; i < 5; i++) frame(0.1); + expect(world.entities.find((e) => e.type === 'sprite')!.animation?.time).toBeCloseTo(0.4); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index c1ed2a8f..a84f4652 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -643,6 +643,7 @@ const EVENT_NODES: Record = { OnCollisionStay: 'behavior-events.test.ts', OnCollisionExit: 'collider.test.ts', OnClick: 'nodes.test.ts', + OnAnimationFinished: 'animation.test.ts', }; describe('node catalog', () => { diff --git a/tests/editor/animation-strip.test.ts b/tests/editor/animation-strip.test.ts new file mode 100644 index 00000000..60113382 --- /dev/null +++ b/tests/editor/animation-strip.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + gapAt, + maxScroll, + moveTarget, + STRIP_PAD, + TILE, + TILE_GAP, + tileIndexAt, +} from '../../src/editor/animation/strip'; + +const STEP = TILE + TILE_GAP; + +describe('the frame strip', () => { + it('finds the tile under the pointer, and none in the gaps or past the last', () => { + const at = (x: number) => tileIndexAt(x, 3, 0); + expect([at(STRIP_PAD), at(STRIP_PAD + TILE), at(STRIP_PAD + STEP + 1)]).toEqual([0, 0, 1]); + expect([at(STRIP_PAD - 1), at(STRIP_PAD + TILE + 2), at(STRIP_PAD + 3 * STEP + 1)]).toEqual([-1, -1, -1]); + expect(tileIndexAt(STRIP_PAD + 1, 3, STEP)).toBe(1); + }); + + it('drops into the gap nearest to the pointer', () => { + const gap = (x: number) => gapAt(x, 3, 0); + expect([gap(0), gap(STRIP_PAD + TILE / 2 - 1), gap(STRIP_PAD + TILE / 2 + 1), gap(9999)]).toEqual([ + 0, 0, 1, 3, + ]); + }); + + it('turns a gap into the index the frame ends up at, and a drop beside itself into nothing', () => { + expect([moveTarget(0, 0), moveTarget(0, 1)]).toEqual([null, null]); + expect([moveTarget(0, 3), moveTarget(2, 0), moveTarget(1, 3)]).toEqual([2, 0, 2]); + }); + + it('scrolls only as far as the last tile needs', () => { + expect(maxScroll(2, 500)).toBe(0); + expect(maxScroll(10, 300)).toBe(STRIP_PAD * 2 + 10 * TILE + 9 * TILE_GAP - 300); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 6fe416ba..2184d206 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -27,6 +27,7 @@ import type { SceneObject } from '../src/core/model/SceneObject'; import { deserializeProject, serializeProject } from '../src/core/store/serialize'; import { BehaviorSystem } from '../src/runtime/systems/BehaviorSystem'; import { CollisionSystem } from '../src/runtime/systems/CollisionSystem'; +import { AnimationSystem } from '../src/runtime/systems/AnimationSystem'; import { Input } from '../src/runtime/systems/Input'; import { World } from '../src/runtime/World'; @@ -230,6 +231,7 @@ export function runNode( export interface WorldOptions { collisions?: boolean; + animations?: boolean; behaviors?: boolean; } @@ -242,7 +244,7 @@ export interface StartedWorld { frame(dt?: number): void; } -/** Instantiates the fixture's scene, optionally with a CollisionSystem and a BehaviorSystem (fed by those collisions). */ +/** Instantiates the fixture's scene, optionally with collisions, animations and a BehaviorSystem fed by both. */ export function startWorld( fixture: SceneFixture, options: WorldOptions & { collisions: true }, @@ -252,8 +254,11 @@ export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): S const world = World.instantiate(fixture.project, fixture.sceneId); const collisions = options.collisions ? new CollisionSystem() : null; const input = new Input(); + const animations = options.animations ? new AnimationSystem() : null; if (collisions) world.addSystem(collisions); - if (options.behaviors) world.addSystem(new BehaviorSystem(fixture.project, input, collisions)); + if (animations) world.addSystem(animations); + if (options.behaviors) + world.addSystem(new BehaviorSystem(fixture.project, input, { collisions, animations })); return { world, collisions, diff --git a/todo-notes/done/sprite-animation.md b/todo-notes/done/sprite-animation.md new file mode 100644 index 00000000..643c629a --- /dev/null +++ b/todo-notes/done/sprite-animation.md @@ -0,0 +1,15 @@ +Sprite-Animation: Frames einer Textur nacheinander abspielen. + +Stand: Texturen lassen sich schon in Frames schneiden (Slice-Dialog), aber ein Sprite zeigt immer genau einen Frame. Nichts spielt sie ab. + +Abzuklären: +- wo die Animation wohnt: eigenes Asset (Frame-Liste + fps + Loop) oder Properties direkt am Sprite +- wie man sie im Graph steuert (starten/stoppen, anderen Clip wählen, "fertig"-Event) +- Vorschau im Editor oder erst im Play + +--- +Entschieden (User-Wahl): +- Eigenes Animation-Asset (Bildliste + fps + Loop/Once/Ping-pong) mit eigenem Tab; das Sprite trägt es über `texture`. +- Kein eigener Node: gewechselt wird per `Properties` (Sprite, `texture` auf Set). Eine andere Animation startet von vorn, dieselbe läuft weiter. Dazu `On animation finished`. +- Scene/Container zeigen den ersten Frame, der Animation-Tab spielt ab, im Play läuft alles. +- State-Machine und ein Neustart-Node erst, wenn ein Spiel sie braucht. diff --git a/todo-notes/sprite-animation.md b/todo-notes/sprite-animation.md deleted file mode 100644 index e9f21e3c..00000000 --- a/todo-notes/sprite-animation.md +++ /dev/null @@ -1,8 +0,0 @@ -Sprite-Animation: Frames einer Textur nacheinander abspielen. - -Stand: Texturen lassen sich schon in Frames schneiden (Slice-Dialog), aber ein Sprite zeigt immer genau einen Frame. Nichts spielt sie ab. - -Abzuklären: -- wo die Animation wohnt: eigenes Asset (Frame-Liste + fps + Loop) oder Properties direkt am Sprite -- wie man sie im Graph steuert (starten/stoppen, anderen Clip wählen, "fertig"-Event) -- Vorschau im Editor oder erst im Play diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs new file mode 100644 index 00000000..3827a336 --- /dev/null +++ b/tools/e2e/25-animation.mjs @@ -0,0 +1,433 @@ +import { colourDistance } from '../lib/harness.mjs'; +import { edge, node, propertiesOf, variable, written } from '../lib/builders.mjs'; +import { MAKE_OBJECT } from './lib/scaffold.mjs'; + +/** + * Animations: made from a texture, edited in their own tab, worn by a sprite through `texture`, + * run in play only, and heard by `On animation finished`. The step pastes a strip of four colour + * cells, so which frame is on screen is a `pixelAt` question. It removes everything it made. + */ + +const CELLS = ['#e63c3c', '#3cc85a', '#466ef0', '#f5f5f5']; +const NAMES = ['red', 'green', 'blue', 'white']; + +const PASTE_STRIP = `(async () => { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 16; + const g = canvas.getContext('2d'); + ${JSON.stringify(CELLS)}.forEach((colour, i) => { g.fillStyle = colour; g.fillRect(i * 16, 0, 16, 16); }); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + const transfer = new DataTransfer(); + transfer.items.add(new File([blob], 'strip.png', { type: 'image/png' })); + window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); +})()`; + +const FRAME_IDS = ['an-f0', 'an-f1', 'an-f2', 'an-f3']; + +function nearestCell(sample) { + let best = -1; + let bestDistance = Infinity; + CELLS.forEach((hex, i) => { + const distance = colourDistance(sample, hex); + if (distance < bestDistance) { + bestDistance = distance; + best = i; + } + }); + return bestDistance < 60 ? NAMES[best] : `other ${sample}`; +} + +/** `On animation finished (walk) → done += 1, and the owner wears Run`. */ +const logicGraph = (walkId) => ({ + nodes: [ + node('an-done', 'OnAnimationFinished', 0, 0, { animation: walkId }), + node('an-count', 'Variables', 300, 0, {}, [written('done', 'number')]), + node('an-add', 'Add', 300, 200, { b: 1 }), + node('an-owner', 'Owner', 300, 360), + node('an-wear', 'Properties', 600, 0, { ...propertiesOf('sprite'), texture: 'an-run' }, [ + written('texture', 'text'), + ]), + ], + edges: [ + edge('an-e1', 'an-done', 'exec', 'an-count', 'exec'), + edge('an-e2', 'an-count', 'done', 'an-add', 'a'), + edge('an-e3', 'an-add', 'result', 'an-count', 'done'), + edge('an-e4', 'an-count', 'exec', 'an-wear', 'exec'), + edge('an-e5', 'an-owner', 'object', 'an-wear', 'object'), + ], +}); + +export async function animation(t) { + const { check, sleep, json, evaluate, shot, mutate, press, click, clickText, drag, undo, redo } = t; + const { mouse, typeText } = t; + const { entity, stop } = t; + const info = () => json('__gm.animation.current()'); + const frames = async (id) => + await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})?.frames ?? null`); + const previewColour = async () => { + const rect = (await info()).preview; + return rect + ? nearestCell( + await json(`__gm.pixelAt(${Math.round(rect.x + rect.w / 2)}, ${Math.round(rect.y + rect.h / 2)})`), + ) + : 'none'; + }; + + await press('Escape'); + await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); + await evaluate('__gm.state.select(null)'); + await sleep(200); + + // 25a. A strip of four cells, cut into four frames and moved to the head of the asset list. + await evaluate(PASTE_STRIP); + await sleep(2500); + const strip = await json( + "(__gm.state.project.assets.find((a) => a.type === 'texture' && a.name === 'strip') ?? {}).id ?? ''", + ); + check('the strip is imported as a texture', strip !== '', strip); + await mutate(` + const p = __gm.state.project; + const index = p.assets.findIndex((a) => a.id === ${JSON.stringify(strip)}); + const texture = p.assets.splice(index, 1)[0]; + texture.props.filter = 'nearest'; + texture.frames = ${JSON.stringify(FRAME_IDS)}.map((id, i) => ({ id, name: 'Cell ' + (i + 1), x: i * 16, y: 0, w: 16, h: 16 })); + p.assets.unshift(texture); + `); + await sleep(400); + + // 25b. The texture's context menu makes an animation of its frames, as one undo step. + const row = await json(`__gm.assetRow(${JSON.stringify(strip)})`); + await click(row.x, row.y, 'right'); + await sleep(300); + await clickText('Create animation from texture'); + await sleep(400); + const made = await json( + "__gm.state.project.assets.filter((a) => a.type === 'animation').map((a) => ({ id: a.id, name: a.name, frames: a.frames, props: a.props }))", + ); + const walk = made[0] ?? {}; + check( + 'Create animation from texture plays its frames in order', + made.length === 1 && JSON.stringify(walk.frames) === JSON.stringify(FRAME_IDS), + JSON.stringify(made), + ); + check( + 'and is selected, named after the texture, at 10 fps looping', + (await json('__gm.state.selection?.assetId')) === walk.id && + walk.name === 'strip 2' && + walk.props?.fps === 10 && + walk.props?.playback === 'loop', + JSON.stringify({ selection: await json('__gm.state.selection'), walk }), + ); + await undo(); + await sleep(300); + const undone = await json("__gm.state.project.assets.filter((a) => a.type === 'animation').length"); + await redo(); + await sleep(300); + check( + 'undo takes asset and frames back in one step, redo brings both', + undone === 0 && JSON.stringify(await frames(walk.id)) === JSON.stringify(FRAME_IDS), + JSON.stringify({ undone, after: await frames(walk.id) }), + ); + + // 25c. The Animation tab shows it and plays it; the inspector keeps its two properties. + await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(walk.id)} })`); + await clickText('Animation'); + await sleep(500); + check( + 'the inspector edits frames per second and playback', + (await t.has('Frames per second')) && (await t.has('Playback')) && (await t.has('Loop')), + ); + const opened = await info(); + check( + 'the tab button has room for its bold label', + (await json("__gm.shownLabel('Animation')")) === 'Animation', + String(await json("__gm.shownLabel('Animation')")), + ); + check( + 'the Animation tab shows the selected animation with its four frames, playing', + (await json('__gm.tab()')) === 'animation' && + opened.assetId === walk.id && + opened.frames === 4 && + opened.playing, + JSON.stringify(opened), + ); + const shown = new Set(); + for (let i = 0; i < 8; i++) { + shown.add((await info()).shown); + await sleep(70); + } + check('the preview runs through the frames', shown.size >= 3, JSON.stringify([...shown])); + await shot('01-playing'); + + // 25d. Pausing holds the frame on screen, and the preview shows that frame's picture. + await click(...Object.values(await json('__gm.animation.playButton()'))); + await sleep(200); + const paused = await info(); + await sleep(350); + check( + 'the play button pauses, and the frame on screen stays', + !paused.playing && + (await json('__gm.animation.playLabel()')) === '▶' && + (await info()).shown === paused.shown, + JSON.stringify({ paused, later: await info() }), + ); + check( + 'the preview draws the frame it says it shows', + (await previewColour()) === NAMES[paused.shown], + JSON.stringify({ shown: paused.shown, colour: await previewColour() }), + ); + + // 25e. A click on a tile selects it and shows it. + const tile = async (i) => json(`__gm.animation.tileCentre(${i})`); + const third = await tile(2); + await click(third.x, third.y); + await sleep(250); + const picked = await info(); + check( + 'clicking a tile selects it and the preview shows it', + picked.selected === 2 && picked.shown === 2 && (await previewColour()) === 'blue', + JSON.stringify({ picked, colour: await previewColour() }), + ); + await shot('02-tile-picked'); + + // 25e'. The wheel zooms the preview about the pointer, the middle button pans, Fit undoes both. + const fitted = picked.preview; + const pointer = { x: Math.round(fitted.x + fitted.w * 0.25), y: Math.round(fitted.y + fitted.h * 0.5) }; + const wheel = async (x, y, deltaY) => { + await mouse('mouseMoved', x, y); + await mouse('mouseWheel', x, y, 'none', { deltaX: 0, deltaY }); + await sleep(250); + }; + await wheel(pointer.x, pointer.y, -240); + const zoomed = (await info()).preview; + const share = (rect) => ((pointer.x - rect.x) / rect.w).toFixed(3); + check( + 'the wheel zooms the preview in about the pointer', + zoomed.w > fitted.w * 1.3 && share(zoomed) === share(fitted), + JSON.stringify({ fitted, zoomed }), + ); + check( + 'the pixel under the pointer is still the frame shown', + nearestCell(await json(`__gm.pixelAt(${pointer.x}, ${pointer.y})`)) === 'blue', + ); + await drag(pointer.x, pointer.y, pointer.x + 40, pointer.y - 30, { button: 'middle' }); + await sleep(200); + const panned = (await info()).preview; + check( + 'the middle button pans it', + Math.round(panned.x - zoomed.x) === 40 && + Math.round(panned.y - zoomed.y) === -30 && + panned.w === zoomed.w, + JSON.stringify({ zoomed, panned }), + ); + await wheel(third.x, third.y, -240); + check( + 'the wheel over the strip leaves the zoom alone', + JSON.stringify((await info()).preview) === JSON.stringify(panned), + JSON.stringify((await info()).preview), + ); + await shot('02b-zoomed'); + await clickText('Fit'); + await sleep(250); + check( + 'Fit puts the preview back', + JSON.stringify((await info()).preview) === JSON.stringify(fitted), + JSON.stringify((await info()).preview), + ); + + // 25f. Dragging a tile into another gap reorders the frames, as one undo step. + const first = await tile(0); + await drag(first.x, first.y, third.x + 39, third.y, 10); + await sleep(300); + const reordered = await frames(walk.id); + check( + 'dragging the first tile behind the third moves that frame there', + JSON.stringify(reordered) === JSON.stringify(['an-f1', 'an-f2', 'an-f0', 'an-f3']) && + (await info()).selected === 2, + JSON.stringify({ reordered, info: await info() }), + ); + await undo(); + await sleep(250); + check('undo puts it back', JSON.stringify(await frames(walk.id)) === JSON.stringify(FRAME_IDS)); + + // 25g. Delete removes the selected tile. + const second = await tile(1); + await click(second.x, second.y); + await sleep(200); + await press('Delete'); + await sleep(250); + const deleted = await frames(walk.id); + check( + 'Delete removes the selected frame and nothing else', + JSON.stringify(deleted) === JSON.stringify(['an-f0', 'an-f2', 'an-f3']) && + (await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(walk.id)})`)), + JSON.stringify(deleted), + ); + await undo(); + await sleep(250); + + // 25h. + Frames offers a sheet's frames at once and inserts them after the selection. + await clickText('+ Frames'); + await sleep(300); + const offered = await json('__gm.overlayTexts()'); + check( + '+ Frames offers the whole strip and each cell, but no animation', + offered.includes('strip – all 4 frames') && + offered.includes('strip / Cell 3') && + !offered.some((s) => s.startsWith('strip 2')), + JSON.stringify(offered), + ); + // In the full suite a 17-frame sheet sorts first and pushes this row out of the list's view. + await typeText('strip'); + await sleep(150); + await clickText('strip – all 4 frames'); + await sleep(300); + const grown = await frames(walk.id); + check( + 'all four go in right after the selected frame', + JSON.stringify(grown) === JSON.stringify(['an-f0', 'an-f1', ...FRAME_IDS, 'an-f2', 'an-f3']), + JSON.stringify(grown), + ); + await undo(); + await sleep(250); + + // 25i. A sprite wears the animation through its Texture picker. + await mutate(` + const p = __gm.state.project; + const sprite = structuredClone(p.assets.find((a) => a.type === 'sprite')); + Object.assign(sprite, { id: 'an-sprite', name: 'Animated' }); + sprite.props = { ...sprite.props, width: 80, height: 80, color: 0xffffff, texture: '' }; + p.assets.unshift(sprite); + const scene = __gm.state.activeScene; + ${MAKE_OBJECT} + const box = make('an-box', 'an-sprite', '', 120, 'Animated'); + box.position.z = 950; + scene.objects.push(box); + __gm.state.select({ kind: 'asset', assetId: 'an-sprite' }); + `); + await sleep(500); + const picker = await json("__gm.inspectorField('Texture', true)"); + await click(picker.x, picker.y); + await sleep(300); + const choices = await json('__gm.overlayTexts()'); + check( + 'the Texture picker offers the animation first, marked as one', + choices.indexOf('strip 2 (animation)') === choices.indexOf('● None') + 1, + JSON.stringify(choices), + ); + await clickText('strip 2 (animation)'); + await sleep(300); + check( + 'picking it makes the sprite wear the animation', + (await json("__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture")) === walk.id, + ); + + // 25j. The scene shows the first frame and does not play. + await evaluate("__gm.showTab('scene')"); + await sleep(700); + const box = await json("__gm.objectRect('an-box')"); + const sceneColour = async () => + nearestCell(await json(`__gm.pixelAt(${Math.round(box.x)}, ${Math.round(box.y)})`)); + const before = await sceneColour(); + await sleep(450); + check( + 'the Scene tab shows the first frame and stays on it', + before === 'red' && (await sceneColour()) === 'red', + JSON.stringify({ box, before, after: await sceneColour() }), + ); + await shot('03-scene-first-frame'); + + // 25k. Play runs it: the pixels the game draws change colour over time. + await t.play(); + await sleep(150); + const colours = new Set(); + for (let i = 0; i < 10; i++) { + const at = await json("__gm.gameScreen('an-box')"); + if (at) colours.add(nearestCell(await json(`__gm.pixelAt(${Math.round(at.x)}, ${Math.round(at.y)})`))); + await sleep(60); + } + const clock = (await entity('an-box'))?.animation; + await shot('04-playing'); + await stop(); + await sleep(300); + check( + 'in play the sprite runs through the frames', + [...colours].filter((c) => NAMES.includes(c)).length >= 3 && clock?.id === walk.id && clock.time > 0, + JSON.stringify({ colours: [...colours], clock }), + ); + + // 25l. Once: the end is an event, and a Properties node puts another animation on. + await mutate(` + const p = __gm.state.project; + p.assets.find((a) => a.id === ${JSON.stringify(walk.id)}).props.playback = 'once'; + p.assets.unshift({ id: 'an-run', type: 'animation', name: 'Run', props: { fps: 10, playback: 'loop' }, frames: ['an-f3'] }); + const logic = structuredClone(p.assets.find((a) => a.id === 'mover')); + Object.assign(logic, { + id: 'an-logic', + name: 'Animation logic', + props: { enabled: true, runInEditor: false, done: 0 }, + variables: ${JSON.stringify([variable('done', 'number', 0)])}, + graph: ${JSON.stringify(logicGraph(walk.id))}, + }); + p.assets.unshift(logic); + const scene = __gm.state.activeScene; + ${MAKE_OBJECT} + scene.objects.push(make('an-brain', 'an-logic', 'an-box', 0, 'Animation logic')); + __gm.state.select(null); + `); + await sleep(400); + await t.play(); + await sleep(900); + const brain = (await entity('an-brain'))?.props; + const worn = await entity('an-box'); + const at = await json("__gm.gameScreen('an-box')"); + const after = at + ? nearestCell(await json(`__gm.pixelAt(${Math.round(at.x)}, ${Math.round(at.y)})`)) + : 'none'; + await stop(); + await sleep(300); + check( + 'On animation finished fires once when a once animation ends', + brain?.done === 1, + JSON.stringify({ brain, log: await json('__gm.state.notice') }), + ); + check( + 'and the Properties node it runs puts the other animation on', + worn?.props.texture === 'an-run' && worn?.animation?.id === 'an-run' && after === 'white', + JSON.stringify({ texture: worn?.props.texture, clock: worn?.animation, after }), + ); + + // 25m. Deleting the texture counts the animations that play its frames. + const again = await json(`__gm.assetRow(${JSON.stringify(strip)})`); + await click(again.x, again.y, 'right'); + await sleep(300); + await clickText('Delete…'); + await sleep(300); + const dialog = await json('__gm.overlayTexts()'); + await shot('05-delete-dialog'); + check( + 'deleting the texture names the animations that use it', + dialog.some((s) => s.includes('2 animation(s)')), + JSON.stringify(dialog), + ); + check('and its bold Delete button is not cut short', dialog.includes('Delete'), JSON.stringify(dialog)); + const cancel = await t.locate('Cancel', true); + await click(cancel.x, cancel.y); + await sleep(300); + + await mutate(` + const p = __gm.state.project; + const mine = new Set(['an-sprite', 'an-run', 'an-logic', ${JSON.stringify(walk.id)}, ${JSON.stringify(strip)}]); + p.assets = p.assets.filter((a) => !mine.has(a.id)); + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => o.id !== 'an-box' && o.id !== 'an-brain'); + __gm.state.select(null); + __gm.state.preview(); + `); + await sleep(300); + const left = await json( + "__gm.state.project.assets.filter((a) => a.type === 'animation' || a.id === 'an-sprite' || a.id === 'an-logic').length", + ); + check('the step leaves the fixture as it found it', left === 0, String(left)); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index bfb75d99..b0135fe9 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -27,6 +27,7 @@ import { listTab } from './21-list.mjs'; import { structVariable } from './22-struct.mjs'; import { variableNodes } from './23-variable-nodes.mjs'; import { propertyNodes } from './24-property-nodes.mjs'; +import { animation } from './25-animation.mjs'; export const STEPS = [ hierarchy, @@ -53,4 +54,5 @@ export const STEPS = [ structVariable, variableNodes, propertyNodes, + animation, ];