An animation is a value a graph can pick, and playing a running one makes it win again (#97)

Play, Pause and Stop animation take the animation on a port instead of a
fixed setting, fed by the new Animation literal or a list item tagged
`$animation`. Playing an animation already running keeps its frame but
counts it as started last, so turning back to a walk that never stopped
shows that walk. The actor demo walks through one Play animation over
`ItemAt(walk, facing)` again and stops every animation to stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-16 20:47:31 +02:00
1 parent 5af0a1c83a
commit 4ed2edd0f0
10 files changed
+158 -71

No files matched your search

+24 -22
View File
@@ -1,16 +1,17 @@
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 type { NodeDef, PortDef } from '../../NodeRegistry';
import { asEntity, asString } from '../../values';
import { exec, obj } from '../shared';
const animationParam = (): PropertyDef => ({
/**
* The `animation` input of the three animation nodes; unconnected it shows the animation picker, the
* way `soundPort` shows the sound picker. An `Animation` literal or a list item is what feeds it.
*/
const animationPort = (): PortDef => ({
key: 'animation',
label: 'Animation',
kind: 'asset',
default: '',
assetTypes: ['animation'],
label: 'animation',
type: 'string',
field: { key: 'animation', label: 'Animation', kind: 'animation', default: '' },
});
export const PlayAnimation: NodeDef = {
@@ -19,13 +20,14 @@ export const PlayAnimation: NodeDef = {
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')],
'instance plays on its own. A paused one runs on from where it stands; one already running keeps its ' +
'frame but wins again over the others on the object.',
inputs: [exec(), obj('object'), animationPort()],
outputs: [exec()],
params: [animationParam()],
run: (_ctx, inputs, params) => {
params: [],
run: (_ctx, inputs) => {
const target = asEntity(inputs.object);
const animation = textOf(params.animation ?? '');
const animation = asString(inputs.animation);
if (target && animation) playAnimation(target, animation);
return { next: 'exec' };
},
@@ -37,12 +39,12 @@ export const StopAnimation: NodeDef = {
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')],
inputs: [exec(), obj('object'), animationPort()],
outputs: [exec()],
params: [animationParam()],
run: (_ctx, inputs, params) => {
params: [],
run: (_ctx, inputs) => {
const target = asEntity(inputs.object);
if (target) stopAnimation(target, textOf(params.animation ?? ''));
if (target) stopAnimation(target, asString(inputs.animation));
return { next: 'exec' };
},
};
@@ -53,12 +55,12 @@ export const PauseAnimation: NodeDef = {
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')],
inputs: [exec(), obj('object'), animationPort()],
outputs: [exec()],
params: [animationParam()],
run: (_ctx, inputs, params) => {
params: [],
run: (_ctx, inputs) => {
const target = asEntity(inputs.object);
if (target) pauseAnimation(target, textOf(params.animation ?? ''));
if (target) pauseAnimation(target, asString(inputs.animation));
return { next: 'exec' };
},
};
+5
View File
@@ -45,6 +45,11 @@ export const SceneLiteral = literal(
'scene',
'A constant scene, for a "Load scene" to work on.',
);
export const AnimationLiteral = literal(
'Animation',
'animation',
'A constant animation, for a "Play animation", "Pause animation" or "Stop animation" to work on.',
);
export const ListLiteral = literal(
'List',
'list',
+2
View File
@@ -33,6 +33,7 @@ import { Join } from './data/Join';
import { KeyIsDown } from './data/KeyIsDown';
import { Length } from './data/Length';
import {
AnimationLiteral,
BooleanLiteral,
ListLiteral,
NumberLiteral,
@@ -156,6 +157,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
StateMachineLiteral,
SoundLiteral,
SceneLiteral,
AnimationLiteral,
Add,
Subtract,
Multiply,
+4 -2
View File
@@ -219,14 +219,16 @@ interface PlayerHolder {
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.
* Starts the animation on the object, or lets a paused one run on. One already running keeps its
* frame, so a graph may ask for it every frame without holding it at its first frame – but it counts
* as started last again: turning back to a walk that still runs has to show that walk.
*/
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;
known.order = ++startCount;
return;
}
players.push({ animationId, time: 0, playing: true, order: ++startCount });
+14
View File
@@ -134,6 +134,20 @@ export const KINDS = [
field: true,
port: true,
},
/** An animation asset's id – a text, so it is tagged in a list like a texture. */
{
kind: 'animation',
label: 'Animation',
mark: '▶',
travels: 'string',
empty: '',
coerce: text,
tagged: true,
reference: true,
variable: true,
field: true,
port: true,
},
{ kind: 'object', label: 'Object', mark: '◇', travels: 'object', port: true },
{ kind: 'list', label: 'List', mark: '≡', travels: 'list', variable: true, port: true },
{ kind: 'struct', label: 'Struct', mark: '⊞', travels: 'struct', variable: true, port: true },
+6
View File
@@ -247,6 +247,11 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
label: assetRefLabel,
choices: (project) => assetRefChoices(project, ['scene']).map((a) => ({ id: a.id, label: a.name })),
},
animation: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project) => assetRefChoices(project, ['animation']).map((a) => ({ id: a.id, label: a.name })),
},
};
/** An id in the words of its reference kind; nothing for a kind that names nothing. */
@@ -374,6 +379,7 @@ export function createField(
case 'statemachine':
case 'sound':
case 'scene':
case 'animation':
return referenceField(ctx, def, REFERENCES[def.kind], value, handlers);
}
}
+15
View File
@@ -357,6 +357,21 @@ describe('the AnimationSystem', () => {
expect(sprite.x).toBeCloseTo(20);
});
it('lets a running animation win again when it is played again, without winding it back', () => {
const { f, walk, sprite, frame } = slide();
const hold = addAnimation(f, f.spriteId, {}, 'Hold');
addRow(f, hold, 'x', [[0, -1]]);
playAnimation(sprite, walk.id);
frame(0.1);
playAnimation(sprite, hold.id);
frame(0.1);
expect(sprite.x).toBeCloseTo(-1);
// Wound back it would read 0, still beaten -1: 20 is the walk at its third tick.
playAnimation(sprite, walk.id);
frame(0.1);
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));
+8 -8
View File
@@ -477,6 +477,10 @@ const CASES: Record<string, Case[]> = {
{ name: 'outputs the id it was set to', params: { value: 'sc-1' }, outputs: { value: 'sc-1' } },
{ name: 'unset is no scene', outputs: { value: '' } },
],
Animation: [
{ name: 'outputs the id it was set to', params: { value: 'anim-1' }, outputs: { value: 'anim-1' } },
{ name: 'unset is no animation', outputs: { value: '' } },
],
Add: [
{ name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } },
{ name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } },
@@ -859,14 +863,12 @@ const CASES: Record<string, Case[]> = {
PlayAnimation: [
{
name: 'no object is a no-op that still continues the flow',
inputs: { object: null },
params: { animation: 'anim-1' },
inputs: { object: null, 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' },
inputs: { object: dancer, animation: 'anim-1' },
next: 'exec',
after: () => {
expect(dancer.animations).toMatchObject([{ animationId: 'anim-1', time: 0, playing: true }]);
@@ -876,8 +878,7 @@ const CASES: Record<string, Case[]> = {
PauseAnimation: [
{
name: 'holds the player where it stands',
inputs: { object: dancer },
params: { animation: 'anim-1' },
inputs: { object: dancer, animation: 'anim-1' },
next: 'exec',
after: () => expect(dancer.animations?.map((p) => p.playing)).toEqual([false]),
},
@@ -885,8 +886,7 @@ const CASES: Record<string, Case[]> = {
StopAnimation: [
{
name: 'with no animation named ends every player on the object',
inputs: { object: dancer },
params: { animation: '' },
inputs: { object: dancer, animation: '' },
next: 'exec',
after: () => expect(dancer.animations).toEqual([]),
},
+31 -19
View File
@@ -7,13 +7,13 @@
* (down, left, right, up). This demo uses the character in the top left corner, so its frames are
* the columns 1-3 of the rows 1-4.
*
* Walking is one animation per direction, playing those three frames as `pingpong`
* (left-stand-right-stand …) – the original walk cycle. Standing is not an animation at all but the
* middle frame of the row.
* Walking is one animation per direction over the sprite: a `texture` row keying those three frames,
* played `pingpong` (left-stand-right-stand …) – the original walk cycle. Standing is not an
* animation at all but the middle frame of the row.
*
* The graph picks a picture the way the editor does: two list variables hold the four walk
* animations and the four standing frames, each item chosen with the texture picker, and `facing`
* is the row index that indexes both. Nothing is built out of an id spelled by hand.
* The graph picks the way the editor does: two list variables hold the four walk animations and the
* four standing frames, each item chosen with its picker, and `facing` is the row index that indexes
* both. Nothing is built out of an id spelled by hand.
*
* Usage: node tools/seed-actor.mjs [projectId] [baseUrl]
*/
@@ -68,11 +68,19 @@ const walkAnimations = DIRECTIONS.map((dir, row) => ({
type: 'animation',
name: `Walk ${dir}`,
props: { fps: 8, playback: 'pingpong' },
frames: [frameId(row, 0), frameId(row, 1), frameId(row, 2)],
root: 'actor-sprite',
tracks: [
{
id: `${WALK(dir)}-texture`,
target: '',
property: 'texture',
keys: [0, 1, 2].map((column) => ({ frame: column, value: frameId(row, column) })),
},
],
}));
/** The two lists the graph indexes: one walk animation and one standing frame per direction. */
const WALK_PICTURES = DIRECTIONS.map((dir) => tagged('texture', WALK(dir)));
const WALK_ANIMATIONS = DIRECTIONS.map((dir) => tagged('animation', WALK(dir)));
const IDLE_PICTURES = DIRECTIONS.map((_, row) => tagged('texture', idleFrame(row)));
/**
@@ -129,9 +137,10 @@ const nodes = [
face('c-up', 'up', 1920, 940),
face('c-down', 'down', 1920, 1080),
// What the sprite shows. Both picture lists are indexed by the row the character faces, so there
// is one node per list and no branch per direction.
// Setting the same animation again lets it keep running, so writing it every frame is free.
// What the sprite shows. Both lists are indexed by the row the character faces, so there is one
// node per list and no branch per direction. Playing a running walk again keeps its frame but makes
// it the one that wins, so turning back to a walk still running shows that walk. Standing stops
// every animation and wears the standing frame.
node('br-move', 'Branch', 1460, 1280),
node('v-pics', 'Variables', 1400, 1480, {}, [
port('walk', 'list'),
@@ -140,8 +149,9 @@ const nodes = [
]),
node('pick-walk', 'ItemAt', 1680, 1430),
node('pick-idle', 'ItemAt', 1680, 1640),
node('wear-walk', 'Properties', 1920, 1280, propertiesOf('sprite'), [written('texture', 'texture')]),
node('wear-idle', 'Properties', 1920, 1510, propertiesOf('sprite'), [written('texture', 'texture')]),
node('play-walk', 'PlayAnimation', 1920, 1280),
node('stop-walk', 'StopAnimation', 1920, 1510),
node('wear-idle', 'Properties', 2160, 1510, propertiesOf('sprite'), [written('texture', 'texture')]),
];
const edges = [
@@ -198,12 +208,14 @@ const edges = [
edge('e48', 'v-pics', 'facing', 'pick-walk', 'index'),
edge('e49', 'v-pics', 'idle', 'pick-idle', 'list'),
edge('e50', 'v-pics', 'facing', 'pick-idle', 'index'),
edge('e51', 'br-move', 'then', 'wear-walk', 'exec'),
edge('e52', 'owner', 'object', 'wear-walk', 'object'),
edge('e53', 'pick-walk', 'item', 'wear-walk', 'texture'),
edge('e54', 'br-move', 'else', 'wear-idle', 'exec'),
edge('e51', 'br-move', 'then', 'play-walk', 'exec'),
edge('e52', 'owner', 'object', 'play-walk', 'object'),
edge('e53', 'pick-walk', 'item', 'play-walk', 'animation'),
edge('e54', 'br-move', 'else', 'stop-walk', 'exec'),
edge('e55', 'owner', 'object', 'wear-idle', 'object'),
edge('e56', 'pick-idle', 'item', 'wear-idle', 'texture'),
edge('e57', 'owner', 'object', 'stop-walk', 'object'),
edge('e58', 'stop-walk', 'exec', 'wear-idle', 'exec'),
];
const sheet = await uploadTexture(baseUrl, SHEET_FILE);
@@ -251,11 +263,11 @@ const project = {
id: 'player',
type: 'behavior',
name: 'Player',
props: { speed: SPEED, facing: 0, walk: WALK_PICTURES, idle: IDLE_PICTURES },
props: { speed: SPEED, facing: 0, walk: WALK_ANIMATIONS, idle: IDLE_PICTURES },
variables: [
variable('speed', 'number', SPEED),
variable('facing', 'number', 0),
variable('walk', 'list', WALK_PICTURES),
variable('walk', 'list', WALK_ANIMATIONS),
variable('idle', 'list', IDLE_PICTURES),
],
graph: { nodes, edges },
+49 -20
View File
@@ -2,7 +2,7 @@
/**
* Plays the walk demo (tools/seed-actor.mjs) in a headless browser: standing, walking in four
* directions, the diagonal, the turn when a key is let go, and the edge of the camera. It is the
* proof that animations, `Axis input` and a sliced sheet work together.
* proof that `Play animation`, `Axis input` and a sliced sheet work together.
*
* Usage: node tools/seed-actor.mjs && node tools/smoke-actor.mjs
* Screenshots land in /tmp/gm-actor-*.png.
@@ -13,8 +13,8 @@ import { PROJECTS, cliArgs } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('smoke-actor.mjs', PROJECTS.actor);
/** What the seeder writes. A mismatch means the loader dropped a node type or an edge. */
const NODES = 44;
const EDGES = 56;
const NODES = 45;
const EDGES = 58;
/**
* The standing frame of each direction: the middle column of its row in the sheet. The graph reaches
@@ -28,6 +28,15 @@ const CELL = 48;
const EDGE_X = 296;
const EDGE_Y = 156;
/** The walk that decides the picture: of the animations running on him, the one started last. */
const walkOf = (e) =>
(e.animations ?? [])
.filter((p) => p.playing)
.reduce((best, p) => (best && best.order > p.order ? best : p), null)?.animationId ?? '';
/** The row of the sheet a frame id lies in – what a picture of a direction looks like. */
const rowOfFrame = (texture) => Number(String(texture).split('-')[1]);
await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, async (t) => {
const { check, sleep, json, shot, entity, play, stop, navigate, exceptions, holdKey, keyDown, keyUp } = t;
@@ -58,14 +67,22 @@ await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, asyn
JSON.stringify(sheet.last),
);
// 3. Four walk animations, three frames each, ping-pong.
// 3. Four walk animations over the sprite, each one texture row of three keys, ping-pong.
const anims = await json(
`__gm.state.project.assets.filter((a) => a.type === 'animation').map((a) => ({ id: a.id, n: a.frames.length, fps: a.props.fps, pb: a.props.playback, mid: a.frames[1] }))`,
`__gm.state.project.assets.filter((a) => a.type === 'animation').map((a) => ({ id: a.id, root: a.root, rows: a.tracks.map((t) => t.target + ':' + t.property), n: a.tracks[0]?.keys.length, fps: a.props.fps, pb: a.props.playback, mid: a.tracks[0]?.keys[1]?.value }))`,
);
check(
'four walk animations of three frames play ping-pong',
anims.length === 4 && anims.every((a) => a.n === 3 && a.pb === 'pingpong' && a.fps === 8),
JSON.stringify(anims.map((a) => `${a.id}:${a.n}`)),
'four walk animations key three frames on the sprite and play ping-pong',
anims.length === 4 &&
anims.every(
(a) =>
a.root === 'actor-sprite' &&
a.rows.join() === ':texture' &&
a.n === 3 &&
a.pb === 'pingpong' &&
a.fps === 8,
),
JSON.stringify(anims.map((a) => `${a.id}:${a.root}:${a.rows}:${a.n}`)),
);
check(
'each walk animation has the standing frame in the middle',
@@ -74,15 +91,17 @@ await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, asyn
);
// 3b. How the graph reaches a picture: two list variables of tagged texture items, indexed by the
// row the character faces. Each item is what the texture picker writes, not a pasted id.
// row the character faces. Each item is what its picker writes, not a pasted id.
const pictures = await json(
`(() => { const b = __gm.state.project.assets.find((a) => a.id === 'player'); return { walk: b.props.walk, idle: b.props.idle, facing: b.props.facing }; })()`,
);
check(
'the two picture lists hold four tagged textures each, indexed by facing',
'the walk list holds four tagged animations, the idle list four tagged textures, indexed by facing',
pictures.walk.length === 4 &&
pictures.idle.length === 4 &&
pictures.walk.every((item, row) => item.$texture === `walk-${['down', 'left', 'right', 'up'][row]}`) &&
pictures.walk.every(
(item, row) => item.$animation === `walk-${['down', 'left', 'right', 'up'][row]}`,
) &&
pictures.idle.every((item, row) => item.$texture === `fr-${row}-1`) &&
pictures.facing === 0,
JSON.stringify(pictures),
@@ -113,15 +132,23 @@ await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, asyn
const still = await entity('oplayer');
check(
'holding D walks right and plays the right walk animation',
walking.x > 30 && walking.props.texture === 'walk-right',
JSON.stringify({ x: Math.round(walking.x), texture: walking.props.texture }),
walking.x > 30 && walkOf(walking) === 'walk-right' && rowOfFrame(walking.props.texture) === 2,
JSON.stringify({ x: Math.round(walking.x), walk: walkOf(walking), texture: walking.props.texture }),
);
// Two samples after the release: he must not move between them. Comparing against the walking
// sample measured how long the screenshot took (22 to 40 px on the CI runner), not the stop.
check(
'letting go stops him on the standing frame of the way he faces',
stopped.props.texture === IDLE.right && Math.abs(still.x - stopped.x) < 1 && stopped.x > walking.x - 1,
JSON.stringify({ x: Math.round(stopped.x), later: Math.round(still.x), texture: stopped.props.texture }),
stopped.props.texture === IDLE.right &&
walkOf(stopped) === '' &&
Math.abs(still.x - stopped.x) < 1 &&
stopped.x > walking.x - 1,
JSON.stringify({
x: Math.round(stopped.x),
later: Math.round(still.x),
walk: walkOf(stopped),
texture: stopped.props.texture,
}),
);
// 6. Walking up is −y.
@@ -144,8 +171,10 @@ await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, asyn
await shot('04-diagonal');
check(
'pressing W while D is held turns him up, the key pressed last',
beforeW.props.texture === 'walk-right' && diagonal.props.texture === 'walk-up',
JSON.stringify({ beforeW: beforeW.props.texture, diagonal: diagonal.props.texture }),
walkOf(beforeW) === 'walk-right' &&
walkOf(diagonal) === 'walk-up' &&
rowOfFrame(diagonal.props.texture) === 3,
JSON.stringify({ beforeW: walkOf(beforeW), diagonal: walkOf(diagonal), texture: diagonal.props.texture }),
);
check(
'the diagonal moves both axes at once',
@@ -160,9 +189,9 @@ await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, asyn
await keyUp('d');
await sleep(200);
check(
'releasing W while D is held turns him back to the right',
afterW.props.texture === 'walk-right',
JSON.stringify({ texture: afterW.props.texture }),
'releasing W while D is held turns him back to the right, onto the walk still running',
walkOf(afterW) === 'walk-right' && rowOfFrame(afterW.props.texture) === 2,
JSON.stringify({ walk: walkOf(afterW), texture: afterW.props.texture }),
);
// 9. `Axis input` hands back a normalized vector, so a diagonal is not a faster way to travel.