diff --git a/src/core/graph/nodes/action/animationNodes.ts b/src/core/graph/nodes/action/animationNodes.ts index 2a0ac2ea..fe6dac4e 100644 --- a/src/core/graph/nodes/action/animationNodes.ts +++ b/src/core/graph/nodes/action/animationNodes.ts @@ -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' }; }, }; diff --git a/src/core/graph/nodes/data/literals.ts b/src/core/graph/nodes/data/literals.ts index 299ac74e..57326f04 100644 --- a/src/core/graph/nodes/data/literals.ts +++ b/src/core/graph/nodes/data/literals.ts @@ -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', diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index e8b9f8db..8914df20 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 08325251..25864f46 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -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 }); diff --git a/src/core/properties/kinds.ts b/src/core/properties/kinds.ts index 4acf28fb..609389c7 100644 --- a/src/core/properties/kinds.ts +++ b/src/core/properties/kinds.ts @@ -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 }, diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 9a045385..4b6edfaf 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -247,6 +247,11 @@ const REFERENCES: Record = { 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); } } diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index e72da9d1..adb0a2a4 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -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)); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 6380abda..e28360e7 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -477,6 +477,10 @@ const CASES: Record = { { 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 = { 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 = { 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 = { 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([]), }, diff --git a/tools/seed-actor.mjs b/tools/seed-actor.mjs index 57db4671..be68129b 100644 --- a/tools/seed-actor.mjs +++ b/tools/seed-actor.mjs @@ -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 }, diff --git a/tools/smoke-actor.mjs b/tools/smoke-actor.mjs index 79467229..91081de2 100644 --- a/tools/smoke-actor.mjs +++ b/tools/smoke-actor.mjs @@ -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.