Files
feuerwerk/src/core/graph/NodeContext.ts
T
adminandClaude Opus 5.5 5c0d52da66
feuerwerk / gate (push) Successful in 8m24s
feuerwerk / suite (push) Successful in 20m19s
Let a behavior judge a key press against the beat of the music (#438)
A rhythm game has to know how far a press lay from the beat. Input only
kept that a key went down during a frame, so a press was known to a
frame (16.7 ms) at best. Hit windows are ±20 to 50 ms.

- Input and the game pointer keep the time stamp of each real press.
  Auto repeat of a held key does not count.
- The new event node On key hit hears a key or a click. It gives the
  offset to the nearest point of the grid (beat, 1/8 or 1/16), with
  bar, beat and the heard second. A calibration input moves the press.
- The SoundLibrary maps the press onto the heard song position through
  getOutputTimestamp, minus the limiter. Safari lacks it, there the
  audio clock minus the latency is wound back to the press.
- Playbacks.times takes the heard second as a parameter, so the song
  position can be asked for a moment in the past.
- A pattern with swing puts its grid points where the swing moves the
  notes. The end of a pattern without loop is no grid point.
- A press older than 0.25 s when its frame comes is dropped, which
  covers presses during the toolbar pause. Exactly half way between two
  points counts as late for the first one.
- Without music playing no event comes, as decided in the issue.
- The 400 step test in tilemap-patch.test.ts gets a 30 s limit. It took
  7.45 s in gate 761 while the suite shared the cores of stein2.

Tests: 23 new unit tests (key-hit 19, sound-library 1, audio-switch 1,
catalog and event count). 23 mutations, all red. e2e 65 ALL PASSED,
79 checks. In the new 65k7 three presses of H in one frame lie 4.4 and
4.1 ms apart, and so do their offsets. Without the time stamps all
three get the same offset and only 65k7 goes red. Gate 5251 of 5252
green, the one red count in event-order.test.ts is fixed and checked
on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:54:55 +01:00

466 lines
21 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.
import type { AnimationPlayer, EaseShare } from '../model/animation';
import type { CompositionPlayer } from '../model/composition';
import type { Mat } from '../model/matrix';
import type { Position, PropertyBag } from '../model/Properties';
import type { FxParams } from '../soundfx/nodes';
import type { HeardPosition, Quantize } from '../music/beats';
import type { RecordingSettings } from '../recording/recording';
import type { VideoReading, VideoSteer } from '../model/videoClock';
import { type ChainTransform, composeAlongChain } from '../model/walk';
import { enabledAlongChain } from '../properties/enabled';
import { localMatrixOf, positionOf, transformOf } from '../properties/transform';
/**
* Marks a live entity apart from a struct value: both reach a data edge as an object. A symbol
* rather than a field, so no struct field name can impersonate it – and it never serializes,
* which is right for a thing that exists only while the game runs.
*/
export const ENTITY_HANDLE: unique symbol = Symbol('entity handle');
/**
* What a graph sees of a running entity; the runtime's Entity satisfies this shape. Mutating
* `x`/`y`/`props` is how actions take effect.
*/
/**
* A sound, note or pattern started in time with a playing song or pattern (#338), a stinger: on the
* spot `quantize` names of the playing numbered `playback`; 0 for none, which starts it at once.
*/
export interface MusicSync {
playback: number;
quantize: Quantize;
}
export interface EntityHandle {
readonly [ENTITY_HANDLE]: true;
id: string;
assetId: string;
name: string;
/** Position local to `parent` (world position for top-level entities). */
x: number;
y: number;
z: number;
props: PropertyBag;
parent: EntityHandle | null;
/**
* Counts the Emit pulses a particle source has been given. Kept off `props` for the reason the
* animation players are: what the world writes back to the model must not grow an override per frame.
*/
emits?: number;
/** What `Play animation` started on this object (see `model/animation.ts`). */
animations?: AnimationPlayer[];
/** What `Play composition` started on a placed composition (see `model/composition.ts`). */
composition?: CompositionPlayer;
}
/** Position, rotation, scale and mirror of every ancestor applied, nearest last. */
export function worldTransformOf(handle: EntityHandle): ChainTransform {
return composeAlongChain(
handle,
(h) => h.parent,
(h) => ({ m: localMatrixOf(h, transformOf(h.props)), z: h.z }),
);
}
export function worldMatrixOf(handle: EntityHandle): Mat {
return worldTransformOf(handle).m;
}
export function worldPositionOf(handle: EntityHandle): Position {
return positionOf(worldTransformOf(handle));
}
/** Whether a handle and every ancestor of it are switched on. */
export function isHandleEnabled(handle: EntityHandle): boolean {
return enabledAlongChain(
handle,
(h) => h.props,
(h) => h.parent,
);
}
/** Position in world units. */
export interface PointerState {
x: number;
y: number;
inside: boolean;
down: boolean;
pressed: boolean;
/** When it was pressed, in milliseconds of `performance.now()`; null where nobody said (#438). */
pressedAt?: number | null;
released: boolean;
/** How far the wheel turned this tick, in notches, positive downwards (see `wheelNotches`). */
wheel: number;
}
/**
* What is shared for the length of one run: the values of each Variables asset, keyed by its id,
* and the current state of each `global` machine. Two Variables assets may declare the same name
* without meeting, since a node names the asset it reads (see `graph/sharedState.ts`).
*/
export interface SharedState {
variables: Record<string, PropertyBag>;
states: Record<string, string>;
}
/**
* What a graph can do to the played game's physics (#147). The object is the one a rigidbody sits
* on, or the rigidbody itself; only a dynamic body is acted on or read, and each call answers
* whether there was one. Vectors are world units, turning is degrees the way rotation counts.
*/
export interface PhysicsControl {
velocityOf(target: EntityHandle): { x: number; y: number; angular: number } | null;
setVelocity(target: EntityHandle, x: number, y: number): boolean;
setAngularVelocity(target: EntityHandle, degrees: number): boolean;
/** World units × kg per second: the velocity changes by impulse ÷ mass. */
applyImpulse(target: EntityHandle, x: number, y: number): boolean;
/** World units per second squared, for the rest of the run. */
setGravity(x: number, y: number): void;
/**
* How far a hinge has turned (degrees) or a slider has glided (world units) from how it was placed,
* and how fast; null for any other joint, for one that holds nothing, and for anything else (#154).
*/
jointStateOf(target: EntityHandle): { value: number; speed: number } | null;
}
/**
* What a move with collision met: which of the object's own axes something stood against, whether
* it hit anything, and the world normal of the first contact (pointing away from what was hit).
*/
export interface MoveOutcome {
blockedX: boolean;
blockedY: boolean;
hit: boolean;
normal: { x: number; y: number };
}
/** What a ray met (#153): the object and the collider (or tilemap), where, the face and how far. */
export interface RayOutcome {
object: EntityHandle;
collider: EntityHandle;
point: { x: number; y: number };
normal: { x: number; y: number };
distance: number;
}
/**
* Everything the graph does to the world goes through here, so the core stays free of runtime
* imports. Implemented by the runtime (BehaviorSystem) and by test doubles.
*/
export interface NodeContext {
self: EntityHandle;
/** The behavior's parent object – the one the graph acts on. */
owner: EntityHandle | null;
variables: PropertyBag;
/**
* The current state of each state machine this instance has set, keyed by machine asset id,
* as the state node's id. A machine not in here is in no state: `State branch` follows nothing.
*/
states: Record<string, string>;
shared: SharedState;
/**
* A machine moved from one state to another – what `On state exited` / `On state entered` are
* fired from. Reported rather than fired on the spot: the events belong after the exec flow that
* caused them, never in the middle of it (see `BehaviorSystem`).
*/
stateChanged(machineId: string, from: string, to: string): void;
/** Seconds since the world started and since the last tick. */
time: number;
dt: number;
findByName(name: string): EntityHandle | null;
/** Whether the object is still in the game – false once destroyed, or once its world has ended. */
isAlive(target: EntityHandle): boolean;
/** The object a reference on `from` names (#258, see `model/objectRefs.ts`); null when there is none. */
findReferenced(from: EntityHandle, ref: string): EntityHandle | null;
/** The nearest object called `name` below `parent` – a child before a grandchild; null for none. */
childByName(parent: EntityHandle, name: string): EntityHandle | null;
/** Every object in the game right now, in scene order – what the plural lookups filter (#259). */
objects(): readonly EntityHandle[];
isKeyDown(key: string): boolean;
/** Whether the key went down / came up in this frame – the edges `On key down` and `On key up` fire on. */
wasKeyPressed(key: string): boolean;
wasKeyReleased(key: string): boolean;
pointer: PointerState;
/**
* The mouse as `camera` sees it (#304): world units through that camera, and whether the mouse is
* on its picture. The main camera gives `pointer`; a camera not drawn is never under the mouse.
*/
pointerThrough(camera: EntityHandle): { x: number; y: number; inside: boolean };
/**
* Whether a world point lies on `target`: on its colliders when it has any, otherwise on its own
* footprint or that of anything parented below it.
*/
hitTest(target: EntityHandle, x: number, y: number): boolean;
/** A collider entity stands for itself; any other entity for its direct collider children. */
isTouching(a: EntityHandle, b: EntityHandle): boolean;
/**
* Moves `target` by (dx, dy) in local units; its solid colliders stop flush at other solid ones
* and the rest of the move goes on along the surface, less what friction takes.
*/
moveAndSlide(target: EntityHandle, dx: number, dy: number): MoveOutcome;
/**
* The first solid a ray from (x, y) along (dx, dy) meets within `length`, all in world units;
* null for none. `ignore` and whatever is parented below it are not met, and neither is anything
* off the layers of `layers` (a 16-bit mask, #158).
*/
raycast(
from: { x: number; y: number },
direction: { x: number; y: number },
length: number,
options: { ignore: EntityHandle | null; triggers: boolean; layers: number },
): RayOutcome | null;
/**
* Everything that ray meets, nearest first, rather than only the first of them (#157). One entry
* per collider and one per tilemap cell; `maxHits` above 0 keeps that many of the nearest.
*/
raycastAll(
from: { x: number; y: number },
direction: { x: number; y: number },
length: number,
options: { ignore: EntityHandle | null; triggers: boolean; layers: number; maxHits: number },
): RayOutcome[];
/**
* The number of the collision layer `target` is on (#211): a collider's or tilemap's own, any
* other object's that of its first collider child; null for an object without a collider.
*/
collisionLayerOf(target: EntityHandle): number | null;
/**
* The number of the render layer `target` is drawn on (#305): its own choice, else its parent's,
* at the top 0; null for an object that is not drawn.
*/
renderLayerOf(target: EntityHandle): number | null;
/** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */
ignoreOneWay(target: EntityHandle, seconds: number): void;
/** Whether `target` is a placed tilemap – what the tile nodes act on (#135). */
isTilemap(target: EntityHandle): boolean;
/**
* The tile id in a cell of a placed tilemap, `''` for an empty one; null when `target` is no
* tilemap, has no layer of that name (`''` = its first) or the cell lies outside its grid.
*/
tileAt(target: EntityHandle, layer: string, cell: { x: number; y: number }): string | null;
/**
* Puts a tile into that cell, `''` empties it; false where `tileAt` answers null or the id is no
* tile. Only this placement changes, and only for the run (see `model/liveTilemap.ts`).
*/
setTile(target: EntityHandle, layer: string, cell: { x: number; y: number }, tileId: string): boolean;
/** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */
physics?: PhysicsControl;
log(message: string): void;
/**
* Plays, pauses or seeks the video the sprite `target` wears (#410). A free one gets a player of its
* own from then on; a driven one, and the editor run, are left alone and say so in the log.
*/
steerVideo(target: EntityHandle, steer: VideoSteer): void;
/** Where the video the sprite `target` wears stands (#410); null for none. */
videoReading(target: EntityHandle): VideoReading | null;
/**
* Starts a sound asset, named by its id. `volume` multiplies the asset's own (1 leaves it alone);
* `loop` is the caller's answer to whether it repeats, and `undefined` leaves that to the asset,
* because the asset says what a sound *is* and the node only colours it. Every call is a voice of
* its own: two plays overlap, whether they loop or not, which is what makes one sound effect
* fired twice in a row audible twice.
*
* The voice belongs to the behavior that started it and ends when that behavior is destroyed –
* a sound is something a thing in the world is doing. `detached` gives that up on purpose, for
* the one case where the rule is in the way: the death sound of an object that destroys itself.
* `fx` names a sound FX asset the voice is played through (#311); `''` the one the sound asset
* names (#352), `NO_FX` none.
* `fxParams` are values for its parameters; one not given is the default the FX declares.
* `tempo` is what a Delay synced to beats counts by (#332). `bus` is the bus it is heard on
* (#325): a bus asset or `MASTER_BUS`, `''` for the one the sound asset names. `sync` starts it in
* time with a playing (#338).
* Returns the number of the voice for `tuneSound`, or 0 where nothing is played.
*/
playSound(
assetId: string,
volume: number,
loop: boolean | undefined,
detached: boolean,
fx: string,
fxParams: FxParams,
tempo: number,
bus: string,
sync: MusicSync,
): number;
/**
* Plays one note of an instrument asset (#331): `note` as MIDI counts it (60 is C4), struck with
* `velocity` (0 to 1, how loud it is next to `volume`) and released after `duration` seconds, or
* held until it is stopped where that is 0. Owned like a `playSound` voice, `instrumentParams` are
* values for the instrument's parameters, `tempo` as for `playSound`. `fx` sends the note through a
* sound FX of its own (#346), `fxParams` with values for its parameters, apart from the instrument's;
* `''` the one the instrument names, `NO_FX` none (#352).
* `bus` as for `playSound`, `''` for the one the instrument names, and `sync` too.
* Returns the number of the voice, 0 where nothing plays.
*/
playNote(
instrumentId: string,
note: number,
velocity: number,
volume: number,
duration: number,
detached: boolean,
instrumentParams: FxParams,
tempo: number,
fx: string,
fxParams: FxParams,
bus: string,
sync: MusicSync,
): number;
/**
* Plays a pattern asset through its instrument, at its own tempo, once or round and round, every
* note at `volume`. Owned like a `playSound` voice. `fx` is a sound FX all of it goes through as
* one chain (#346), `''` the one the pattern names, `NO_FX` none (#352); `fxParams` values for its
* parameters, `bus` as for `playSound`. Synced to a playing (#338), it starts on its spot and plays
* at its tempo. Returns its number, for `stopVoice` and `tuneSound`; 0 where there is nothing to play.
*/
playPattern(
patternId: string,
volume: number,
loop: boolean,
detached: boolean,
fx: string,
fxParams: FxParams,
bus: string,
sync: MusicSync,
): number;
/** Stops every playing of that pattern, `''` every pattern; `letRing` lets its notes fall away in their release. */
stopPattern(patternId: string, letRing: boolean): void;
/**
* Plays a song asset (#336) at its tempo, its tracks as they are set: from its start, then round
* the loop of its ruler with `loop`, else once to its end. `volume` (0 to 1) is the level of the
* whole of it, rising there from silence over `fadeIn` seconds. Owned like a `playSound` voice.
* `fx`, `fxParams` and `bus` as for `playPattern`, after its tracks. With `live` it plays its notes
* even where it has a bake, so its tracks can be turned (#338). Returns its number; 0 where there is
* nothing to play.
*/
playSong(
songId: string,
volume: number,
fadeIn: number,
loop: boolean,
detached: boolean,
fx: string,
fxParams: FxParams,
bus: string,
live: boolean,
): number;
/**
* Turns one track of a playing song (#338) to `volumeDb` on top of its own level, over `fade`
* seconds from where `quantize` lands; a playing of its bake has no tracks, which is logged.
*/
setSongTrack(playback: number, track: string, volumeDb: number, fade: number, quantize: Quantize): void;
/**
* Holds the lane `lane` of one track of a playing song (#338) on `value`, in the lane's units,
* reached over `fade` seconds from where `quantize` lands; its points are not heard until it is
* let go. A track without such a lane, or a playing of its bake, is logged.
*/
setSongLane(
playback: number,
track: string,
lane: string,
value: number,
fade: number,
quantize: Quantize,
): void;
/** Lets such a held lane go (#338): back to its curve over `fade` seconds, as soon as can be heard. */
releaseSongLane(playback: number, track: string, lane: string, fade: number): void;
/**
* Plays `songId` in place of a playing song (#338), `''` the same one, from the marker `marker`
* (`''` its start), where `quantize` lands: over `fade` seconds, else in a cut. It keeps its number.
*/
switchSong(playback: number, songId: string, marker: string, quantize: Quantize, fade: number): void;
/** Stops every playing of that song, `''` every song, falling silent over `fadeOut` seconds. */
stopSong(songId: string, fadeOut: number): void;
/** Stops one playing of a song or pattern by its number, falling silent over `fadeOut` seconds. */
stopPlayback(playback: number, fadeOut: number): void;
/**
* How many seconds one run of that sound asset lasts, so a change can span it; 0 where it does not
* exist or has not been loaded yet.
*/
soundLength(assetId: string): number;
/** How many seconds that voice rings on after its sound ends: its echoes (#324); 0 for none. */
soundTail(voice: number): number;
/**
* Silences every voice of that sound; `''` silences everything this run started. `letRing` stops
* the sound and lets its echoes fade (#324).
*/
stopSound(assetId: string, letRing: boolean): void;
/** Silences one voice, by the number `playSound` gave it. */
stopVoice(voice: number, letRing: boolean): void;
/**
* Hands new values to the sound FX parameters of one voice, by the number `playSound` gave it
* (#315), so a running sound follows the game. A voice that has ended is not reached. `time` is
* how many seconds the values take to get there, on the audio clock and along `ease`; 0 is a
* quick glide.
*/
tuneSound(voice: number, fxParams: FxParams, time: number, ease: EaseShare): void;
/**
* The same for the sound FX of a bus (#325): a bus asset or `MASTER_BUS`. A bus that is not there
* is not reached.
*/
tuneBus(bus: string, fxParams: FxParams, time: number, ease: EaseShare): void;
/**
* Sets the volume of a bus (#325), `''` the master, over `time` seconds along `ease`. It holds
* for the rest of the run, over the volume of its asset and across scenes.
*/
setBusVolume(bus: string, volume: number, time: number, ease: EaseShare): void;
/** The volume a bus is at, `''` the master: what a game set, else that of its asset; 0 for none. */
busVolume(bus: string): number;
/**
* Where a playing song or pattern stands as it is heard (#338): the one numbered `playback`, else
* the one started last of `song` or `pattern`, of every one where both are `''`; null for none.
*/
songPosition(playback: number | null, song: string, pattern: string): HeardPosition | null;
/**
* Whether the voice or playing numbered `id` is still to be heard (#373): waiting on its files,
* playing, fading or ringing on; false for 0.
*/
playing(id: number): boolean;
/**
* Asks for another scene, by asset id. The swap happens once the frame is over – a graph must be
* able to finish the flow it is in, and half a frame in the old world and half in the new one is
* the kind of thing nobody can debug. The last request of a frame is the one that happens.
*/
loadScene(sceneId: string): void;
/**
* The position is local to `parent` (world when none); null when the asset does not exist or
* cannot be placed. A spawned behavior runs its `On start` at the end of this tick, before the frame is drawn.
*/
spawn(
assetId: string,
position: { x: number; y: number; z: number },
parent: EntityHandle | null,
): EntityHandle | null;
/**
* Places the joint asset on `owner` at `at` of that object and connects it to `connected`, the
* scene when null (#154). Null when the asset is no joint or the object is gone.
*/
addJoint(
assetId: string,
owner: EntityHandle,
connected: EntityHandle | null,
at: { x: number; y: number },
): EntityHandle | null;
/**
* Removes `target` with everything below it. With `keepChildren` only the object goes: its
* children move up to its parent where they stand, and are answered, nearest level only.
*/
destroy(target: EntityHandle, keepChildren: boolean): EntityHandle[];
stop(): void;
/** Ends the run and starts it again from the saved scene (everything spawned is lost). */
restart(): void;
/** The global time scale the run goes at (#167): 1 is real time, 0 frozen. */
timeScale(): number;
/** For the rest of the run, the next scene included; Play and Stop set it back to 1. */
setTimeScale(scale: number): void;
/** Whether a behavior paused the game – not the editor's own pause, which runs no graph at all. */
isPaused(): boolean;
/** From the next frame on, only behaviors that run while paused are ticked. */
setPaused(paused: boolean): void;
/** Starts recording the game view (#405); where it cannot, the log says why. */
startRecording(settings: RecordingSettings): void;
/** Ends the recording and saves it; without one, only the log hears of it. */
stopRecording(): void;
/** Pauses the editor's Play after this frame, as its pause button does; elsewhere nothing. */
breakpoint(): void;
}