Let a behavior hear the end of an animation it played itself
A behavior that played an animation on the parent of its owner never heard it finish. On animation finished listened to the owner and to what stands below it, and the parent stands above. - Play animation notes on the player which behavior asked for it. - On animation finished hears, as before, every animation on the owner or below it, and now also every animation the behavior played itself, on whatever object. Its output object names that object. - Two behaviors that both played the same animation on the same object each hear its end once. - A behavior that is called by Run behavior or heard by an On node counts as the behavior it sits in. - The description of the node and the engine prompt say so. Tests: - Gate on melektaus-atx: 218 files, 3392 tests, CHECK EXIT 0. - Two new unit tests: the behavior hears the animation on the parent of its owner while a bystander does not, and two starters hear it once each. - Both counter-probes bite. - e2e 6,17-26 on melektaus-atx: 480 checks, all pass. - No new e2e check, and nothing was looked at in the browser. The rule for called and heard behaviors has no test of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7dd3ae1131
commit
d6fd000160
7 files changed
+76
-6
No files matched your search
@@ -304,7 +304,7 @@ An animation asset is a timeline over one **root asset** (a sprite, text, contai
|
|||||||
- Numbers and colours blend between keys; textures, booleans, texts and choices hold until the next key. The playhead moves every tick, not only on whole frames. A row may be **relative**: its numbers are added to what the target held when the animation started.
|
- Numbers and colours blend between keys; textures, booleans, texts and choices hold until the next key. The playhead moves every tick, not only on whole frames. A row may be **relative**: its numbers are added to what the target held when the animation started.
|
||||||
- Each key of a blending row has an **easing** to the next key: Linear, Step, the curves Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back, Elastic, Bounce each as In, Out or In-out, or a curve asset.
|
- Each key of a blending row has an **easing** to the next key: Linear, Step, the curves Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back, Elastic, Bounce each as In, Out or In-out, or a curve asset.
|
||||||
- **Animation tab:** a header (root picker, *+ Row*, Play/Pause, Fit), a preview that shows the root at the playhead without changing the document, and a timeline with a frame ruler: double-click a cell to set a key, click or band-select keys, drag to move them, `Ctrl`+drag to duplicate, `Delete` removes them, copy and paste at the playhead, click the ruler to scrub. The selected key's value and easing are edited in the inspector.
|
- **Animation tab:** a header (root picker, *+ Row*, Play/Pause, Fit), a preview that shows the root at the playhead without changing the document, and a timeline with a frame ruler: double-click a cell to set a key, click or band-select keys, drag to move them, `Ctrl`+drag to duplicate, `Delete` removes them, copy and paste at the playhead, click the ruler to scrub. The selected key's value and easing are edited in the inspector.
|
||||||
- **Runtime:** `Play animation` (object, animation, `blend` seconds to fade in from the current values, `blend out` for Once (reset), `restart`), `Stop animation` (with optional blend out), `Pause animation`. Playing an animation on a placed container plays it on that instance alone. Several animations may run on one object; where two write the same property the one started later wins, and a behavior writing it in the same frame wins over both. `On animation finished` fires exactly once in the frame a Once animation ends. `Animation playing` returns `playing`, `progress` (0 to 1) and `time left`; `Animation length` returns the seconds of one pass.
|
- **Runtime:** `Play animation` (object, animation, `blend` seconds to fade in from the current values, `blend out` for Once (reset), `restart`), `Stop animation` (with optional blend out), `Pause animation`. Playing an animation on a placed container plays it on that instance alone. Several animations may run on one object; where two write the same property the one started later wins, and a behavior writing it in the same frame wins over both. `On animation finished` fires exactly once in the frame a Once animation ends. A behavior hears it for animations on its owner or below it, and for every animation it played itself on any object. `Animation playing` returns `playing`, `progress` (0 to 1) and `time left`; `Animation length` returns the seconds of one pass.
|
||||||
- Animations run in play only; the scene view shows objects as stored.
|
- Animations run in play only; the scene view shows objects as stored.
|
||||||
|
|
||||||
## 16. Shaders
|
## 16. Shaders
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const PlayAnimation: NodeDef = {
|
|||||||
],
|
],
|
||||||
outputs: [exec()],
|
outputs: [exec()],
|
||||||
params: [],
|
params: [],
|
||||||
run: (_ctx, inputs) => {
|
run: (ctx, inputs) => {
|
||||||
const target = asEntity(inputs.object);
|
const target = asEntity(inputs.object);
|
||||||
const animation = asString(inputs.animation);
|
const animation = asString(inputs.animation);
|
||||||
if (target && animation)
|
if (target && animation)
|
||||||
@@ -46,6 +46,7 @@ export const PlayAnimation: NodeDef = {
|
|||||||
Math.max(0, asNumber(inputs.blend)),
|
Math.max(0, asNumber(inputs.blend)),
|
||||||
Math.max(0, asNumber(inputs.blendOut)),
|
Math.max(0, asNumber(inputs.blendOut)),
|
||||||
asBoolean(inputs.restart),
|
asBoolean(inputs.restart),
|
||||||
|
ctx.self,
|
||||||
);
|
);
|
||||||
return { next: 'exec' };
|
return { next: 'exec' };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export const OnAnimationFinished: NodeDef = {
|
|||||||
label: 'On animation finished',
|
label: 'On animation finished',
|
||||||
category: 'event',
|
category: 'event',
|
||||||
description:
|
description:
|
||||||
'Runs once when an animation played once on the owner, or on an object below it, reaches its end. Leave Animation at None to hear every animation.',
|
'Runs once when an animation played once on the owner, or on an object below it, reaches its end – ' +
|
||||||
|
'and when one this behavior played itself does, on whatever object. Leave Animation at None to hear every animation.',
|
||||||
inputs: [],
|
inputs: [],
|
||||||
outputs: [exec(), obj('object')],
|
outputs: [exec(), obj('object')],
|
||||||
params: [
|
params: [
|
||||||
|
|||||||
@@ -548,6 +548,11 @@ export interface AnimationPlayer {
|
|||||||
/** Seconds a `once-reset` pass takes to blend back to its starting values when its frames run out. */
|
/** Seconds a `once-reset` pass takes to blend back to its starting values when its frames run out. */
|
||||||
blendOut?: number;
|
blendOut?: number;
|
||||||
fade?: AnimationFade;
|
fade?: AnimationFade;
|
||||||
|
/**
|
||||||
|
* Who asked for it – the behaviors that played it, which hear its end wherever the object stands.
|
||||||
|
* Opaque here: the runtime hands in its behavior entities.
|
||||||
|
*/
|
||||||
|
startedBy?: object[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -596,7 +601,8 @@ let startCount = 0;
|
|||||||
* when a `once-reset` pass already ran out, starts over. `blendOutSeconds` is the fade at the end of
|
* when a `once-reset` pass already ran out, starts over. `blendOutSeconds` is the fade at the end of
|
||||||
* a `once-reset` pass. `restart` starts a known one over from its first frame in any case – blended in
|
* a `once-reset` pass. `restart` starts a known one over from its first frame in any case – blended in
|
||||||
* from where it stands when `blendSeconds` is set – and keeps its starting values, so a relative row
|
* from where it stands when `blendSeconds` is set – and keeps its starting values, so a relative row
|
||||||
* does not add up.
|
* does not add up. `startedBy` is noted on the player next to whoever asked before, so each of them
|
||||||
|
* hears it finish.
|
||||||
*/
|
*/
|
||||||
export function playAnimation(
|
export function playAnimation(
|
||||||
holder: PlayerHolder,
|
holder: PlayerHolder,
|
||||||
@@ -604,12 +610,15 @@ export function playAnimation(
|
|||||||
blendSeconds = 0,
|
blendSeconds = 0,
|
||||||
blendOutSeconds = 0,
|
blendOutSeconds = 0,
|
||||||
restart = false,
|
restart = false,
|
||||||
|
startedBy?: object,
|
||||||
): void {
|
): void {
|
||||||
const players = (holder.animations ??= []);
|
const players = (holder.animations ??= []);
|
||||||
const known = players.find((p) => p.animationId === animationId);
|
const known = players.find((p) => p.animationId === animationId);
|
||||||
const blend = blendSeconds > 0 ? { seconds: blendSeconds, elapsed: 0 } : undefined;
|
const blend = blendSeconds > 0 ? { seconds: blendSeconds, elapsed: 0 } : undefined;
|
||||||
const blendOut = blendOutSeconds > 0 ? blendOutSeconds : undefined;
|
const blendOut = blendOutSeconds > 0 ? blendOutSeconds : undefined;
|
||||||
if (known) {
|
if (known) {
|
||||||
|
if (startedBy && !known.startedBy?.includes(startedBy))
|
||||||
|
known.startedBy = [...(known.startedBy ?? []), startedBy];
|
||||||
const leads = players.every((p) => p.order <= known.order);
|
const leads = players.every((p) => p.order <= known.order);
|
||||||
const fade = known.fade;
|
const fade = known.fade;
|
||||||
known.playing = true;
|
known.playing = true;
|
||||||
@@ -633,6 +642,7 @@ export function playAnimation(
|
|||||||
order: ++startCount,
|
order: ++startCount,
|
||||||
...(blend && { blend }),
|
...(blend && { blend }),
|
||||||
...(blendOut && { blendOut }),
|
...(blendOut && { blendOut }),
|
||||||
|
...(startedBy && { startedBy: [startedBy] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface AnimationFinish {
|
|||||||
/** The object the animation was played on. */
|
/** The object the animation was played on. */
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
animationId: string;
|
animationId: string;
|
||||||
|
/** The behaviors that played it; they hear it wherever `entity` stands. */
|
||||||
|
startedBy: readonly object[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A row that reaches something this tick: how to read and write it, and the kind it blends as. */
|
/** A row that reaches something this tick: how to read and write it, and the kind it blends as. */
|
||||||
@@ -104,7 +106,7 @@ export class AnimationSystem implements System {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!head.finished) continue;
|
if (!head.finished) continue;
|
||||||
this.finished.push({ entity, animationId: asset.id });
|
this.finished.push({ entity, animationId: asset.id, startedBy: player.startedBy ?? [] });
|
||||||
if (playbackOf(asset) !== 'once-reset') {
|
if (playbackOf(asset) !== 'once-reset') {
|
||||||
drop(entity, player);
|
drop(entity, player);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -208,11 +208,15 @@ export function collisionExitPulses(
|
|||||||
/**
|
/**
|
||||||
* A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below
|
* A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below
|
||||||
* it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view.
|
* it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view.
|
||||||
|
* What it played itself it hears wherever that stands: on the owner's parent, or across the scene.
|
||||||
*/
|
*/
|
||||||
export function animationFinishedPulses(instance: Instance, animations: AnimationSystem | null): Pulse[] {
|
export function animationFinishedPulses(instance: Instance, animations: AnimationSystem | null): Pulse[] {
|
||||||
const owner = instance.entity.parent;
|
const owner = instance.entity.parent;
|
||||||
const finished = (animations?.finished ?? []).filter(
|
const finished = (animations?.finished ?? []).filter(
|
||||||
({ entity }) => !owner || [...parentChain(entity, (e) => e.parent)].includes(owner),
|
({ entity, startedBy }) =>
|
||||||
|
!owner ||
|
||||||
|
startedBy.includes(instance.entity) ||
|
||||||
|
[...parentChain(entity, (e) => e.parent)].includes(owner),
|
||||||
);
|
);
|
||||||
return instance.interpreter.eventNodes('OnAnimationFinished').flatMap((node) => {
|
return instance.interpreter.eventNodes('OnAnimationFinished').flatMap((node) => {
|
||||||
const wanted = textOf(node.params.animation ?? '');
|
const wanted = textOf(node.params.animation ?? '');
|
||||||
|
|||||||
@@ -1307,6 +1307,58 @@ describe('the animation nodes', () => {
|
|||||||
expect(listening('other').done).toBe(0);
|
expect(listening('other').done).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two arms below a box, a behavior on each. The first plays `walk` on the box, its owner's parent;
|
||||||
|
* the second only listens, or plays it too. Both count `On animation finished`.
|
||||||
|
*/
|
||||||
|
function above(second: 'listens' | 'plays too', times = 8) {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||||
|
const walk = addAnimation(f, f.spriteId, { fps: 10, playback: 'once' });
|
||||||
|
addRow(f, walk, 'x', [
|
||||||
|
[0, 0],
|
||||||
|
[2, 20],
|
||||||
|
]);
|
||||||
|
const counts = (name: string, plays: boolean) => {
|
||||||
|
const arm = place(f, f.spriteId, { x: 0, y: 0 }, box);
|
||||||
|
const behavior = makeBehavior(f, name, ['done']);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 }, arm);
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const finished = edit.addNode('OnAnimationFinished');
|
||||||
|
const store = edit.variables(['done'], { write: true });
|
||||||
|
const add = edit.addNode('Add', { b: 1 });
|
||||||
|
edit.wire([
|
||||||
|
[finished, 'exec', store, 'exec'],
|
||||||
|
[store, 'done', add, 'a'],
|
||||||
|
[add, 'result', store, 'done'],
|
||||||
|
]);
|
||||||
|
if (!plays) return;
|
||||||
|
const start = edit.addNode('OnStart');
|
||||||
|
const owner = edit.addNode('Owner');
|
||||||
|
const parent = edit.addNode('Parent');
|
||||||
|
const play = edit.addNode('PlayAnimation', { animation: walk.id });
|
||||||
|
edit.wire([
|
||||||
|
[start, 'exec', play, 'exec'],
|
||||||
|
[owner, 'object', parent, 'object'],
|
||||||
|
[parent, 'parent', play, 'object'],
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
counts('First', true);
|
||||||
|
counts('Second', second === 'plays too');
|
||||||
|
const { world, frame } = startWorld(f, { animations: true, behaviors: true });
|
||||||
|
for (let i = 0; i < times; i++) frame(0.1);
|
||||||
|
const done = world.entities.filter((e) => e.type === 'behavior').map((e) => e.props.done);
|
||||||
|
return { done, x: world.findByObjectId(box)!.x };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('hears an animation it played on the parent of its owner, and a bystander does not', () => {
|
||||||
|
expect(above('listens')).toEqual({ done: [1, 0], x: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is heard by each behavior that played it, once', () => {
|
||||||
|
expect(above('plays too')).toEqual({ done: [1, 1], x: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
it('passes Blend on to the player it starts (#106)', () => {
|
it('passes Blend on to the player it starts (#106)', () => {
|
||||||
const f = makeSpriteScene();
|
const f = makeSpriteScene();
|
||||||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||||
|
|||||||
Reference in new issue
Block a user