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>
250 lines
9.8 KiB
TypeScript
250 lines
9.8 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|