Let a behavior hear the end of an animation it played itself
game-maker / gate (push) Successful in 3m11s
game-maker / suite (push) Failing after 12m9s

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:
adminandClaude Opus 5.5 committed 2026-10-02 22:37:04 +02:00
1 parent 7dd3ae1131
commit d6fd000160
7 files changed
+76 -6

No files matched your search

@@ -36,7 +36,7 @@ export const PlayAnimation: NodeDef = {
],
outputs: [exec()],
params: [],
run: (_ctx, inputs) => {
run: (ctx, inputs) => {
const target = asEntity(inputs.object);
const animation = asString(inputs.animation);
if (target && animation)
@@ -46,6 +46,7 @@ export const PlayAnimation: NodeDef = {
Math.max(0, asNumber(inputs.blend)),
Math.max(0, asNumber(inputs.blendOut)),
asBoolean(inputs.restart),
ctx.self,
);
return { next: 'exec' };
},
@@ -8,7 +8,8 @@ export const OnAnimationFinished: NodeDef = {
label: 'On animation finished',
category: 'event',
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: [],
outputs: [exec(), obj('object')],
params: [
+11 -1
View File
@@ -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. */
blendOut?: number;
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
* 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
* 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(
holder: PlayerHolder,
@@ -604,12 +610,15 @@ export function playAnimation(
blendSeconds = 0,
blendOutSeconds = 0,
restart = false,
startedBy?: object,
): void {
const players = (holder.animations ??= []);
const known = players.find((p) => p.animationId === animationId);
const blend = blendSeconds > 0 ? { seconds: blendSeconds, elapsed: 0 } : undefined;
const blendOut = blendOutSeconds > 0 ? blendOutSeconds : undefined;
if (known) {
if (startedBy && !known.startedBy?.includes(startedBy))
known.startedBy = [...(known.startedBy ?? []), startedBy];
const leads = players.every((p) => p.order <= known.order);
const fade = known.fade;
known.playing = true;
@@ -633,6 +642,7 @@ export function playAnimation(
order: ++startCount,
...(blend && { blend }),
...(blendOut && { blendOut }),
...(startedBy && { startedBy: [startedBy] }),
});
}
+3 -1
View File
@@ -25,6 +25,8 @@ export interface AnimationFinish {
/** The object the animation was played on. */
entity: Entity;
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. */
@@ -104,7 +106,7 @@ export class AnimationSystem implements System {
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') {
drop(entity, player);
continue;
+5 -1
View File
@@ -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
* 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[] {
const owner = instance.entity.parent;
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) => {
const wanted = textOf(node.params.animation ?? '');