Add nodes that ask whether an animation still runs (#233)
A graph could start, pause and stop an animation and hear it finish, but it could not ask in between whether one runs or how far it is. Two data nodes answer that now. - Animation playing takes an object and an animation and gives playing, progress and time left (s). It answers at the moment it is read: right after Play animation in the same pass it says on, and in the frame On animation finished fires it says off. - With Animation at None it answers for the running animation started last on the object. - Animation length gives the seconds of one pass, from the timeline and the frame rate alone. - The rule is a pure function in src/core/model/animation.ts (playerStatus, leadingPlayer). The nodes reach an animation's length through a new field animation on GraphLookup. - animationPort is exported so both nodes show the same picker as Play animation. Decided in the issue: a Once (reset) counts as running while it blends out, and progress and time left also hold for Loop and Ping-pong, at the place of the playhead. On Ping-pong progress falls again on the way back and time left counts to the next turn. Decided here, the issue says nothing on it: - A paused animation does not count as running. Progress and time left stand still. - One that Stop animation fades out does not count as running, and its time left is 0. - Time left of a Once (reset) includes its blend out. While it blends out progress is 1 and time left is the rest of the fade. Finding, not fixed: on the node the name of the chosen animation overlaps the output label progress in the same row. That comes from the fixed node width in NodeView and was seen in the Fit view only. Tests: - Gate: 198 files, 2903 tests (2891 + 12), exit 0 - e2e 1-6: 282 checks passed, 06 has +2 for block 6z-ix - Nine unit counter-probes fail, 1 to 4 tests each - The browser counter-probe fails 6z-ix with ["false","false"] - e2e total 1615 is computed, not measured in a full run. The smokes were not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
26cc6324af
commit
c1cc80bb45
11 files changed
+437
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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) };
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<string, Case[]> = {
|
||||
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',
|
||||
|
||||
@@ -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-'));
|
||||
`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user