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:
adminandClaude Opus 5.5 committed 2026-10-01 10:57:31 +02:00
1 parent 26cc6324af
commit c1cc80bb45
11 files changed
+437 -4

No files matched your search

+6 -1
View File
@@ -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) };
},
};
+4
View File
@@ -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,