An animation is a timeline of rows over one asset, played on an object rather than worn as a picture (#97)
Part (a) of the animation rework: model, commands, saving, runtime and nodes. - AnimationAsset holds a root (sprite, text, container or scene) and rows; a row keys one property of the root or of an object inside it, keys sit on whole frames. - The last key decides the length (its frame counts as a frame of its own). The playhead runs between frames, so numbers and colors blend every tick even at 10 fps; everything else holds. - Playback gains `once-reset`, which puts back what was there before the start. A row can be relative, adding to where its target stood. - Play / Pause / Stop animation act on an object; each instance plays on its own, the later start wins on a shared property, and a behavior writing the same property still writes last. - A sprite no longer wears an animation through `texture`. "Create animation from texture" builds a sprite plus a texture row keyed frame by frame instead. - The Animation tab shows a placeholder until the timeline editor (part b) lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d3563897d8
commit
193378e6a9
29 files changed
+1194
-1499
No files matched your search
@@ -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<string>[] = [];
|
||||
/** 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<string, Set<number>>();
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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' };
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+29
-4
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+214
-32
@@ -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<string>,
|
||||
): 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 `<entity id> <property>`.
|
||||
*/
|
||||
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<string, PropertyValue>;
|
||||
}
|
||||
|
||||
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<number, AnimationKey>();
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -201,7 +201,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
// 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<AssetType, PropertySchema> = {
|
||||
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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
+31
-10
@@ -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 = {};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<AnimationAsset, AnimationCanvas> {
|
||||
/** 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<AnimationAsset, TimelinePlaceholder> {
|
||||
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<AnimationAsset, AnimationCan
|
||||
return asset.type === 'animation';
|
||||
}
|
||||
|
||||
protected override refresh(): void {
|
||||
super.refresh();
|
||||
this.canvas.show(this.canvas.currentAssetId);
|
||||
}
|
||||
|
||||
protected override infoText(asset: AnimationAsset): string {
|
||||
const count = asset.frames.length;
|
||||
const rows = asset.tracks.length;
|
||||
const frames = Math.max(0, frameCount(asset));
|
||||
const seconds = formatNumber(animationDuration(asset), 1);
|
||||
const fps = Number(fpsOf(asset).toFixed(2));
|
||||
return `${count} ${count === 1 ? 'frame' : 'frames'} · ${fps} fps · ${seconds} s`;
|
||||
}
|
||||
|
||||
protected override syncExtras(asset: AnimationAsset | null): void {
|
||||
this.playButton.setText(this.canvas.playing ? '❚❚' : '▶');
|
||||
this.playButton.setEnabled(Boolean(asset && asset.frames.length > 0));
|
||||
this.addButton.setEnabled(asset !== null);
|
||||
}
|
||||
|
||||
/** Each sheet offers all its frames at once before the single pictures. */
|
||||
private openAddMenu(): void {
|
||||
const items: MenuItem[] = [];
|
||||
const sheets = new Set<TextureAsset>();
|
||||
for (const choice of pictureChoices(this.state.project)) {
|
||||
const sheet = choice.asset as TextureAsset;
|
||||
if (!sheets.has(sheet) && sheet.frames.length > 1) {
|
||||
sheets.add(sheet);
|
||||
items.push({
|
||||
label: `${sheet.name} – all ${sheet.frames.length} frames`,
|
||||
onSelect: () => this.canvas.addFrames(framesOfTexture(sheet)),
|
||||
});
|
||||
}
|
||||
items.push({ label: choice.label, onSelect: () => this.canvas.addFrames([choice.id]) });
|
||||
}
|
||||
if (items.length === 0)
|
||||
items.push({ label: 'No textures yet', disabled: true, onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.addButton);
|
||||
return `${rows} ${rows === 1 ? 'row' : 'rows'} · ${frames} ${frames === 1 ? 'frame' : 'frames'} · ${fps} fps · ${seconds} s`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+346
-255
@@ -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<string, unknown>[] };
|
||||
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<typeof startWorld>['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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<NodeContext['pointer']>): Partial<NodeContext> => ({
|
||||
@@ -854,6 +856,41 @@ const CASES: Record<string, Case[]> = {
|
||||
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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
+22
-498
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user