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, ];