diff --git a/README.md b/README.md index 8b16757b..a27c3785 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1613 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1615 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/lookup.ts b/src/core/graph/lookup.ts index ad5c0cdc..5eee5e60 100644 --- a/src/core/graph/lookup.ts +++ b/src/core/graph/lookup.ts @@ -1,3 +1,4 @@ +import type { AnimationClip } from '../model/animation'; import type { VectorPoint } from '../model/Asset'; import type { PropertyBag } from '../model/Properties'; import type { Graph } from './Graph'; @@ -38,7 +39,8 @@ export interface StateMachine { /** * What a node may look up in the project around its graph: the behaviors it can run, the state * machines it can read, the curves a `Lerp` eases along (#247) and the struct types `Add item` - * checks (#254), and what a variable written by a graph is held to (#271). One handle, so no caller + * checks (#254), what a variable written by a graph is held to (#271) and how long an animation is + * (#233). One handle, so no caller * can resolve a node with half of it. */ export interface GraphLookup { @@ -49,6 +51,8 @@ export interface GraphLookup { structType(assetId: string): StructShape | null; /** A texture's, frame's or pixel-art frame's own size in pixels; null for no picture. */ textureSize(pictureId: string): { w: number; h: number } | null; + /** An animation's length and playback, for a node that asks where a player stands in it (#233). */ + animation(assetId: string): AnimationClip | null; /** The asset whose graph this is, `''` for none. */ ownerOf(graph: Graph): string; /** What a variable of that asset declares about the structs it holds (#271); none for nothing typed. */ @@ -78,6 +82,7 @@ export const NO_LOOKUP: GraphLookup = { curve: () => null, structType: () => null, textureSize: () => null, + animation: () => null, ownerOf: () => '', variableTyping: () => undefined, }; diff --git a/src/core/graph/nodes/action/animationNodes.ts b/src/core/graph/nodes/action/animationNodes.ts index 4fefe70d..b03ccabb 100644 --- a/src/core/graph/nodes/action/animationNodes.ts +++ b/src/core/graph/nodes/action/animationNodes.ts @@ -4,10 +4,10 @@ import { asBoolean, asEntity, asNumber, asString } from '../../values'; import { bool, exec, num, obj, withDefault } from '../shared'; /** - * The `animation` input of the three animation nodes; unconnected it shows the animation picker, the + * The `animation` input of the 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 => ({ +export const animationPort = (): PortDef => ({ key: 'animation', label: 'animation', type: 'string', diff --git a/src/core/graph/nodes/data/AnimationLength.ts b/src/core/graph/nodes/data/AnimationLength.ts new file mode 100644 index 00000000..4a67b585 --- /dev/null +++ b/src/core/graph/nodes/data/AnimationLength.ts @@ -0,0 +1,19 @@ +import { clipDuration } from '../../../model/animation'; +import type { NodeDef } from '../../NodeRegistry'; +import { asString } from '../../values'; +import { animationPort } from '../action/animationNodes'; +import { num } from '../shared'; + +export const AnimationLength: NodeDef = { + type: 'AnimationLength', + label: 'Animation length', + category: 'data', + description: 'The seconds one pass of an animation takes, as its timeline and frame rate say; 0 for none.', + inputs: [animationPort()], + outputs: [num('seconds')], + params: [], + read: (_ctx, _params, input, _def, lookup) => { + const clip = lookup.animation(asString(input('animation'))); + return { seconds: clip ? clipDuration(clip) : 0 }; + }, +}; diff --git a/src/core/graph/nodes/data/AnimationPlaying.ts b/src/core/graph/nodes/data/AnimationPlaying.ts new file mode 100644 index 00000000..7ea69d3b --- /dev/null +++ b/src/core/graph/nodes/data/AnimationPlaying.ts @@ -0,0 +1,26 @@ +import { leadingPlayer, NOT_PLAYING, playerStatus } from '../../../model/animation'; +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity, asString } from '../../values'; +import { animationPort } from '../action/animationNodes'; +import { bool, num, obj } from '../shared'; + +export const AnimationPlaying: NodeDef = { + type: 'AnimationPlaying', + label: 'Animation playing', + category: 'data', + description: + 'Whether an animation runs on an object right now, and where its playhead stands. A paused or stopped ' + + 'one does not run; a Once (reset) blending out still does. Progress is the place on the timeline, ' + + '0…1 – on Ping-pong it falls again on the way back. Time left is the seconds until the playhead ' + + 'reaches the end of its pass, blend out included. Leave Animation at None to ask for the one ' + + 'started last.', + inputs: [obj('object'), animationPort()], + outputs: [bool('playing'), num('progress'), num('timeLeft', 'time left (s)')], + params: [], + read: (_ctx, _params, input, _def, lookup) => { + const target = asEntity(input('object')); + const player = target && leadingPlayer(target, asString(input('animation'))); + const clip = player && lookup.animation(player.animationId); + return { ...(clip ? playerStatus(clip, player) : NOT_PLAYING) }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index db2a7ea7..3b4584c8 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -26,6 +26,8 @@ import { StopSound } from './action/StopSound'; import { CellToWorld, SetTile, TileAt, TileLiteral, WorldToCell } from './action/tileNodes'; import { Abs } from './data/Abs'; import { Add } from './data/Add'; +import { AnimationLength } from './data/AnimationLength'; +import { AnimationPlaying } from './data/AnimationPlaying'; import { And } from './data/And'; import { AxisInput } from './data/AxisInput'; import { Ceil } from './data/Ceil'; @@ -188,6 +190,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ Properties, GetPosition, TextureSize, + AnimationPlaying, + AnimationLength, NumberLiteral, BooleanLiteral, StringLiteral, diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index b153d15c..825c80c1 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -651,6 +651,72 @@ export function pauseAnimation(holder: PlayerHolder, animationId: string): void if (animationId === '' || player.animationId === animationId) player.playing = false; } +/** What a run needs of an animation to say where a player stands in it (#233). */ +export interface AnimationClip { + frames: number; + fps: number; + playback: Playback; +} + +export function clipOf(asset: AnimationAsset): AnimationClip { + return { frames: frameCount(asset), fps: fpsOf(asset), playback: playbackOf(asset) }; +} + +export function clipDuration(clip: AnimationClip): number { + return clip.fps > 0 ? clip.frames / clip.fps : 0; +} + +/** What `Animation playing` answers (#233). */ +export interface PlayerStatus { + playing: boolean; + /** Where the playhead stands on the timeline, 0…1 – on ping-pong's way back it falls again. */ + progress: number; + /** Seconds until the playhead reaches the end of its pass, and a `once-reset` has blended out. */ + timeLeft: number; +} + +export const NOT_PLAYING: PlayerStatus = { playing: false, progress: 0, timeLeft: 0 }; + +/** + * A paused player is not running, and neither is one `Stop animation` fades out. A `once-reset` pass + * blending back is: it still shows. + */ +function isRunning(player: AnimationPlayer): boolean { + return player.fade ? player.fade.ended === true : player.playing; +} + +/** + * The player `Animation playing` answers for: the one of that animation, or for `''` the one started + * last among those running – and when none runs, the one started last at all. + */ +export function leadingPlayer(holder: PlayerHolder, animationId: string): AnimationPlayer | undefined { + const players = (holder.animations ?? []).filter( + (p) => animationId === '' || p.animationId === animationId, + ); + const running = players.filter(isRunning); + return (running.length > 0 ? running : players).sort((a, b) => b.order - a.order)[0]; +} + +export function playerStatus(clip: AnimationClip, player: AnimationPlayer): PlayerStatus { + const playing = isRunning(player); + const fade = player.fade; + if (fade?.ended) return { playing, progress: 1, timeLeft: Math.max(0, fade.seconds - fade.elapsed) }; + if (clip.frames <= 0 || clip.fps <= 0) return { playing, progress: 0, timeLeft: 0 }; + const head = playheadAt(clip.frames, clip.fps, clip.playback, player.time); + if (clip.playback === 'pingpong') { + const last = clip.frames - 1; + if (last <= 0) return { playing, progress: 0, timeLeft: 0 }; + const ahead = head.reverse ? head.frame : last - head.frame; + return { playing, progress: head.frame / last, timeLeft: fade ? 0 : ahead / clip.fps }; + } + const blendOut = clip.playback === 'once-reset' ? (player.blendOut ?? 0) : 0; + return { + playing, + progress: Math.min(1, head.frame / clip.frames), + timeLeft: fade ? 0 : Math.max(0, clip.frames - head.frame) / clip.fps + blendOut, + }; +} + /** A key on one row, as the timeline selects it. */ export interface KeyRef { trackId: string; diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index dff5bc49..beb65fa9 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -2,6 +2,7 @@ import type { GraphLookup } from '../graph/lookup'; import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine'; import { pictureSize } from '../properties/aspect'; import { resolveAssetProps } from '../properties/resolve'; +import { clipOf } from './animation'; import { isGraphAsset, isVariableOwner } from './Asset'; import { bagWithoutItemTypes } from './Properties'; import { fieldTypings, typeFields, typingOf } from './structTypes'; @@ -45,6 +46,10 @@ export function graphLookup(project: Project): GraphLookup { const ref = resolvePicture(project, pictureId); return ref ? pictureSize(ref) : null; }, + animation: (assetId) => { + const asset = findAssetOf(project, assetId, 'animation'); + return asset ? clipOf(asset) : null; + }, ownerOf: (graph) => project.assets.find((a) => isGraphAsset(a) && a.graph === graph)?.id ?? '', variableTyping: (assetId, key) => { const owner = findAsset(project, assetId); diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index b1d88c1d..e0c91540 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -30,13 +30,20 @@ import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands' import { RenameVariableCommand, SetVariablePrivateCommand } from '../../src/core/commands/variableCommands'; import { animatableDefs, + type AnimationClip, animationDuration, + type AnimationPlayer, frameCount, isPositionRow, + leadingPlayer, + NOT_PLAYING, offeredDefs, pauseAnimation, PLACED_ROOT_ID, playAnimation, + type Playback, + playerStatus, + type PlayerStatus, playheadAt, stopAnimation, targetDefs, @@ -46,6 +53,7 @@ import { } from '../../src/core/model/animation'; import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders'; import { previewHolder } from '../../src/core/model/animationPreview'; +import { graphLookup } from '../../src/core/model/graphLookup'; import type { AnimationAsset, AnimationKey, @@ -1382,6 +1390,149 @@ describe('the animation nodes', () => { }); }); +describe('asking whether an animation still runs (#233)', () => { + const clip = (playback: Playback): AnimationClip => ({ frames: 10, fps: 10, playback }); + const at = (time: number, more = {}): AnimationPlayer => ({ + animationId: 'a', + time, + playing: true, + order: 1, + ...more, + }); + const close = (status: PlayerStatus, progress: number, timeLeft: number) => { + expect(status.progress).toBeCloseTo(progress, 4); + expect(status.timeLeft).toBeCloseTo(timeLeft, 4); + }; + /** What the node answers for the object, the lookup being the project's own. */ + const asked = (f: SceneFixture, holder: { animations?: AnimationPlayer[] }, animationId: string) => { + const player = leadingPlayer(holder, animationId); + const known = player && graphLookup(f.project).animation(player.animationId); + return known ? playerStatus(known, player) : NOT_PLAYING; + }; + + it('says where the playhead stands in its pass, in every playback', () => { + close(playerStatus(clip('once'), at(0.25)), 0.25, 0.75); + close(playerStatus(clip('loop'), at(1.25)), 0.25, 0.75); + // Ping-pong turns on the last key: 9 frames up, and on the way back the progress falls again. + close(playerStatus(clip('pingpong'), at(0.3)), 1 / 3, 0.6); + close(playerStatus(clip('pingpong'), at(1.2)), 2 / 3, 0.6); + close(playerStatus(clip('once-reset'), at(0.25, { blendOut: 0.4 })), 0.25, 1.15); + close(playerStatus(clip('once'), at(0.25, { blendOut: 0.4 })), 0.25, 0.75); + expect(playerStatus({ frames: 0, fps: 10, playback: 'loop' }, at(3))).toEqual({ + playing: true, + progress: 0, + timeLeft: 0, + }); + }); + + it('still runs while a once-reset pass blends out, and is gone after', () => { + const { f, walk, sprite, frame } = slide({ playback: 'once-reset' }); + expect(asked(f, sprite, walk.id)).toEqual(NOT_PLAYING); + playAnimation(sprite, walk.id, 0, 0.4); + for (let i = 0; i < 6; i++) frame(0.1); + expect(asked(f, sprite, walk.id).playing).toBe(true); + close(asked(f, sprite, walk.id), 0.5, 0.9); + for (let i = 0; i < 5; i++) frame(0.1); + expect(asked(f, sprite, walk.id).playing).toBe(true); + close(asked(f, sprite, walk.id), 1, 0.4); + frame(0.1); + close(asked(f, sprite, walk.id), 1, 0.3); + for (let i = 0; i < 3; i++) frame(0.1); + expect(asked(f, sprite, walk.id)).toEqual(NOT_PLAYING); + }); + + it('does not run paused or stopped, and keeps its place', () => { + const { f, walk, sprite, frame } = slide(); + playAnimation(sprite, walk.id); + for (let i = 0; i < 4; i++) frame(0.1); + pauseAnimation(sprite, walk.id); + frame(0.5); + expect(asked(f, sprite, walk.id).playing).toBe(false); + close(asked(f, sprite, walk.id), 0.3, 0.7); + playAnimation(sprite, walk.id); + expect(asked(f, sprite, walk.id).playing).toBe(true); + stopAnimation(sprite, walk.id, 0.5); + expect(asked(f, sprite, walk.id).playing).toBe(false); + close(asked(f, sprite, walk.id), 0.3, 0); + }); + + it('answers for the one started last when no animation is named, a running one first', () => { + const { f, walk, sprite } = slide(); + const bob = addAnimation(f, f.spriteId, { fps: 10 }, 'Bob'); + playAnimation(sprite, walk.id); + playAnimation(sprite, bob.id); + expect(leadingPlayer(sprite, '')?.animationId).toBe(bob.id); + expect(leadingPlayer(sprite, walk.id)?.animationId).toBe(walk.id); + pauseAnimation(sprite, bob.id); + expect(leadingPlayer(sprite, '')?.animationId).toBe(walk.id); + pauseAnimation(sprite, walk.id); + expect(leadingPlayer(sprite, '')?.animationId).toBe(bob.id); + expect(asked(f, sprite, '').playing).toBe(false); + expect(leadingPlayer({}, '')).toBeUndefined(); + }); + + /** + * A behavior below the sprite plays `walk` on its owner at start, and every frame writes down what + * `Animation playing` says: `ran` counts the frames it ran, `at` and `left` are the last answer. + */ + function watching(playback: Playback, frames: number, named = true) { + const f = makeSpriteScene(); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + const walk = addAnimation(f, f.spriteId, { fps: 10, playback }); + addRow(f, walk, 'x', [ + [0, 0], + [9, 90], + ]); + const behavior = makeBehavior(f, 'Logic', ['ran', 'at', 'left', 'length']); + place(f, behavior.id, { x: 0, y: 0 }, box); + const edit = graphEditor(f, behavior); + const start = edit.addNode('OnStart'); + const owner = edit.addNode('Owner'); + const play = edit.addNode('PlayAnimation', { animation: walk.id }); + const update = edit.addNode('OnUpdate'); + const playing = edit.addNode('AnimationPlaying', { animation: named ? walk.id : '' }); + const length = edit.addNode('AnimationLength', { animation: walk.id }); + const note = edit.variables(['at', 'left', 'length'], { write: true }); + const branch = edit.addNode('Branch'); + const count = edit.variables(['ran'], { write: true }); + const add = edit.addNode('Add', { b: 1 }); + edit.wire([ + [start, 'exec', play, 'exec'], + [owner, 'object', play, 'object'], + [owner, 'object', playing, 'object'], + [play, 'exec', note, 'exec'], + [update, 'exec', note, 'exec'], + [playing, 'progress', note, 'at'], + [playing, 'timeLeft', note, 'left'], + [length, 'seconds', note, 'length'], + [note, 'exec', branch, 'exec'], + [playing, 'playing', branch, 'condition'], + [branch, 'then', count, 'exec'], + [count, 'ran', add, 'a'], + [add, 'result', count, 'ran'], + ]); + const { world, frame } = startWorld(f, { animations: true, behaviors: true }); + for (let i = 0; i < frames; i++) frame(0.1); + return world.entities.find((e) => e.type === 'behavior')!.props; + } + + it('answers in a graph: true in the very pulse that plays it, false once a once pass ended', () => { + // The start plays it and asks at once; the pass ends in frame 11, before the behaviors run. + expect(watching('once', 1).ran).toBe(2); + const midway = watching('once', 6); + expect(midway.ran).toBe(7); + expect(midway.at).toBeCloseTo(0.5, 4); + expect(midway.left).toBeCloseTo(0.5, 4); + expect(midway.length).toBe(1); + const over = watching('once', 20); + expect(over).toMatchObject({ ran: 11, at: 0, left: 0 }); + }); + + it('answers for whatever runs on the object when the node names no animation', () => { + expect(watching('loop', 6, false)).toMatchObject({ ran: 7 }); + }); +}); + describe('a row on z, the draw order', () => { /** The sprite placed twice: `box` at z 0 plays a row moving z 0 → 10, `wall` stands at z 5. */ function climb() { diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 5c15134d..93d149e3 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -100,6 +100,23 @@ const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y const emitTarget = fakeEntity('Sparks'); /** Their own entity each: the animation nodes leave players on the object they are handed. */ const dancer = fakeEntity('Dancer'); +/** An object whose once-reset "swing" has run out and blends back over half a second (#233). */ +const swinging = { + ...fakeEntity('Fighter'), + animations: [ + { + animationId: 'swing', + time: 1, + playing: true, + order: 1, + fade: { seconds: 0.5, elapsed: 0, ended: true }, + }, + ], +}; +const swingLookup: GraphLookup = { + ...NO_LOOKUP, + animation: (id) => (id === 'swing' ? { frames: 10, fps: 10, playback: 'once-reset' } : null), +}; const packed = fakeEntity('Player'); const painted = fakeHandle({ width: 100, text: '' }); @@ -278,6 +295,41 @@ const CASES: Record = { outputs: { width: 0, height: 0 }, }, ], + AnimationPlaying: [ + { + name: 'a once-reset pass blending out still runs, for as long as its blend out lasts (#233)', + inputs: { object: swinging, animation: 'swing' }, + lookup: swingLookup, + outputs: { playing: true, progress: 1, timeLeft: 0.5 }, + }, + { + name: 'with no animation named answers for what runs on the object', + inputs: { object: swinging, animation: '' }, + lookup: swingLookup, + outputs: { playing: true, progress: 1, timeLeft: 0.5 }, + }, + { + name: 'another animation is not playing', + inputs: { object: swinging, animation: 'walk' }, + lookup: swingLookup, + outputs: { playing: false, progress: 0, timeLeft: 0 }, + }, + { + name: 'no object plays nothing', + inputs: { object: null, animation: '' }, + lookup: swingLookup, + outputs: { playing: false, progress: 0, timeLeft: 0 }, + }, + ], + AnimationLength: [ + { + name: 'one pass in seconds: ten frames at 10 fps', + inputs: { animation: 'swing' }, + lookup: swingLookup, + outputs: { seconds: 1 }, + }, + { name: 'no animation is 0', inputs: { animation: '' }, outputs: { seconds: 0 } }, + ], CurrentState: [ { name: 'names the state the machine is in, and hands out its id as well', diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 6e680b5c..9b6d7802 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -1106,6 +1106,7 @@ export async function graph(t) { await splitOutputs(); await pluralLookups(); await eventOrder(); + await animationPlaying(); await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`); @@ -1726,4 +1727,108 @@ export async function graph(t) { scene.objects = scene.objects.filter((o) => !o.id.startsWith('ord-')); `); } + + // 6z-ix. Animation playing (#233): typed into the palette it arrives with its ports. In a run it + // says true in the very pulse that plays a once animation, and false when that one finished. + async function animationPlaying() { + await mutate(` + const p = __gm.state.project; + const sprite = p.assets.find((a) => a.type === 'sprite'); + p.assets.push( + { + id: 'ap-swing', + type: 'animation', + name: 'Swing 6', + props: { fps: 10, playback: 'once' }, + root: sprite.id, + tracks: [{ id: 'ap-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 2, value: 20 }] }], + }, + { + id: 'apy', + type: 'behavior', + name: 'Asker', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: { nodes: [], edges: [] }, + }, + ); + __gm.state.select({ kind: 'asset', assetId: 'apy' }); + `); + await showTab('graph'); + const spot = await json('__gm.graph.emptySpot()'); + await click(spot.x, spot.y, 'right'); + await typeText('animation playing'); + await press('Enter'); + const asker = (await asset('apy')).graph.nodes[0]; + const types = asker ? await json(`__gm.graph.portTypes(${JSON.stringify(asker.id)})`) : null; + const inputs = asker ? await json(`__gm.graph.nodeInputs(${JSON.stringify(asker.id)})`) : null; + check( + 'typing "animation playing" into the palette adds the node: object and animation in, three answers out', + asker?.type === 'AnimationPlaying' && + JSON.stringify(Object.keys(types?.inputs ?? {})) === JSON.stringify(['object', 'animation']) && + JSON.stringify(types?.outputs) === + JSON.stringify({ playing: 'boolean', progress: 'number', timeLeft: 'number' }) && + inputs?.[1] === 'animation None', + JSON.stringify({ asker, types, inputs }), + ); + if (!asker) return; + const id = JSON.stringify(asker.id); + await mutate(` + const p = __gm.state.project; + const behavior = p.assets.find((a) => a.id === 'apy'); + behavior.graph.nodes.find((n) => n.id === ${id}).params.animation = 'ap-swing'; + const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params }); + const edge = (id, from, fromPort, to, toPort) => + ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } }); + behavior.graph.nodes.push( + node('ap-start', 'OnStart', -300, -200), + node('ap-owner', 'Owner', -560, 0), + node('ap-play', 'PlayAnimation', -40, -200, { animation: 'ap-swing', blend: 0, blendOut: 0, restart: false }), + node('ap-said', 'Log', 420, -200), + node('ap-finished', 'OnAnimationFinished', 0, 260, { animation: 'ap-swing', order: 0 }), + node('ap-after', 'Log', 420, 260), + ); + behavior.graph.edges.push( + edge('ap-e1', 'ap-start', 'exec', 'ap-play', 'exec'), + edge('ap-e2', 'ap-owner', 'object', 'ap-play', 'object'), + edge('ap-e3', 'ap-owner', 'object', ${id}, 'object'), + edge('ap-e4', 'ap-play', 'exec', 'ap-said', 'exec'), + edge('ap-e5', ${id}, 'playing', 'ap-said', 'message'), + edge('ap-e6', 'ap-finished', 'exec', 'ap-after', 'exec'), + edge('ap-e7', ${id}, 'playing', 'ap-after', 'message'), + ); + const scene = __gm.state.activeScene; + const sprite = p.assets.find((a) => a.type === 'sprite'); + for (const [id, assetId, parentId, name] of [ + ['ap-target', sprite.id, '', 'Swinger'], + ['ap-holder', 'apy', 'ap-target', 'Ask holder'], + ]) { + const o = structuredClone(scene.objects[0]); + Object.assign(o, { id, assetId, parentId, name, position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); + delete o.groupId; + scene.objects.push(o); + } + `); + await clickText('Fit'); + await shot('28-animation-playing'); + const logFrom = t.consoleLog.length; + await t.play(); + await sleep(900); + await stop(); + const logged = t.consoleLog + .slice(logFrom) + .filter((line) => line.startsWith('[behavior Ask holder]')) + .map((line) => line.split(']').pop().trim()); + check( + 'a run hears true right after Play animation, and false once the animation finished', + JSON.stringify(logged) === JSON.stringify(['true', 'false']), + JSON.stringify(logged), + ); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'apy' && a.id !== 'ap-swing'); + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => !o.id.startsWith('ap-')); + `); + } }