diff --git a/src/core/commands/animationCommands.ts b/src/core/commands/animationCommands.ts index 1fe88855..0f2cec26 100644 --- a/src/core/commands/animationCommands.ts +++ b/src/core/commands/animationCommands.ts @@ -1,99 +1,283 @@ -import type { AnimationAsset } from '../model/Asset'; +import { newId } from '../ids'; +import { + ANIMATION_ROOT_TYPES, + animatableDefs, + animatedKind, + normalizeKeys, + targetAssetOf, + trackValueAt, +} from '../model/animation'; +import type { AnimationAsset, AnimationKey, AnimationTrack } from '../model/Asset'; import { findAsset, type Project } from '../model/Project'; -import { type Removed, restoreAt } from '../util/list'; +import type { PropertyValue } from '../model/Properties'; +import { coerceValue } from '../properties/PropertySchema'; import { type Command, CommandError } from './Command'; import { requireAsset } from './require'; +/** A key on one row, as the timeline selects it. */ +export interface KeyRef { + trackId: string; + frame: number; +} + 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}`); +function requireTrack(asset: AnimationAsset, trackId: string): AnimationTrack { + const track = asset.tracks.find((t) => t.id === trackId); + if (!track) throw new CommandError(`${asset.name} has no such row`); + return track; } -/** 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; +/** The kind of `property` on the object `target` names, refusing what cannot be animated there. */ +function requireAnimatable( + project: Project, + asset: AnimationAsset, + target: string, + property: string, +): string { + if (!findAsset(project, asset.root)) throw new CommandError(`${asset.name} has nothing to animate yet`); + const aimed = targetAssetOf(project, asset, target); + if (!aimed) throw new CommandError('That object is not part of the animated asset'); + const kind = animatedKind(aimed.type, property); + if (!kind) throw new CommandError(`${aimed.name} has no property ${property} to animate`); + return kind; +} - 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`; - } +function requireUnique(asset: AnimationAsset, target: string, property: string, except = ''): void { + if (asset.tracks.some((t) => t.id !== except && t.target === target && t.property === property)) + throw new CommandError('That property already has a row'); +} + +/** + * Every edit of a timeline keeps the rows as they were and puts them back on undo – the rows of + * one asset are small, and a key move that swallows another key is exactly undone this way. + */ +abstract class TimelineCommand implements Command { + abstract readonly label: string; + private before: AnimationTrack[] = []; + private rootBefore = ''; + + constructor(protected readonly assetId: string) {} 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.before = structuredClone(asset.tracks); + this.rootBefore = asset.root; + try { + this.apply(project, asset); + } catch (error) { + asset.tracks = this.before; + asset.root = this.rootBefore; + throw error; } - 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); + const asset = requireAnimation(project, this.assetId); + asset.tracks = structuredClone(this.before); + asset.root = this.rootBefore; } + + protected abstract apply(project: Project, asset: AnimationAsset): void; } -export class RemoveAnimationFramesCommand implements Command { - readonly label: string; - private removed: Removed[] = []; +/** The rows stay: a row whose object the new root lacks reads as missing and keeps its keys. */ +export class SetAnimationRootCommand extends TimelineCommand { + readonly label = 'Set animated asset'; constructor( - private readonly assetId: string, - private readonly indices: readonly number[], + assetId: string, + private readonly rootId: string, ) { - this.label = indices.length === 1 ? 'Remove frame' : `Remove ${indices.length} frames`; + super(assetId); } - 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); + protected apply(project: Project, asset: AnimationAsset): void { + if (this.rootId !== '') { + const root = requireAsset(project, this.rootId); + if (!ANIMATION_ROOT_TYPES.includes(root.type)) + throw new CommandError(`A ${root.type} cannot be animated`); + } + asset.root = this.rootId; } } -/** `to` is the index the frame ends up at. */ -export class MoveAnimationFrameCommand implements Command { - readonly label = 'Move frame'; +export class AddAnimationTrackCommand extends TimelineCommand { + readonly label = 'Add row'; + readonly trackId = newId(); 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); + assetId: string, + private readonly target: string, + private readonly property: string, + ) { + super(assetId); } - undo(project: Project): void { - move(requireAnimation(project, this.assetId).frames, this.to, this.from); + protected apply(project: Project, asset: AnimationAsset): void { + requireAnimatable(project, asset, this.target, this.property); + requireUnique(asset, this.target, this.property); + asset.tracks.push({ id: this.trackId, target: this.target, property: this.property, keys: [] }); } } -function move(list: string[], from: number, to: number): void { - const [item] = list.splice(from, 1); - list.splice(to, 0, item!); +export class RemoveAnimationTracksCommand extends TimelineCommand { + readonly label: string; + + constructor( + assetId: string, + private readonly trackIds: readonly string[], + ) { + super(assetId); + this.label = trackIds.length === 1 ? 'Remove row' : `Remove ${trackIds.length} rows`; + } + + protected apply(_project: Project, asset: AnimationAsset): void { + for (const id of this.trackIds) requireTrack(asset, id); + asset.tracks = asset.tracks.filter((t) => !this.trackIds.includes(t.id)); + } +} + +/** + * Aims a row somewhere else and keeps its keys – only at a property of the same kind, since a + * color key would mean nothing on a switch. A row aiming at something gone may go anywhere. + */ +export class RetargetAnimationTrackCommand extends TimelineCommand { + readonly label = 'Change row'; + + constructor( + assetId: string, + private readonly trackId: string, + private readonly target: string, + private readonly property: string, + ) { + super(assetId); + } + + protected apply(project: Project, asset: AnimationAsset): void { + const track = requireTrack(asset, this.trackId); + const kind = requireAnimatable(project, asset, this.target, this.property); + const aimed = targetAssetOf(project, asset, track.target); + const was = aimed ? animatedKind(aimed.type, track.property) : undefined; + if (was !== undefined && was !== kind) throw new CommandError(`A ${was} row cannot become a ${kind} row`); + requireUnique(asset, this.target, this.property, track.id); + track.target = this.target; + track.property = this.property; + if (kind !== 'number') delete track.relative; + } +} + +/** Only a number can be relative: an offset of a color or a picture means nothing. */ +export class SetTrackRelativeCommand extends TimelineCommand { + readonly label: string; + + constructor( + assetId: string, + private readonly trackId: string, + private readonly relative: boolean, + ) { + super(assetId); + this.label = relative ? 'Make row relative' : 'Make row absolute'; + } + + protected apply(project: Project, asset: AnimationAsset): void { + const track = requireTrack(asset, this.trackId); + if (this.relative) { + if (requireAnimatable(project, asset, track.target, track.property) !== 'number') + throw new CommandError('Only a number row can be relative'); + track.relative = true; + } else delete track.relative; + } +} + +/** + * Sets the key at `frame`, replacing one already there. Without a value it takes the value the row + * has at that frame – what a double click in the timeline means – and on an empty row the property's + * default, which for a relative row is no offset at all. + */ +export class SetAnimationKeyCommand extends TimelineCommand { + readonly label = 'Set key'; + + constructor( + assetId: string, + private readonly trackId: string, + private readonly frame: number, + private readonly value?: PropertyValue, + ) { + super(assetId); + } + + protected apply(project: Project, asset: AnimationAsset): void { + const track = requireTrack(asset, this.trackId); + if (!Number.isInteger(this.frame) || this.frame < 0) + throw new CommandError('A key sits on a whole frame from 0'); + const aimed = targetAssetOf(project, asset, track.target); + const def = aimed && animatableDefs(aimed.type).find((d) => d.key === track.property); + if (!def) throw new CommandError('The row aims at something that is gone'); + const current = trackValueAt(track.keys, def.kind, this.frame); + const raw = this.value ?? current ?? (track.relative ? 0 : def.default); + const key: AnimationKey = { frame: this.frame, value: coerceValue(def, raw) }; + track.keys = normalizeKeys([...track.keys, key]); + } +} + +export class RemoveAnimationKeysCommand extends TimelineCommand { + readonly label: string; + + constructor( + assetId: string, + private readonly keys: readonly KeyRef[], + ) { + super(assetId); + this.label = keys.length === 1 ? 'Remove key' : `Remove ${keys.length} keys`; + } + + protected apply(_project: Project, asset: AnimationAsset): void { + if (this.keys.length === 0) throw new CommandError('No keys to remove'); + for (const ref of this.keys) { + const track = requireTrack(asset, ref.trackId); + const at = track.keys.findIndex((k) => k.frame === ref.frame); + if (at < 0) throw new CommandError(`No key on frame ${ref.frame}`); + track.keys.splice(at, 1); + } + } +} + +/** Shifts the keys by `delta` frames together; one landing on a key that stays behind replaces it. */ +export class MoveAnimationKeysCommand extends TimelineCommand { + readonly label: string; + + constructor( + assetId: string, + private readonly keys: readonly KeyRef[], + private readonly delta: number, + ) { + super(assetId); + this.label = keys.length === 1 ? 'Move key' : `Move ${keys.length} keys`; + } + + protected apply(_project: Project, asset: AnimationAsset): void { + if (!Number.isInteger(this.delta)) throw new CommandError('Keys move by whole frames'); + const byTrack = new Map>(); + for (const ref of this.keys) { + const track = requireTrack(asset, ref.trackId); + if (!track.keys.some((k) => k.frame === ref.frame)) + throw new CommandError(`No key on frame ${ref.frame}`); + if (ref.frame + this.delta < 0) throw new CommandError('A key cannot move before frame 0'); + byTrack.set(ref.trackId, (byTrack.get(ref.trackId) ?? new Set()).add(ref.frame)); + } + for (const [trackId, frames] of byTrack) { + const track = requireTrack(asset, trackId); + const staying = track.keys.filter((k) => !frames.has(k.frame)); + const moved = track.keys + .filter((k) => frames.has(k.frame)) + .map((k) => ({ ...k, frame: k.frame + this.delta })); + // Later wins in `normalizeKeys`, so the moved keys go last. + track.keys = normalizeKeys([...staying, ...moved]); + } + } } diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 970454ae..7c0784c4 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -38,7 +38,7 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { case 'container': return { ...base, type: 'container', objects: [] }; case 'animation': - return { ...base, type: 'animation', frames: [] }; + return { ...base, type: 'animation', root: '', tracks: [] }; case 'statemachine': return { ...base, type: 'statemachine', graph: createEmptyGraph() }; case 'shader': diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 5155c710..b09b6710 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -1,3 +1,4 @@ +import type { AnimationPlayer } from '../model/animation'; import type { Mat } from '../model/matrix'; import type { Position, PropertyBag } from '../model/Properties'; import { type ChainTransform, composeAlongChain } from '../model/walk'; @@ -28,9 +29,11 @@ export interface EntityHandle { parent: EntityHandle | null; /** * Counts the Emit pulses a particle source has been given. Kept off `props` for the reason the - * animation clock is: what the world writes back to the model must not grow an override per frame. + * animation players are: what the world writes back to the model must not grow an override per frame. */ emits?: number; + /** What `Play animation` started on this object (see `model/animation.ts`). */ + animations?: AnimationPlayer[]; } /** Position, rotation, scale and mirror of every ancestor applied, nearest last. */ diff --git a/src/core/graph/nodes/action/animationNodes.ts b/src/core/graph/nodes/action/animationNodes.ts new file mode 100644 index 00000000..2a0ac2ea --- /dev/null +++ b/src/core/graph/nodes/action/animationNodes.ts @@ -0,0 +1,64 @@ +import { pauseAnimation, playAnimation, stopAnimation } from '../../../model/animation'; +import { textOf } from '../../../model/Properties'; +import type { PropertyDef } from '../../../properties/PropertySchema'; +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { exec, obj } from '../shared'; + +const animationParam = (): PropertyDef => ({ + key: 'animation', + label: 'Animation', + kind: 'asset', + default: '', + assetTypes: ['animation'], +}); + +export const PlayAnimation: NodeDef = { + type: 'PlayAnimation', + label: 'Play animation', + category: 'action', + description: + 'Plays an animation on an object: its rows reach the object and what is placed inside it, so every ' + + 'instance plays on its own. A paused one runs on from where it stands; one already running is left alone.', + inputs: [exec(), obj('object')], + outputs: [exec()], + params: [animationParam()], + run: (_ctx, inputs, params) => { + const target = asEntity(inputs.object); + const animation = textOf(params.animation ?? ''); + if (target && animation) playAnimation(target, animation); + return { next: 'exec' }; + }, +}; + +export const StopAnimation: NodeDef = { + type: 'StopAnimation', + label: 'Stop animation', + category: 'action', + description: + 'Ends an animation on an object where it stands – the values it set stay. Leave Animation at None to end every one.', + inputs: [exec(), obj('object')], + outputs: [exec()], + params: [animationParam()], + run: (_ctx, inputs, params) => { + const target = asEntity(inputs.object); + if (target) stopAnimation(target, textOf(params.animation ?? '')); + return { next: 'exec' }; + }, +}; + +export const PauseAnimation: NodeDef = { + type: 'PauseAnimation', + label: 'Pause animation', + category: 'action', + description: + 'Holds an animation on an object at its frame until Play animation lets it run on. Leave Animation at None to hold every one.', + inputs: [exec(), obj('object')], + outputs: [exec()], + params: [animationParam()], + run: (_ctx, inputs, params) => { + const target = asEntity(inputs.object); + if (target) pauseAnimation(target, textOf(params.animation ?? '')); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/data/literals.ts b/src/core/graph/nodes/data/literals.ts index 72f2ca2b..299ac74e 100644 --- a/src/core/graph/nodes/data/literals.ts +++ b/src/core/graph/nodes/data/literals.ts @@ -28,7 +28,7 @@ export const StringLiteral = literal('String', 'text', 'A constant text.'); export const TextureLiteral = literal( 'Texture', 'texture', - 'A constant picture: a texture, one of its frames or an animation, as a sprite wears it.', + 'A constant picture: a texture or one of its frames, as a sprite wears it.', ); export const StateMachineLiteral = literal( 'StateMachine', diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 59d394fd..e8b9f8db 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -1,5 +1,6 @@ import { type NodeDef, registerNode } from '../NodeRegistry'; import { Destroy } from './action/Destroy'; +import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes'; import { Emit } from './action/Emit'; import { LoadScene } from './action/LoadScene'; import { Log } from './action/Log'; @@ -204,6 +205,9 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ Spawn, Destroy, Emit, + PlayAnimation, + PauseAnimation, + StopAnimation, PlaySound, StopSound, LoadScene, diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index bfeecc69..7ca1ea65 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -1,7 +1,7 @@ import type { Graph } from '../graph/Graph'; import type { PropertyDef } from '../properties/PropertySchema'; import type { AssetType } from './AssetTypes'; -import type { PropertyBag } from './Properties'; +import type { PropertyBag, PropertyValue } from './Properties'; import type { Scope } from './Scope'; import type { SceneObject } from './SceneObject'; @@ -101,13 +101,38 @@ export interface TextureAsset extends AssetBase { frames: TextureFrame[]; } +/** How a key blends into the next one; absent is `linear`, the only one there is so far (#97). */ +export type KeyInterpolation = 'linear'; + +/** A value on a whole frame. A value that cannot blend (a texture, a switch) holds until the next key. */ +export interface AnimationKey { + frame: number; + value: PropertyValue; + interpolation?: KeyInterpolation; +} + /** - * `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. + * One row of the timeline: a property of the root (`target` empty) or of an object inside it + * (`target` = the template object's id). `keys` are sorted by frame, at most one per frame. + * `relative` adds a number to the value the target had when the animation started. + */ +export interface AnimationTrack { + id: string; + target: string; + property: string; + relative?: boolean; + keys: AnimationKey[]; +} + +/** + * A timeline over one root asset – a sprite, text, container or scene. It is played on an object, + * and its rows reach that object and the objects inside it, so every instance plays on its own. + * The last key decides how long it runs (see `model/animation.ts`). */ export interface AnimationAsset extends AssetBase { type: 'animation'; - frames: string[]; + root: string; + tracks: AnimationTrack[]; } /** diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 272f5e42..ff72a237 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -1,38 +1,35 @@ -import type { AnimationAsset, TextureAsset } from './Asset'; -import { textOf } from './Properties'; +import { isKindWith } from '../properties/kinds'; +import { type PropertyDef, schemaFor } from '../properties/PropertySchema'; +import type { AnimationAsset, AnimationKey, AnimationTrack, Asset } from './Asset'; +import type { AssetType } from './AssetTypes'; +import { findAsset, type Project } from './Project'; +import { colorOf, type PropertyValue, textOf } from './Properties'; +import type { SceneObject } from './SceneObject'; -export const PLAYBACKS = ['loop', 'once', 'pingpong'] as const; +/** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */ +export const PLAYBACKS = ['loop', 'once', 'once-reset', '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. */ +/** What an animation can be built over: something placed, or a whole scene. */ +export const ANIMATION_ROOT_TYPES: readonly AssetType[] = ['sprite', 'text', 'container', 'scene']; + +/** Position lives on the object, not in its property bag, so the timeline offers it by name. */ +export const POSITION_KEYS = ['x', 'y'] as const; + +export function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] { + return (POSITION_KEYS as readonly string[]).includes(key); +} + +export interface PlayheadAt { + /** Frames since the start, fractional: a value between two keys is blended at this point. */ + frame: number; + /** Only the two `once` modes ever finish. */ 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'; @@ -43,16 +40,201 @@ export function fpsOf(asset: AnimationAsset): number { 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); +/** The frame of the last key over all rows; -1 for an animation without keys. */ +export function lastKeyFrame(asset: AnimationAsset): number { + let last = -1; + for (const track of asset.tracks) { + const key = track.keys[track.keys.length - 1]; + if (key && key.frame > last) last = key.frame; + } + return last; +} + +/** + * How many frames one pass lasts: the last key's frame is a frame of its own, so keys on 0…9 are + * ten frames – at 10 fps one second. A pause at the end is a last key that repeats the value. + */ +export function frameCount(asset: AnimationAsset): number { + return lastKeyFrame(asset) + 1; } export function animationDuration(asset: AnimationAsset): number { const fps = fpsOf(asset); - return fps > 0 ? asset.frames.length / fps : 0; + return fps > 0 ? frameCount(asset) / 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]; +/** Where the playhead stands `time` seconds after the start, for a pass of `count` frames. */ +export function playheadAt(count: number, fps: number, playback: Playback, time: number): PlayheadAt { + if (count <= 0) return { frame: 0, finished: playback === 'once' || playback === 'once-reset' }; + if (!Number.isFinite(fps) || fps <= 0 || !(time > 0)) return { frame: 0, finished: false }; + const at = time * fps + STEP_EPSILON; + switch (playback) { + case 'loop': + return { frame: at % count, finished: false }; + case 'once': + case 'once-reset': + return at >= count ? { frame: count, finished: true } : { frame: at, finished: false }; + case 'pingpong': { + const phase = at % (2 * count); + return { frame: phase < count ? phase : 2 * count - phase, finished: false }; + } + } +} + +export function animationPlayheadAt(asset: AnimationAsset, time: number): PlayheadAt { + return playheadAt(frameCount(asset), fpsOf(asset), playbackOf(asset), time); +} + +/** Only these blend between keys; every other kind holds its value until the next key. */ +export function blends(kind: string): boolean { + return kind === 'number' || kind === 'color'; +} + +function lerpColor(a: number, b: number, t: number): number { + const channel = (shift: number) => { + const from = (a >> shift) & 0xff; + const to = (b >> shift) & 0xff; + return Math.round(from + (to - from) * t) << shift; + }; + return channel(16) | channel(8) | channel(0); +} + +/** The value between two keys, `t` from 0 (at `a`) to 1 (at `b`). */ +export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t: number): PropertyValue { + if (kind === 'number') { + const from = Number(a); + const to = Number(b); + return Number.isFinite(from) && Number.isFinite(to) ? from + (to - from) * t : a; + } + if (kind === 'color') return lerpColor(colorOf(a, 0), colorOf(b, 0), t); + return a; +} + +/** + * A row's value at a (fractional) frame: the first value before the first key, the last after the + * last one. `undefined` only for a row without keys. + */ +export function trackValueAt( + keys: readonly AnimationKey[], + kind: string, + frame: number, +): PropertyValue | undefined { + const first = keys[0]; + if (!first) return undefined; + if (frame <= first.frame) return first.value; + for (let i = 1; i < keys.length; i++) { + const next = keys[i]!; + if (frame >= next.frame) continue; + const prev = keys[i - 1]!; + if (!blends(kind)) return prev.value; + return blendValue(kind, prev.value, next.value, (frame - prev.frame) / (next.frame - prev.frame)); + } + return keys[keys.length - 1]!.value; +} + +/** The kind a row's property has on an asset of that type; undefined for one the timeline cannot animate. */ +export function animatedKind(type: AssetType, key: string): string | undefined { + if (isPositionKey(key)) return 'number'; + return animatableDefs(type).find((d) => d.key === key)?.kind; +} + +/** + * The rows the timeline offers for an asset type: position, then the schema's flat properties. A + * list or struct has no single value to key; a shader's own parameters come later (#97). + */ +export function animatableDefs(type: AssetType): PropertyDef[] { + if (type === 'scene') return []; + const position: PropertyDef[] = POSITION_KEYS.map((key) => ({ + key, + label: key.toUpperCase(), + kind: 'number', + default: 0, + })); + const own = schemaFor(type).filter((d) => d.kind !== 'list' && d.kind !== 'struct'); + return [...position, ...own]; +} + +/** The objects a row may aim at below the root: a container's or scene's whole tree, nothing for the rest. */ +export function rootObjects(root: Asset | undefined): readonly SceneObject[] { + return root && (root.type === 'container' || root.type === 'scene') ? root.objects : []; +} + +/** The asset a row aims at: the root itself, or what the object inside it places. Undefined once gone. */ +export function targetAssetOf( + project: Project, + animation: AnimationAsset, + target: string, +): Asset | undefined { + const root = findAsset(project, animation.root); + if (target === '') return root; + const object = rootObjects(root).find((o) => o.id === target); + return object ? findAsset(project, object.assetId) : undefined; +} + +/** Whether a value names something in the project – what deleting that something has to count. */ +export function referencesAny( + track: AnimationTrack, + type: AssetType | undefined, + ids: ReadonlySet, +): boolean { + const kind = type ? animatedKind(type, track.property) : undefined; + if (!kind || !isKindWith('reference', kind)) return false; + return track.keys.some((k) => typeof k.value === 'string' && ids.has(k.value)); +} + +/** + * One animation playing on one object. It lives on the object, off its property bag, like the + * emit counter: what the world writes back must not grow an override per frame. `bases` is what + * each row's target held when the first tick after the start reached it – the zero of a relative + * row and what `once-reset` puts back – keyed ` `. + */ +export interface AnimationPlayer { + animationId: string; + time: number; + playing: boolean; + /** Start order across the whole run: of two players setting one property, the later start wins. */ + order: number; + bases?: Record; +} + +interface PlayerHolder { + animations?: AnimationPlayer[]; +} + +let startCount = 0; + +/** + * Starts the animation on the object, or lets a paused one run on. One already running is left + * alone, so a graph may ask for it every frame without holding it at its first frame. + */ +export function playAnimation(holder: PlayerHolder, animationId: string): void { + const players = (holder.animations ??= []); + const known = players.find((p) => p.animationId === animationId); + if (known) { + known.playing = true; + return; + } + players.push({ animationId, time: 0, playing: true, order: ++startCount }); +} + +/** Ends it where it stands – the values stay; `''` ends every animation on the object. */ +export function stopAnimation(holder: PlayerHolder, animationId: string): void { + if (!holder.animations) return; + holder.animations = holder.animations.filter((p) => animationId !== '' && p.animationId !== animationId); +} + +/** Holds it at its frame until `Play animation` lets it run on; `''` holds every one. */ +export function pauseAnimation(holder: PlayerHolder, animationId: string): void { + for (const player of holder.animations ?? []) + if (animationId === '' || player.animationId === animationId) player.playing = false; +} + +/** Sorted by frame, at most one per frame (the later wins), frames whole and not negative. */ +export function normalizeKeys(keys: readonly AnimationKey[]): AnimationKey[] { + const byFrame = new Map(); + for (const key of keys) { + const frame = Math.max(0, Math.round(key.frame)); + byFrame.set(frame, { ...key, frame }); + } + return [...byFrame.values()].sort((a, b) => a.frame - b.frame); } diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 6a68952e..d926a748 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -1,4 +1,3 @@ -import { animationFrameAt } from './animation'; import type { FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset'; import { type PropertyBag, textOf } from './Properties'; import { findAsset, type Project } from './Project'; @@ -81,21 +80,12 @@ export function textureRefIds(asset: TextureAsset): string[] { return [asset.id, ...asset.frames.map((f) => f.id)]; } -/** - * 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); +/** What a sprite wearing `props.texture` shows. */ +export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null { + return resolvePicture(project, textOf(props.texture ?? '')); } -/** A texture or frame id. An animation is not a picture, which keeps one inside another from resolving. */ +/** A texture or frame id; any other asset's id is no picture. */ export function resolvePicture(project: Project, id: string): TextureRef | null { if (!id) return null; const asset = findAsset(project, id); diff --git a/src/core/model/usages.ts b/src/core/model/usages.ts index f7ff23ef..18b41d66 100644 --- a/src/core/model/usages.ts +++ b/src/core/model/usages.ts @@ -5,6 +5,7 @@ */ import { isKindWith } from '../properties/kinds'; import { schemaForAsset } from '../properties/PropertySchema'; +import { referencesAny, targetAssetOf } from './animation'; import type { AnimationAsset, Asset } from './Asset'; import { overrideSites } from './instance'; import { type ObjectHolder, objectHolders, type Project } from './Project'; @@ -62,10 +63,12 @@ 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. */ +/** The animations keying any of these ids – 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)), + (a): a is AnimationAsset => + a.type === 'animation' && + a.tracks.some((t) => referencesAny(t, targetAssetOf(project, a, t.target)?.type, wanted)), ); } diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 89c795bd..095cedc1 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -201,7 +201,7 @@ const ASSET_SCHEMAS: Record = { // How big the borders of every sliced frame are drawn: 2 makes a 16 px border 32 units wide (#96). { key: SLICE_SCALE_KEY, label: 'Nine-slice scale', kind: 'number', default: 1, min: 0.01, step: 0.1 }, ], - // The pictures are intrinsic data on the asset (see AnimationAsset). + // The root and the rows 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 }, { @@ -211,7 +211,8 @@ const ASSET_SCHEMAS: Record = { default: 'loop', options: [ { value: 'loop', label: 'Loop' }, - { value: 'once', label: 'Once' }, + { value: 'once', label: 'Once (hold)' }, + { value: 'once-reset', label: 'Once (reset)' }, { value: 'pingpong', label: 'Ping-pong' }, ], }, diff --git a/src/core/properties/aspect.ts b/src/core/properties/aspect.ts index 0d14f980..2baafe12 100644 --- a/src/core/properties/aspect.ts +++ b/src/core/properties/aspect.ts @@ -10,7 +10,7 @@ export interface PixelSize { } /** - * Pixel size of the picture a sprite wears – for an animation its first frame. Null while that + * Pixel size of the picture a sprite wears. 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. */ diff --git a/src/core/properties/kinds.ts b/src/core/properties/kinds.ts index 8ebf2ded..4acf28fb 100644 --- a/src/core/properties/kinds.ts +++ b/src/core/properties/kinds.ts @@ -78,7 +78,7 @@ export const KINDS = [ field: true, port: true, }, - /** A texture, one of its frames or an animation – all one id, which is why it is not an `asset`. */ + /** A texture or one of its frames – one id either way, which is why it is not an `asset`. */ { kind: 'texture', label: 'Texture', diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 44b1f972..e3bbc924 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -18,7 +18,8 @@ import { } from '../graph'; import { isParticleNodeDef } from '../particles/nodes'; import { isShaderNodeDef } from '../shader/nodes'; -import type { Asset, StateMachineAsset, TextureFrame } from '../model/Asset'; +import { normalizeKeys } from '../model/animation'; +import type { AnimationKey, AnimationTrack, Asset, StateMachineAsset, TextureFrame } from '../model/Asset'; import { isAssetType } from '../model/AssetTypes'; import { isFontFile } from '../model/font'; import { isSoundFile } from '../model/sound'; @@ -168,15 +169,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { const file = typeof a.file === 'string' && isSoundFile(a.file) ? a.file : ''; assets.push({ id, type, name, props, file }); } 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 !== ''), - }); + // A root or row aiming at something gone is kept: it reads as missing and keeps its keys. + const root = typeof a.root === 'string' ? a.root : ''; + assets.push({ id, type, name, props, root, tracks: normalizeTracks(a.tracks) }); } else { assets.push({ id, type, name, props }); } @@ -461,6 +456,32 @@ function normalizeTexture( return { file, width, height, frames }; } +/** + * The rows of an animation. Keys hold flat values only – a row animates one property – and are + * sorted onto whole frames; a second row on the same target and property, or with an id already + * taken, is dropped like a doubled node. + */ +function normalizeTracks(raw: unknown): AnimationTrack[] { + const tracks: AnimationTrack[] = []; + for (const item of Array.isArray(raw) ? raw : []) { + const t = asRecord(item); + if (typeof t.id !== 'string' || !t.id || typeof t.property !== 'string' || !t.property) continue; + const target = typeof t.target === 'string' ? t.target : ''; + if (tracks.some((o) => o.id === t.id || (o.target === target && o.property === t.property))) continue; + const keys: AnimationKey[] = []; + for (const k of Array.isArray(t.keys) ? t.keys : []) { + const key = asRecord(k); + const frame = Number(key.frame); + if (!Number.isFinite(frame) || !isPrimitive(key.value)) continue; + keys.push({ frame, value: key.value }); + } + const track: AnimationTrack = { id: t.id, target, property: t.property, keys: normalizeKeys(keys) }; + if (t.relative === true) track.relative = true; + tracks.push(track); + } + return tracks; +} + function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag { const r = asRecord(raw); const bag: PropertyBag = {}; diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 22ff5ed5..16a8fda7 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -10,7 +10,7 @@ import { behaviorError, type Trace, } from '../runtime/systems/BehaviorSystem'; -import type { AnimationClock } from '../runtime/Entity'; +import type { AnimationPlayer } from '../core/model/animation'; import type { PropertyBag } from '../core/model/Properties'; import { gameClock } from '../core/time'; import { Input } from '../runtime/systems/Input'; @@ -35,7 +35,7 @@ export interface WorldSnapshot { worldY: number; parent: string; props: Record; - animation: AnimationClock | null; + animations: AnimationPlayer[]; }[]; /** What `global` declarations share this run – no entity carries it, so it is reported apart. */ shared: SharedState; @@ -221,7 +221,7 @@ export class PlaySession { worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props }, - animation: e.animation ? { ...e.animation } : null, + animations: structuredClone(e.animations ?? []), }; }), shared: this.behaviors?.shared ?? emptySharedState(), diff --git a/src/editor/animation/AnimationCanvas.ts b/src/editor/animation/AnimationCanvas.ts deleted file mode 100644 index f3a52170..00000000 --- a/src/editor/animation/AnimationCanvas.ts +++ /dev/null @@ -1,439 +0,0 @@ -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 { 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({ texture }); - 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 index 950d4aac..4c8d1d0a 100644 --- a/src/editor/animation/AnimationEditor.ts +++ b/src/editor/animation/AnimationEditor.ts @@ -1,35 +1,56 @@ -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 { animationDuration, fpsOf, frameCount } from '../../core/model/animation'; +import type { AnimationAsset, Asset } from '../../core/model/Asset'; +import { findAsset } from '../../core/model/Project'; +import { formatNumber, Label, Theme, VStack } from '../../ui'; +import { AssetTabEditor, type TabCanvas } 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; +/** Room kept for the "3 rows · 24 frames · 10 fps · 2.4 s" readout. */ +const INFO_WIDTH = 220; -export class AnimationEditor extends AssetTabEditor { - /** Public for the check scripts. */ - readonly playButton: Button; - readonly addButton: Dropdown; +/** + * Stands where the timeline goes (#97): says what the animation animates and holds the tab's + * subject, so picking, deleting and the readout already work on the new model. + */ +export class TimelinePlaceholder extends VStack implements TabCanvas { + private assetId = ''; + private readonly note = new Label('', { style: 'muted' }); + constructor(private readonly state: EditorState) { + super({ padding: Theme.padding }); + this.add(this.note); + } + + get currentAssetId(): string { + return this.assetId; + } + + show(assetId: string): void { + this.assetId = assetId; + const asset = findAsset(this.state.project, assetId); + const root = asset?.type === 'animation' ? findAsset(this.state.project, asset.root) : undefined; + this.note.setText( + asset?.type !== 'animation' + ? '' + : `Animates ${root ? root.name : 'nothing yet'} – the timeline editor is being built.`, + ); + } + + fit(): void {} +} + +export class AnimationEditor extends AssetTabEditor { 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()); + this.mount(new TimelinePlaceholder(state)); } - /** Only called while the tab is visible: the preview has no clock of its own. */ - tick(dt: number): void { - this.canvas.tick(dt); - } + /** Only called while the tab is visible; the timeline brings its own preview clock. */ + tick(_dt: number): void {} /** Whether the preview is running – a moving picture needs a frame without an event. */ get isPlaying(): boolean { - return this.canvas.playing; + return false; } protected override selectedId(): string { @@ -40,36 +61,16 @@ export class AnimationEditor extends AssetTabEditor 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); + return `${rows} ${rows === 1 ? 'row' : 'rows'} · ${frames} ${frames === 1 ? 'frame' : 'frames'} · ${fps} fps · ${seconds} s`; } } diff --git a/src/editor/animation/strip.ts b/src/editor/animation/strip.ts deleted file mode 100644 index 0fb0ecbe..00000000 --- a/src/editor/animation/strip.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** 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 44f0db02..cde49630 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -461,11 +461,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E rect: () => textureEditor.canvasRect(), }, 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, + current: () => editor.animationEditor.currentAssetId, + info: () => editor.animationEditor.shownInfo, }, container: { current: () => containerEditor.currentAssetId, diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index a3cb0c8a..414db430 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -1,4 +1,8 @@ -import { AddAnimationFramesCommand } from '../../core/commands/animationCommands'; +import { + AddAnimationTrackCommand, + SetAnimationKeyCommand, + SetAnimationRootCommand, +} from '../../core/commands/animationCommands'; import { CreateAssetCommand, SetActiveSceneCommand, @@ -12,7 +16,6 @@ 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'; @@ -131,11 +134,26 @@ export class AssetPanel extends Panel { if (this.state.execute(command)) this.state.openAsset(command.asset.id); } - /** One undo step for the asset and its frames. */ + /** + * A sprite wearing the texture and an animation over it whose one row keys the frames in order, + * one per frame – what a picture sequence is on a timeline. One undo step for all of it. + */ 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.openAsset(create.asset.id); + const project = this.state.project; + const sprite = new CreateAssetCommand( + 'sprite', + nextAssetName(project, texture.name), + spriteFromTexture(project, texture.id), + ); + const create = new CreateAssetCommand('animation', nextAssetName(project, texture.name)); + const root = new SetAnimationRootCommand(create.asset.id, sprite.asset.id); + const row = new AddAnimationTrackCommand(create.asset.id, '', 'texture'); + const pictures = texture.frames.length > 0 ? texture.frames.map((f) => f.id) : [texture.id]; + const keys = pictures.map( + (id, frame) => new SetAnimationKeyCommand(create.asset.id, row.trackId, frame, id), + ); + if (this.state.execute(oneStep(create.label, [sprite, create, root, row, ...keys]))) + this.state.openAsset(create.asset.id); } /** diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts index c0ee3dc8..d32532d4 100644 --- a/src/runtime/Entity.ts +++ b/src/runtime/Entity.ts @@ -1,4 +1,5 @@ import type { ENTITY_HANDLE } from '../core/graph/NodeContext'; +import type { AnimationPlayer } from '../core/model/animation'; import type { AssetType } from '../core/model/AssetTypes'; import type { PropertyBag } from '../core/model/Properties'; @@ -21,14 +22,8 @@ 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; + /** The animations playing on this object; kept out of `props`, which the world writes back. */ + animations?: AnimationPlayer[]; /** How often an `Emit` node has restarted this particle source; out of `props` for the same reason. */ emits?: number; } - -export interface AnimationClock { - id: string; - time: number; - finished: boolean; -} diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 6b837494..dfcb432a 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -1,45 +1,76 @@ import { isHandleEnabled } from '../../core/graph'; -import { animationFrameAt } from '../../core/model/animation'; -import { textOf } from '../../core/model/Properties'; +import { + type AnimationPlayer, + animatedKind, + animationPlayheadAt, + isPositionKey, + playbackOf, + trackValueAt, +} from '../../core/model/animation'; +import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; +import { instanceChildId, splitInstanceId } from '../../core/model/instance'; import { findAssetOf } from '../../core/model/Project'; +import type { PropertyValue } from '../../core/model/Properties'; import type { Entity } from '../Entity'; import type { World } from '../World'; import type { System } from './System'; export interface AnimationFinish { + /** The object the animation was played on. */ entity: Entity; animationId: string; } +/** A row that reaches something this tick: the entity it writes and the kind it blends as. */ +interface Binding { + track: AnimationTrack; + entity: Entity; + kind: string; + base: 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`. + * Plays what `Play animation` started. Players run in start order, so of two setting one property + * the later start wins. Runs before the BehaviorSystem: a behavior writing the same property writes + * last, and hears 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'); + const running: { entity: Entity; player: AnimationPlayer }[] = []; + for (const entity of world.entities) + for (const player of entity.animations ?? []) running.push({ entity, player }); + running.sort((a, b) => a.player.order - b.player.order); + for (const { entity, player } of running) { + const asset = findAssetOf(world.project, player.animationId, 'animation'); if (!asset) { - delete entity.animation; + drop(entity, player); 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 }); + const bindings = bind(world, asset, entity); + if (!player.bases) { + // The first tick only takes the starting values and shows frame 0; the clock runs from the next. + player.bases = {}; + for (const b of bindings) player.bases[b.base] = read(b.entity, b.track.property); + } else if (player.playing && isHandleEnabled(entity)) player.time += dt; + const head = animationPlayheadAt(asset, player.time); + for (const b of bindings) { + const value = trackValueAt(b.track.keys, b.kind, head.frame); + if (value === undefined) continue; + const base = player.bases[b.base]; + const offset = b.track.relative && b.kind === 'number' && typeof base === 'number'; + write(b.entity, b.track.property, offset ? base + Number(value) : value); } + if (!head.finished) continue; + if (playbackOf(asset) === 'once-reset') + for (const b of bindings) { + const base = player.bases[b.base]; + if (base !== undefined) write(b.entity, b.track.property, base); + } + drop(entity, player); + this.finished.push({ entity, animationId: asset.id }); } } @@ -48,8 +79,47 @@ export class AnimationSystem implements System { } } -/** 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; +function drop(entity: Entity, player: AnimationPlayer): void { + entity.animations = entity.animations?.filter((p) => p !== player); +} + +/** + * The rows that reach something on this object. A scene's rows aim at its objects by id, and only + * while that scene runs; everything else aims at the object itself or at what its template placed + * inside it. A row whose target is gone, or lacks the property, is skipped. + */ +function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] { + const root = world.project.assets.find((a) => a.id === asset.root); + if (!root) return []; + const bindings: Binding[] = []; + for (const track of asset.tracks) { + let entity: Entity | undefined; + if (root.type === 'scene') + entity = world.sceneId === root.id && track.target ? world.findByObjectId(track.target) : undefined; + else if (track.target === '') entity = owner; + else entity = world.findByObjectId(childId(owner.objectId, track.target)); + const kind = entity && animatedKind(entity.type, track.property); + if (entity && kind) bindings.push({ track, entity, kind, base: `${entity.id} ${track.property}` }); + } + return bindings; +} + +/** The id a template object gets below a placed container – nested one deeper when the container is content itself. */ +function childId(ownerId: string, templateId: string): string { + const split = splitInstanceId(ownerId); + return split + ? instanceChildId(split.instanceId, `${split.path}/${templateId}`) + : instanceChildId(ownerId, templateId); +} + +function read(entity: Entity, key: string): PropertyValue { + if (isPositionKey(key)) return entity[key]; + return entity.props[key] ?? 0; +} + +function write(entity: Entity, key: string, value: PropertyValue): void { + if (isPositionKey(key)) { + const n = Number(value); + if (Number.isFinite(n)) entity[key] = n; + } else entity.props[key] = value; } diff --git a/src/viewport/items.ts b/src/viewport/items.ts index ddcbaff9..da5a069b 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -12,7 +12,6 @@ import { enabledAlongChain } from '../core/properties/enabled'; import { fontOf } from '../core/properties/fonts'; import { resolveSceneObjectProps } from '../core/properties/resolve'; import { editorTransform } from '../core/properties/transform'; -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. */ @@ -116,8 +115,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`, an animation runs. - texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props, animationTime(e)) : null, + // Resolved per frame on purpose: a `Properties` node or an animation may swap `texture`. + texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props) : null, font: e.type === 'text' ? fontFileOf(world.project, fontOf(e.props)) : '', particles: e.type === 'particles' ? (findAssetOf(world.project, e.assetId, 'particles') ?? null) : null, emits: e.emits ?? 0, diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index d328d9cc..e72da9d1 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -1,34 +1,38 @@ /** - * 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. + * Animations (#97): a timeline over one root asset. Each row keys one property of the root or of an + * object inside it; played on an object, the rows reach that object, so every instance plays on its + * own. The value rules are pure; the AnimationSystem gives each object its players. */ import { describe, expect, it } from 'vitest'; import { - AddAnimationFramesCommand, - MoveAnimationFrameCommand, - RemoveAnimationFramesCommand, + AddAnimationTrackCommand, + MoveAnimationKeysCommand, + RemoveAnimationKeysCommand, + RetargetAnimationTrackCommand, + SetAnimationKeyCommand, + SetAnimationRootCommand, + SetTrackRelativeCommand, } from '../../src/core/commands/animationCommands'; -import { - CreateAssetCommand, - CreateTextureCommand, - SetAssetPropertyCommand, -} from '../../src/core/commands/assetCommands'; +import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands'; import { CommandError } from '../../src/core/commands/Command'; -import { SliceFramesCommand } from '../../src/core/commands/frameCommands'; +import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; 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 { + animationDuration, + frameCount, + pauseAnimation, + playAnimation, + playheadAt, + stopAnimation, + trackValueAt, +} from '../../src/core/model/animation'; +import type { AnimationAsset, AnimationKey, ContainerAsset } from '../../src/core/model/Asset'; +import { instanceChildId } from '../../src/core/model/instance'; +import { findAsset, findAssetOf } from '../../src/core/model/Project'; +import { animationsUsing } from '../../src/core/model/usages'; 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, @@ -39,315 +43,402 @@ import { startWorld, } from '../helpers'; -const FILE = `${'a1b2c3d4'.repeat(8)}.png`; +const keys = (...pairs: [number, AnimationKey['value']][]): AnimationKey[] => + pairs.map(([frame, value]) => ({ frame, value })); -/** 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 { +/** An animation over `root` in the fixture, props through the schema like any asset's. */ +function addAnimation(f: SceneFixture, root: 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)); + f.stack.execute(new SetAnimationRootCommand(create.asset.id, root)); 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; +/** A row on `animation` with a key per pair; returns the row's id. */ +function addRow( + f: SceneFixture, + animation: AnimationAsset, + property: string, + pairs: [number, AnimationKey['value']][], + target = '', +): string { + const row = new AddAnimationTrackCommand(animation.id, target, property); + f.stack.execute(row); + for (const [frame, value] of pairs) + f.stack.execute(new SetAnimationKeyCommand(animation.id, row.trackId, frame, value)); + return row.trackId; +} -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]); +const rowOf = (animation: AnimationAsset, id: string) => animation.tracks.find((t) => t.id === id)!; + +describe('where the playhead stands', () => { + it('counts the last key frame as a frame of its own, so keys on 0…9 last one second at 10 fps', () => { + const f = makeSpriteScene(); + const walk = addAnimation(f, f.spriteId); + addRow(f, walk, 'x', [ + [0, 0], + [9, 90], + ]); + expect(frameCount(walk)).toBe(10); + expect(animationDuration(walk)).toBe(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('runs between frames, so a slow rate still moves every tick', () => { + expect(playheadAt(10, 10, 'loop', 0.05).frame).toBeCloseTo(0.5); + expect(playheadAt(10, 10, 'loop', 1.05).frame).toBeCloseTo(0.5); }); - 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 }); + it('finishes only in the two once modes, and ping-pong turns at the end', () => { + expect(playheadAt(10, 10, 'once', 1.2)).toEqual({ frame: 10, finished: true }); + expect(playheadAt(10, 10, 'once-reset', 0.5).finished).toBe(false); + expect(playheadAt(10, 10, 'pingpong', 1.5).frame).toBeCloseTo(5); + expect(playheadAt(10, 10, 'loop', 30).finished).toBe(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]); +describe('the value of a row', () => { + const row = keys([10, 100], [20, 200]); + + it('holds the first value before the first key and the last after the last', () => { + expect(trackValueAt(row, 'number', 0)).toBe(100); + expect(trackValueAt(row, 'number', 99)).toBe(200); + expect(trackValueAt(keys([4, 7]), 'number', 50)).toBe(7); + expect(trackValueAt([], 'number', 0)).toBeUndefined(); }); - 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('blends a number linearly, also at a fraction of a frame', () => { + expect(trackValueAt(row, 'number', 15)).toBe(150); + expect(trackValueAt(row, 'number', 10.5)).toBe(105); }); - 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('blends a color over its channels', () => { + expect(trackValueAt(keys([0, 0x000000], [10, 0xff8000]), 'color', 5)).toBe(0x804000); }); - 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'); + it('lets a rotation run the long way, since a number knows no turn', () => { + expect(trackValueAt(keys([0, 350], [10, 10]), 'number', 5)).toBe(180); + }); + + it('holds what cannot blend until the next key', () => { + const pictures = keys([0, 'a'], [2, 'b']); + expect(trackValueAt(pictures, 'texture', 1.9)).toBe('a'); + expect(trackValueAt(pictures, 'texture', 2)).toBe('b'); + expect(trackValueAt(keys([0, true], [5, false]), 'boolean', 4)).toBe(true); }); }); -describe('editing the frames', () => { - it('inserts where asked and takes exactly those back on undo', () => { +describe('editing the timeline', () => { + it('builds only over a sprite, text, container or scene', () => { 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]); + const walk = addAnimation(f, f.spriteId); + const behavior = makeBehavior(f); + expect(() => f.stack.execute(new SetAnimationRootCommand(walk.id, behavior.id))).toThrow(CommandError); + expect(findAssetOf(f.project, walk.id, 'animation')!.root).toBe(f.spriteId); + }); + + it('refuses a property the target lacks, and a second row on the same one', () => { + const f = makeSpriteScene(); + const walk = addAnimation(f, f.spriteId); + addRow(f, walk, 'rotation', []); + expect(() => f.stack.execute(new AddAnimationTrackCommand(walk.id, '', 'fontSize'))).toThrow( + CommandError, + ); + expect(() => f.stack.execute(new AddAnimationTrackCommand(walk.id, '', 'rotation'))).toThrow( + CommandError, + ); + expect(walk.tracks).toHaveLength(1); + }); + + it('keys the value the row has there, replaces a key on the same frame, and undoes each step', () => { + const f = makeSpriteScene(); + const walk = addAnimation(f, f.spriteId); + const id = addRow(f, walk, 'x', [ + [0, 0], + [10, 100], + ]); + f.stack.execute(new SetAnimationKeyCommand(walk.id, id, 4)); + expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [4, 40], [10, 100])); + f.stack.execute(new SetAnimationKeyCommand(walk.id, id, 4, 7)); + expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [4, 7], [10, 100])); 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); + expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [10, 100])); }); - it('moves a frame to where it is dropped and back on undo', () => { + it('starts an empty row at the property default, a relative one at no offset', () => { 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]]); + const walk = addAnimation(f, f.spriteId); + const scale = addRow(f, walk, 'scale', []); + f.stack.execute(new SetAnimationKeyCommand(walk.id, scale, 3)); + const x = addRow(f, walk, 'x', []); + f.stack.execute(new SetTrackRelativeCommand(walk.id, x, true)); + f.stack.execute(new SetAnimationKeyCommand(walk.id, x, 0)); + expect(rowOf(walk, scale).keys).toEqual(keys([3, 1])); + expect(rowOf(walk, x).keys).toEqual(keys([0, 0])); + }); + + it('moves keys together; one landing on a key left behind replaces it, and undo brings both back', () => { + const f = makeSpriteScene(); + const walk = addAnimation(f, f.spriteId); + const id = addRow(f, walk, 'x', [ + [0, 0], + [2, 20], + [5, 50], + ]); + f.stack.execute( + new MoveAnimationKeysCommand( + walk.id, + [ + { trackId: id, frame: 0 }, + { trackId: id, frame: 2 }, + ], + 3, + ), + ); + expect(rowOf(walk, id).keys).toEqual(keys([3, 0], [5, 20])); + expect(() => + f.stack.execute(new MoveAnimationKeysCommand(walk.id, [{ trackId: id, frame: 3 }], -4)), + ).toThrow(CommandError); f.stack.undo(); - expect(walk.frames).toEqual(ids); + expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [2, 20], [5, 50])); + f.stack.execute(new RemoveAnimationKeysCommand(walk.id, [{ trackId: id, frame: 2 }])); + expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [5, 50])); }); - it('refuses an animation as a frame, and indices that are not there', () => { + it('aims a row elsewhere keeping its keys, only at a property of the same kind', () => { 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]); + const walk = addAnimation(f, f.spriteId); + const id = addRow(f, walk, 'x', [[0, 5]]); + f.stack.execute(new RetargetAnimationTrackCommand(walk.id, id, '', 'rotation')); + expect(rowOf(walk, id)).toMatchObject({ property: 'rotation', keys: keys([0, 5]) }); + expect(() => f.stack.execute(new RetargetAnimationTrackCommand(walk.id, id, '', 'color'))).toThrow( + CommandError, + ); + expect(() => + f.stack.execute(new SetTrackRelativeCommand(walk.id, addRow(f, walk, 'color', []), true)), + ).toThrow(CommandError); }); }); -describe('saving and deleting', () => { - it('keeps the frames through a round trip, dangling ones included, and coerces its settings', () => { +describe('saving', () => { + it('keeps root, rows and keys through a round trip, and cleans what a hand-edited file brings', () => { 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 walk = addAnimation(f, f.spriteId, { fps: 12, playback: 'once-reset' }); + const id = addRow(f, walk, 'x', [ + [0, 0], + [9, 90], + ]); + f.stack.execute(new SetTrackRelativeCommand(walk.id, id, true)); + expect(findAssetOf(roundTrip(f.project), walk.id, 'animation')).toEqual(walk); 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' }; + stored.tracks = [ + { + id: 'r1', + target: 'gone', + property: 'x', + keys: [ + { frame: 4.4, value: 1 }, + { frame: 1, value: 2 }, + { frame: 'x', value: 3 }, + { frame: 2, value: [1] }, + ], + }, + { id: 'r2', target: 'gone', property: 'x', keys: [] }, + { id: 'r1', target: '', property: 'y', keys: [] }, + { target: '', property: 'rotation', keys: [] }, + ]; + stored.props = { fps: 10, 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' }); + expect(coerced.tracks).toEqual([{ id: 'r1', target: 'gone', property: 'x', keys: keys([1, 2], [4, 1]) }]); + expect(coerced.props.playback).toBe('loop'); }); - it('counts the animations playing a texture, and the sprites wearing an animation', () => { + it('counts the animations keying a texture', () => { 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); + const texture = new CreateTextureCommand('Hero', { file: `${'ab'.repeat(32)}.png`, width: 8, height: 8 }); + f.stack.execute(texture); + const walk = addAnimation(f, f.spriteId); + addRow(f, walk, 'texture', [[0, texture.asset.id]]); + addAnimation(f, f.spriteId, {}, 'Other'); + expect(animationsUsing(f.project, [texture.asset.id])).toEqual([walk]); }); }); -/** A placed sprite wearing `walk`, in a world with the AnimationSystem. */ -function playing(frames = 4, props = {}) { +/** A placed sprite and an animation over it moving `x` 0 → 90 on frames 0…9 at 10 fps. */ +function slide(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 }; + const walk = addAnimation(f, f.spriteId, { fps: 10, ...props }); + const row = addRow(f, walk, 'x', [ + [0, 0], + [9, 90], + ]); + const started = startWorld(f, { animations: true }); + const sprite = started.world.findByObjectId(box)!; + return { f, walk, row, box, sprite, ...started }; } 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('shows frame 0 on the tick after the start, then blends every tick', () => { + const { walk, sprite, frame } = slide(); + playAnimation(sprite, walk.id); + frame(0.05); + expect(sprite.x).toBe(0); + frame(0.05); + expect(sprite.x).toBeCloseTo(5); + frame(0.5); + expect(sprite.x).toBeCloseTo(55); }); - 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' }); + it('holds the last value when a once animation ends, and reports it exactly once', () => { + const { walk, box, f } = slide({ playback: 'once' }); const world = World.instantiate(f.project, f.sceneId); const system = new AnimationSystem(); world.addSystem(system); + const sprite = world.findByObjectId(box)!; + playAnimation(sprite, walk.id); const reports: number[] = []; - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 14; i++) { world.tick(0.1); reports.push(system.finished.length); } - expect(reports).toEqual([0, 0, 1, 0, 0, 0]); + expect(reports.filter((n) => n > 0)).toEqual([1]); + expect(sprite.x).toBe(90); + expect(sprite.animations).toEqual([]); }); - 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 }); + it('puts the starting values back when a once-reset animation ends', () => { + const { walk, sprite, frame } = slide({ playback: 'once-reset' }); + sprite.x = 7; + playAnimation(sprite, walk.id); frame(0.1); frame(0.5); - expect(world.entities.find((e) => e.type === 'sprite')!.animation?.time).toBe(0); + expect(sprite.x).toBeCloseTo(50); + for (let i = 0; i < 10; i++) frame(0.1); + expect(sprite.x).toBe(7); + }); + + it('adds a relative row to where the object stood', () => { + const { f, walk, row, sprite, frame } = slide(); + f.stack.execute(new SetTrackRelativeCommand(walk.id, row, true)); + sprite.x = 100; + playAnimation(sprite, walk.id); + frame(0.1); + frame(0.3); + expect(sprite.x).toBeCloseTo(130); + }); + + it('pauses where it stands, runs on when played again, and stop leaves the value', () => { + const { walk, sprite, frame } = slide(); + playAnimation(sprite, walk.id); + frame(0.1); + frame(0.2); + pauseAnimation(sprite, walk.id); + frame(0.5); + expect(sprite.x).toBeCloseTo(20); + playAnimation(sprite, walk.id); + frame(0.1); + expect(sprite.x).toBeCloseTo(30); + stopAnimation(sprite, ''); + frame(0.5); + expect(sprite.x).toBeCloseTo(30); + }); + + it('lets the later start win where two animations set one property', () => { + const { f, walk, sprite, frame } = slide(); + const hold = addAnimation(f, f.spriteId, {}, 'Hold'); + addRow(f, hold, 'x', [[0, -1]]); + playAnimation(sprite, hold.id); + playAnimation(sprite, walk.id); + frame(0.1); + frame(0.2); + expect(sprite.x).toBeCloseTo(20); + }); + + it('stands still while the object is switched off', () => { + const { f, walk, box } = slide(); + f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'enabled', false)); + const { world, frame } = startWorld(f, { animations: true }); + const sprite = world.findByObjectId(box)!; + playAnimation(sprite, walk.id); + frame(0.1); + frame(0.5); + expect(sprite.animations?.[0]?.time).toBe(0); + }); + + it('plays a container animation on each instance on its own, and skips a row whose object is gone', () => { + const f = makeSpriteScene(); + const inner = place(f, f.spriteId, { x: 0, y: 0 }); + const wrap = new CreateContainerCommand(f.sceneId, [inner]); + f.stack.execute(wrap); + const template = (findAsset(f.project, wrap.assetId) as ContainerAsset).objects[0]!; + const second = place(f, wrap.assetId, { x: 500, y: 0 }); + const spin = addAnimation(f, wrap.assetId, { fps: 10 }); + addRow( + f, + spin, + 'rotation', + [ + [0, 0], + [10, 100], + ], + template.id, + ); + spin.tracks.push({ id: 'dangling', target: 'gone', property: 'rotation', keys: keys([0, 5]) }); + const { world, frame } = startWorld(f, { animations: true }); + const first = world.findByObjectId(wrap.objectId)!; + playAnimation(first, spin.id); + frame(0.1); + frame(0.5); + const rotation = (instance: string) => + world.findByObjectId(instanceChildId(instance, template.id))!.props.rotation; + expect(rotation(wrap.objectId)).toBeCloseTo(50); + expect(rotation(second)).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'], +describe('the animation nodes', () => { + /** A behavior below the sprite plays `walk` on its owner at start and counts `On animation finished`. */ + function listening(filter: 'walk' | 'other') { + const f = makeSpriteScene(); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + const walk = addAnimation(f, f.spriteId, { fps: 10, playback: 'once' }); + addRow(f, walk, 'x', [ + [0, 0], + [2, 20], ]); - 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; + const other = addAnimation(f, f.spriteId, {}, 'Other'); + const behavior = makeBehavior(f, 'Logic', ['done']); place(f, behavior.id, { x: 0, y: 0 }, box); const edit = graphEditor(f, behavior); - const update = edit.addNode('OnUpdate'); + const start = edit.addNode('OnStart'); const owner = edit.addNode('Owner'); - const wear = edit.properties([{ key: 'texture', kind: 'text', write: true }], { - [OBJECT_TYPE_PARAM]: 'sprite', - texture: walk.id, + const play = edit.addNode('PlayAnimation', { animation: walk.id }); + const finished = edit.addNode('OnAnimationFinished', { + animation: filter === 'walk' ? walk.id : other.id, }); + const store = edit.variables(['done'], { write: true }); + const add = edit.addNode('Add', { b: 1 }); edit.wire([ - [update, 'exec', wear, 'exec'], - [owner, 'object', wear, 'object'], + [start, 'exec', play, 'exec'], + [owner, 'object', play, 'object'], + [finished, 'exec', store, 'exec'], + [store, 'done', add, 'a'], + [add, 'result', store, 'done'], ]); 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); + for (let i = 0; i < 8; i++) frame(0.1); + const logic = world.entities.find((e) => e.type === 'behavior')!; + return { done: logic.props.done, x: world.findByObjectId(box)!.x }; + } + + it('plays on the object wired in, and On animation finished hears it once', () => { + expect(listening('walk')).toEqual({ done: 1, x: 20 }); + }); + + it('is not heard by an On animation finished naming another animation', () => { + expect(listening('other').done).toBe(0); }); }); diff --git a/tests/core/list-item-kind.test.ts b/tests/core/list-item-kind.test.ts index 981f5b4c..e122490c 100644 --- a/tests/core/list-item-kind.test.ts +++ b/tests/core/list-item-kind.test.ts @@ -50,13 +50,13 @@ describe('the kind of one list item', () => { /** * The second kind JSON cannot tell apart on its own: an asset id is a text like any other. What a - * sprite wears is one id whether it names a texture, one of its frames or an animation. + * sprite wears is one id whether it names a texture or one of its frames. */ it('keeps a texture apart from the text beside it, and reads back as its id', () => { - const { f, behaviorId } = withList([tagged('texture', 'anim-walk'), 'anim-walk']); + const { f, behaviorId } = withList([tagged('texture', 'tex-walk'), 'tex-walk']); const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[]; expect(back.map(itemKindOf)).toEqual(['texture', 'text']); - expect(back.map(untag)).toEqual(['anim-walk', 'anim-walk']); + expect(back.map(untag)).toEqual(['tex-walk', 'tex-walk']); }); it('turns a text into a texture item and back without losing the id', () => { diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index e58127e2..6380abda 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -82,6 +82,8 @@ const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y }); /** Its own entity, because Emit's whole effect is the count it leaves on the one it is handed. */ const emitTarget = fakeEntity('Sparks'); +/** Their own entity each: the animation nodes leave players on the object they are handed. */ +const dancer = fakeEntity('Dancer'); const painted = fakeHandle({ width: 100, text: '' }); const pointer = (overrides: Partial): Partial => ({ @@ -854,6 +856,41 @@ const CASES: Record = { after: () => expect(emitTarget.emits).toBe(1), }, ], + PlayAnimation: [ + { + name: 'no object is a no-op that still continues the flow', + inputs: { object: null }, + params: { animation: 'anim-1' }, + next: 'exec', + }, + { + name: 'starts a player on the object, and a second play leaves it running', + inputs: { object: dancer }, + params: { animation: 'anim-1' }, + next: 'exec', + after: () => { + expect(dancer.animations).toMatchObject([{ animationId: 'anim-1', time: 0, playing: true }]); + }, + }, + ], + PauseAnimation: [ + { + name: 'holds the player where it stands', + inputs: { object: dancer }, + params: { animation: 'anim-1' }, + next: 'exec', + after: () => expect(dancer.animations?.map((p) => p.playing)).toEqual([false]), + }, + ], + StopAnimation: [ + { + name: 'with no animation named ends every player on the object', + inputs: { object: dancer }, + params: { animation: '' }, + next: 'exec', + after: () => expect(dancer.animations).toEqual([]), + }, + ], PlaySound: [ { name: 'no sound is a no-op that still continues the flow', diff --git a/tests/editor/animation-strip.test.ts b/tests/editor/animation-strip.test.ts deleted file mode 100644 index 60113382..00000000 --- a/tests/editor/animation-strip.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -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/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 672f5202..0f0b8fcb 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -161,7 +161,7 @@ export async function assets(t) { await mutate(`__gm.state.project.assets.push( { id: 'sm-open', type: 'statemachine', name: 'Machine', props: {}, graph: { nodes: [], edges: [] } }, { id: 'tex-open', type: 'texture', name: 'Sheet', props: {}, file: '', width: 0, height: 0, frames: [] }, - { id: 'anim-open', type: 'animation', name: 'Walk', props: {}, frames: [] }, + { id: 'anim-open', type: 'animation', name: 'Walk', props: {}, root: '', tracks: [] }, { id: 'box-open', type: 'container', name: 'Box', props: {}, objects: [] }, )`); const openable = [ @@ -169,7 +169,7 @@ export async function assets(t) { ['sm-open', 'statemachine', '__gm.stateMachine.current()'], ['adjust-colors', 'shader', '__gm.shader.current()'], ['tex-open', 'texture', '__gm.texture.current()'], - ['anim-open', 'animation', '__gm.animation.current().assetId'], + ['anim-open', 'animation', '__gm.animation.current()'], ['box-open', 'container', '__gm.container.current()'], ]; for (const [id, tab, shown] of openable) { diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index dd7113c6..e516a6cf 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -1,518 +1,42 @@ -import { colourDistance } from '../lib/harness.mjs'; -import { edge, node, propertiesOf, variable, written } from '../lib/builders.mjs'; -import { MAKE_OBJECT, openTab } from './lib/scaffold.mjs'; +import { openTab } 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. + * Animations (#97): the timeline replaced the picture strip. Until the timeline editor lands this + * step only holds the tab to the new model – an animation over a sprite opens, and the readout + * counts its rows and frames. 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', 'texture'), - ]), - ], - 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, showTab } = - 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'; - }; + const { check, json, mutate, shot } = t; - await press('Escape'); - await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); - await evaluate('__gm.state.select(null)'); - - // 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); + __gm.state.project.assets.push( + { id: 'an-sprite', type: 'sprite', name: 'Dancer', props: { width: 40, height: 40 } }, + { + id: 'an-walk', type: 'animation', name: 'Walk', props: { fps: 10, playback: 'loop' }, root: 'an-sprite', + tracks: [{ id: 'an-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 9, value: 90 }] }], + }, + ); + __gm.state.select({ kind: 'asset', assetId: 'an-walk' }); + __gm.state.preview(); `); - - // 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 clickText('Create animation from texture'); - 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(); - const undone = await json("__gm.state.project.assets.filter((a) => a.type === 'animation').length"); - await redo(); - 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 openTab(t, 'animation'); + await shot('01-tab'); + check('the animation tab shows the animation', (await json('__gm.animation.current()')) === 'an-walk'); + const info = await json('__gm.animation.info()'); check( - 'the inspector edits frames per second and playback', - (await t.has('Frames per second')) && (await t.has('Playback')) && (await t.has('Loop')), + 'its readout counts one row over ten frames, one second at 10 fps', + info === '1 row · 10 frames · 10 fps · 1.0 s', + info, ); - 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'); - - // 25c-ii. (#90) The preview runs on the global clock like everything else that moves by itself, - // so a scale of 0 holds the frame on screen while the preview is still *playing*. - await evaluate('__gm.setTimeScale(0)'); - await sleep(300); - const atZero = await info(); - await sleep(500); - const stillZero = await info(); - await evaluate('__gm.setTimeScale(1)'); - const moved = new Set(); - for (let i = 0; i < 8; i++) { - moved.add((await info()).shown); - await sleep(70); - } - check( - 'a global time scale of 0 holds the frame without stopping the play, and 1 runs it on', - atZero.playing === true && - stillZero.playing === true && - stillZero.shown === atZero.shown && - moved.size >= 3, - JSON.stringify({ atZero: atZero.shown, stillZero: stillZero.shown, afterwards: [...moved] }), - ); - - // 25d. Pausing holds the frame on screen, and the preview shows that frame's picture. - await click(...Object.values(await json('__gm.animation.playButton()'))); - 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); - 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' }); - 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'); - 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); - 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(); - 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 press('Delete'); - 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(); - - // 25h. + Frames offers a sheet's frames at once and inserts them after the selection. - await clickText('+ Frames'); - // Search first: in the full suite a 17-frame sheet sorts ahead of this one and pushes the later - // cells out of the list's view, and a row outside its list is not on the screen to be read. - // The filter also sharpens the check, because the animation is called "strip 2" and would match. - await typeText('strip'); - 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.includes('strip / Cell 4') && - !offered.some((s) => s.startsWith('strip 2')), - JSON.stringify(offered), - ); - await clickText('strip – all 4 frames'); - 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(); - - // 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); - 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)'); - 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 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'); - - // 25j'. A frame is a texture of its own. Drawn from the sheet, an edge that landed inside a screen - // pixel showed the neighbouring cell: the partly covered pixel is evaluated at its centre, - // past the edge. Measured in play, where the background is flat. - await mutate(`__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture = 'an-f1'`); - const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); - const fraction = (v) => v - Math.floor(v); - const edgesOf = async () => { - const r = await json("__gm.gameRect('an-box')"); - return { y: Math.round(r.y), left: r.x - r.w / 2, right: r.x + r.w / 2, scale: r.w / 80 }; - }; - // Moves the box so the edge's fraction lands where the pixel is partly covered but its centre - // lies outside; `outward` says which way the neighbouring cell would be. - const edgePixels = async (side, target, outward) => { - await t.play(); - const first = await edgesOf(); - await stop(); - const shift = (target - fraction(first[side]) + 1) % 1; - await mutate( - `__gm.state.activeScene.objects.find((o) => o.id === 'an-box').position.x += ${shift / first.scale}`, - ); - await t.play(); - const e = await edgesOf(); - const x = Math.floor(e[side]); - const edge = rgb(await json(`__gm.pixelAt(${x}, ${e.y})`)); - const outside = rgb(await json(`__gm.pixelAt(${x + outward * 2}, ${e.y})`)); - await stop(); - return { fraction: fraction(e[side]).toFixed(2), edge, outside }; - }; - // The edge pixel is the cell's green over the background and nothing else: red and blue rise - // above the background only in the cell's own proportion to the rise in green (plus rounding), - // and green rises enough to prove it is the partly covered pixel, not plain background. - const cell = rgb(CELLS[1]); - const onlyOwnColour = ({ edge, outside }) => { - const green = edge[1] - outside[1]; - return ( - green > 10 && - [0, 2].every( - (c) => edge[c] - outside[c] <= (green * (cell[c] - outside[c])) / (cell[1] - outside[1]) + 3, - ) - ); - }; - const leftEdge = await edgePixels('left', 0.75, -1); - check( - 'the left edge of a frame shows nothing of the cell to its left', - onlyOwnColour(leftEdge), - JSON.stringify(leftEdge), - ); - const rightEdge = await edgePixels('right', 0.25, 1); - check( - 'nor the right edge anything of the cell to its right', - onlyOwnColour(rightEdge), - JSON.stringify(rightEdge), - ); - await mutate( - `__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture = ${JSON.stringify(walk.id)}`, - ); - - // 25k. Play runs it: the pixels the game draws change colour over time. - await t.play(); - 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(); - 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 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(); - 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 }), - ); - - // 25l'. A picture is a kind of its own: the Texture node hands one into the graph, and a - // variable may be one. Both are picked, never typed. - const pickPicture = async (label, name) => { - const field = await json(`__gm.inspectorField(${JSON.stringify(label)}, true)`); - await click(field.x, field.y); - await typeText(name); - await press('Enter'); - }; - await mutate(` - const logic = __gm.state.project.assets.find((a) => a.id === 'an-logic'); - logic.graph.nodes.push(${JSON.stringify(node('an-pic', 'Texture', 600, 300))}); - logic.variables.push(${JSON.stringify(variable('pic', 'texture', ''))}); - logic.props.pic = ''; - __gm.state.select({ kind: 'node', assetId: 'an-logic', nodeId: 'an-pic', nodeIds: ['an-pic'] }); - `); - await pickPicture('Value', walk.name); - const picNode = (await t.asset('an-logic')).graph.nodes.find((n) => n.id === 'an-pic'); - const picRows = (await json("__gm.graph.nodeSettings('an-pic')")) ?? []; - check( - 'the Texture node picks its picture and shows it by name', - picNode?.params.value === walk.id && picRows.some((r) => r.includes(walk.name)), - JSON.stringify({ params: picNode?.params, rows: picRows }), - ); - await evaluate("__gm.state.select({ kind: 'asset', assetId: 'an-logic' })"); - await pickPicture('pic', walk.name); - check( - 'a texture variable is picked with the same picker', - (await t.asset('an-logic')).props.pic === walk.id, - JSON.stringify((await t.asset('an-logic')).props), - ); - await shot('04b-texture-kind'); - - // 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 clickText('Delete…'); - 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 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'); + p.assets = p.assets.filter((a) => a.id !== 'an-walk' && a.id !== 'an-sprite'); __gm.state.select(null); __gm.state.preview(); `); const left = await json( - "__gm.state.project.assets.filter((a) => a.type === 'animation' || a.id === 'an-sprite' || a.id === 'an-logic').length", + "__gm.state.project.assets.filter((a) => a.id === 'an-walk' || a.id === 'an-sprite').length", ); check('the step leaves the fixture as it found it', left === 0, String(left)); }