Files
feuerwerk/src/runtime/systems/behaviorPulses.ts
T
adminandClaude Opus 5.5 d6fd000160
game-maker / gate (push) Successful in 3m11s
game-maker / suite (push) Failing after 12m9s
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>
2026-10-02 22:37:04 +02:00

250 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A running behavior, and the pulses the events of one frame send into it: one per event node that
* fires, with what the event hands on. `BehaviorSystem` decides when each kind is gathered and runs
* them in their order.
*/
import { asNumber, type Graph, type GraphInterpreter, type GraphNode, type Value } from '../../core/graph';
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
import { keyName } from '../../core/keys';
import { type PropertyBag, textOf } from '../../core/model/Properties';
import { parentChain } from '../../core/model/walk';
import type { Entity } from '../Entity';
import type { AnimationSystem } from './AnimationSystem';
import type { Input } from './Input';
import { bodyOf, type CollisionPair, type CollisionSource, type Contact, pairKey } from './solids';
/** What one node last handed on, and when – the graph tab shows the newest of every instance. */
export interface Produced {
time: number;
values: Record<string, Value>;
}
export interface Instance {
entity: Entity;
interpreter: GraphInterpreter;
/** Recency of exec edges and nodes, keyed by id, in world time. */
trace: { edges: Map<string, number>; nodes: Map<string, number> };
values: Map<Graph, Map<string, Produced>>;
/** Current state per state machine – this instance's alone, like its variables. */
states: Record<string, string>;
/** Whether the behavior was effectively on when last looked at; a change fires On enable/On disable. */
active: boolean;
/**
* Set on a behavior an `On behavior` node listens to (#294): it runs on the same entity as the
* one holding that node, which hears every pulse of it that reaches the end node.
*/
host?: { instance: Instance; nodeId: string; behaviorId: string };
/** Its own variables, where it has a host; the entity's belong to the behavior placed on it. */
variables?: PropertyBag;
}
/** One event node about to run for one instance: what the event hands it, and the output it leaves by. */
export interface Pulse {
instance: Instance;
node: GraphNode;
outputs?: Record<string, Value>;
port?: string;
}
/** A collision pair seen from one behavior's body: its own collider and the one across. */
interface Side {
pair: CollisionPair;
own: Entity;
other: Entity;
}
/** Every node of one event type in these instances, in hierarchy and then node order. */
export function pulsesOf(
instances: readonly Instance[],
type: string,
outputs: Record<string, Value> = {},
filter?: (node: GraphNode) => boolean,
): Pulse[] {
return instances.flatMap((instance) =>
instance.interpreter
.eventNodes(type)
.filter((node) => !filter || filter(node))
.map((node) => ({ instance, node, outputs })),
);
}
/**
* The press plus whatever repeats holding the key has earned. Each node keeps its own hold clock
* in `nodeState`, so two nodes on the same key repeat on their own schedules.
*/
export function keyDownPulses(instance: Instance, node: GraphNode, input: Input, dt: number): Pulse[] {
const i = instance.interpreter;
const key = keyName(textOf(node.params.key ?? ''));
const stored = i.nodeState.get(node.id);
const { state, fires } = stepKeyRepeat(
stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null,
{
pressed: input.justPressed.has(key),
down: input.heldKeys.has(key),
dt,
// The node asks for milliseconds so the fields stay whole numbers; the clock runs in seconds.
after: asNumber(node.params.repeatAfter ?? 0) / 1000,
interval: asNumber(node.params.repeatInterval ?? 0) / 1000,
},
);
if (state) i.nodeState.set(node.id, { held: state.held, repeats: state.repeats });
else i.nodeState.delete(node.id);
return Array.from({ length: fires }, () => ({ instance, node }));
}
export function timerPulses(instance: Instance, dt: number): Pulse[] {
const i = instance.interpreter;
const pulses: Pulse[] = [];
for (const node of i.eventNodes('OnTimer')) {
const state = i.nodeState.get(node.id) ?? { elapsed: 0 };
i.nodeState.set(node.id, state);
const interval = Math.max(0.01, Number(node.params.interval ?? 1));
state.elapsed = (state.elapsed ?? 0) + dt;
// What is left after taking one interval is how long ago this tick was due. When one frame
// catches up several ticks, the first one was due longest ago and is the latest.
while (state.elapsed >= interval) {
state.elapsed -= interval;
pulses.push({ instance, node, outputs: { late: state.elapsed } });
}
}
return pulses;
}
/**
* The collision pairs of one kind that concern this behavior: every pair in which one collider
* belongs to the behavior's body (its owner, or itself when it has none), with `other` being the
* body on the far side.
*/
function sides(instance: Instance, pairs: readonly CollisionPair[]): Side[] {
const body = instance.entity.parent ?? instance.entity;
return pairs.flatMap((pair) => {
const own = bodyOf(pair.a) === body ? pair.a : bodyOf(pair.b) === body ? pair.b : null;
return own ? [{ pair, own, other: own === pair.a ? pair.b : pair.a }] : [];
});
}
/** Reads an event node's "per cell" input for its instance (#160). */
export type PerCell = (instance: Instance, node: GraphNode) => boolean;
/**
* What of the pairs one node hears. A trigger tile is told twice, per cell and per area (#160), and
* the node's "per cell" input picks one; it is only read where a tile is among the pairs at all.
*/
function heardBy(instance: Instance, node: GraphNode, all: Side[], perCell: PerCell): Side[] {
if (!all.some((side) => side.pair.tile)) return all;
const cells = perCell(instance, node);
return all.filter((side) => !side.pair.tile || side.pair.tile.perCell === cells);
}
export function collisionEnterPulses(
instance: Instance,
collisions: CollisionSource | null,
perCell: PerCell,
): Pulse[] {
const entered = sides(instance, collisions?.enters ?? []);
return instance.interpreter.eventNodes('OnCollisionEnter').flatMap((node) =>
heardBy(instance, node, entered, perCell).map((side) => ({
instance,
node,
outputs: sideOutputs(side, side.pair),
})),
);
}
/**
* Stay fires every frame or, with an interval, once each time that long has passed since the pair
* met (#149). Each stay node keeps a clock per pair in its node state, and a pair that is gone
* takes its clock with it.
*/
export function collisionStayPulses(
instance: Instance,
collisions: CollisionSource | null,
dt: number,
perCell: PerCell,
): Pulse[] {
if (!collisions) return [];
const interpreter = instance.interpreter;
const stays = sides(instance, collisions.stays);
const contacts = new Map<CollisionPair, Contact | null>();
const contactOf = (pair: CollisionPair) => {
if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair));
return contacts.get(pair)!;
};
const pulses: Pulse[] = [];
for (const node of interpreter.eventNodes('OnCollisionStay')) {
const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0;
const clocks = interpreter.nodeState.get(node.id) ?? {};
const running: Record<string, number> = {};
for (const side of heardBy(instance, node, stays, perCell)) {
let fires = 1;
if (interval > 0) {
const key = pairKey(side.pair);
let elapsed = (clocks[key] ?? 0) + dt;
for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval;
running[key] = elapsed;
}
for (let i = 0; i < fires; i++)
pulses.push({ instance, node, outputs: sideOutputs(side, contactOf(side.pair)) });
}
if (interval > 0) interpreter.nodeState.set(node.id, running);
else interpreter.nodeState.delete(node.id);
}
return pulses;
}
export function collisionExitPulses(
instance: Instance,
collisions: CollisionSource | null,
perCell: PerCell,
): Pulse[] {
const left = sides(instance, collisions?.exits ?? []);
return instance.interpreter
.eventNodes('OnCollisionExit')
.flatMap((node) =>
heardBy(instance, node, left, perCell).map((side) => ({ instance, node, outputs: sideOutputs(side) })),
);
}
/**
* 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, 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 ?? '');
return finished
.filter(({ animationId }) => wanted === '' || wanted === animationId)
.map(({ entity }) => ({ instance, node, outputs: { object: entity } }));
});
}
/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */
function sideOutputs({ own, other, pair }: Side, contact?: Partial<Contact> | null): Record<string, Value> {
const outputs: Record<string, Value> = {
other: bodyOf(other),
otherCollider: other,
collider: own,
tile: pair.tile?.tileId ?? '',
cellX: pair.tile?.cell.x ?? -1,
cellY: pair.tile?.cell.y ?? -1,
};
if (contact !== undefined) {
// The pair's normal points towards `a`; seen from `b` it points the other way.
const sign = own === pair.a ? 1 : -1;
outputs.normalX = sign * (contact?.normal?.x ?? 0) || 0;
outputs.normalY = sign * (contact?.normal?.y ?? 0) || 0;
outputs.impulse = contact?.impulse ?? 0;
outputs.pointX = contact?.point?.x ?? 0;
outputs.pointY = contact?.point?.y ?? 0;
}
return outputs;
}