Files
feuerwerk/src/runtime/systems/behaviorContext.ts
T
adminandClaude Opus 5.5 804a97b0a9
game-maker / gate (push) Successful in 3m12s
game-maker / suite (push) Failing after 26m24s
Hand out the length of a played sound from Play sound
Wired into the time of Set sound FX parameters, a change then spans
exactly one run of the clip, so a fade or a filter sweep ends with its
last sample.

- Play sound has a second output, length, in seconds. It is 0 where
  nothing was played or the sound has not loaded yet.
- The AudioSink gives the length of a file, the SoundLibrary takes it
  from the decoded buffer. A pitch the voice starts on keeps it (#312).
- A pitch changed while the voice plays still changes its length, so a
  pitch sweep over length does not end with the sound. A pitch that
  keeps the length in real time is #317.

Tests:
- soundfx, sounds, keep-on-scene-load, node-catalog, sound-library:
  462 tests green.
- Counter-check: with length always 0, the catalog case and the new
  soundfx case fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:27:44 +02:00

288 lines
12 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.
/**
* What a behavior's graph reaches of the running game, as the `NodeContext` its nodes are handed:
* the world, the keyboard, collisions and rays, physics, sound, the clock and the shared values.
* Handles arriving from a graph are resolved to the world's entities here, so no node holds one.
*/
import type { EntityHandle, NodeContext, PhysicsControl, SharedState } from '../../core/graph';
import { keyName } from '../../core/keys';
import { collisionFilterOf } from '../../core/model/collisionLayers';
import type { Vec2 } from '../../core/model/Properties';
import { JOINT_CONNECTED_KEY } from '../../core/model/joint';
import { objectReference } from '../../core/model/objectRefs';
import { findAsset, findAssetOf, findScene, type Project } from '../../core/model/Project';
import { renderLayerAlong } from '../../core/model/renderLayers';
import { soundFileOf } from '../../core/model/sound';
import { hasRenderLayer } from '../../core/properties/PropertySchema';
import { resolveAssetProps } from '../../core/properties/resolve';
import { loopsOf, volumeOf } from '../../core/properties/sounds';
import { soundFxBuildFor } from '../../core/soundfx/compile';
import { DRY_VOICE, type FxParams } from '../../core/soundfx/nodes';
import type { Entity } from '../Entity';
import { PhysicsSystem } from '../physics/PhysicsSystem';
import type { World } from '../World';
import type { AudioSink } from './Audio';
import type { Instance } from './behaviorPulses';
import type { Input } from './Input';
import { bodyOf, type CollisionSource, layerHolderOf, NOT_BLOCKED } from './solids';
export interface TimeScaled {
scale: number;
}
/** What the running `BehaviorSystem` lends every context it makes. */
export interface ContextSources {
project: Project;
input: Input;
collisions: CollisionSource | null;
audio: AudioSink;
clock: TimeScaled | null;
shared: SharedState;
/** A graph moved one of this instance's machines; its events follow between exec flows. */
stateChanged(machineId: string, from: string, to: string): void;
}
const NO_DYNAMIC_BODY =
'has no dynamic rigidbody, so Apply force, Apply impulse and Set velocity do nothing on it.';
export function behaviorContext(
instance: Instance,
world: World,
dt: number,
run: ContextSources,
): NodeContext {
const { entity, states } = instance;
const { project, collisions, clock } = run;
// Handles arriving from the graph are the entities themselves, or test doubles with the same id.
const resolve = (handle: EntityHandle | null): Entity | undefined =>
handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined;
return {
self: entity,
owner: entity.parent,
variables: instance.variables ?? entity.props,
states,
shared: run.shared,
stateChanged: (machineId, from, to) => run.stateChanged(machineId, from, to),
time: world.time,
dt,
findByName: (name) => world.findByName(name) ?? null,
// By identity, not by id: a restarted scene builds new entities under the same ids.
isAlive: (target) => world.entities.some((e) => e === target),
findReferenced: (from, ref) => {
const found = resolve(from);
return (found && world.findReferenced(found, ref)) ?? null;
},
childByName: (parent, name) => {
const found = resolve(parent);
return (found && world.childByName(found, name)) ?? null;
},
objects: () => world.entities,
isKeyDown: (key) => run.input.isDown(key),
wasKeyPressed: (key) => run.input.justPressed.has(keyName(key)),
wasKeyReleased: (key) => run.input.justReleased.has(keyName(key)),
pointer: world.pointer,
pointerThrough: (camera) => {
const found = resolve(camera);
const { x, y } = world.pointer;
return found ? world.pointerThrough(found) : { x, y, inside: false };
},
hitTest: (target, x, y) => {
const found = resolve(target);
return Boolean(found && world.hitTest(found, x, y));
},
isTouching: (a, b) => {
const ea = resolve(a);
const eb = resolve(b);
return Boolean(collisions && ea && eb && collisions.touching(world, ea, eb));
},
moveAndSlide: (target, dx, dy) => {
const found = resolve(target);
if (!found) return NOT_BLOCKED;
if (collisions) return collisions.moveAndSlide(world, found, dx, dy);
found.x += dx;
found.y += dy;
return NOT_BLOCKED;
},
raycast: (from, direction, length, { ignore, triggers, layers }) => {
const hit = collisions?.raycast(world, from, direction, length, {
ignore: resolve(ignore) ?? null,
triggers,
layers,
});
world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit));
return hit ? { ...hit, object: bodyOf(hit.collider) } : null;
},
raycastAll: (from, direction, length, { ignore, triggers, layers, maxHits }) => {
const hits =
collisions?.raycastAll(
world,
from,
direction,
length,
{ ignore: resolve(ignore) ?? null, triggers, layers },
maxHits,
) ?? [];
// Drawn to the last thing it met, not to the first: that is how far this ray looked.
const last = hits[hits.length - 1];
const end = last ? last.point : pointAlong(from, direction, length);
world.noteRay(from, end, hits.length > 0);
return hits.map((hit) => ({ ...hit, object: bodyOf(hit.collider) }));
},
collisionLayerOf: (target) => {
const found = resolve(target);
const holder = found && layerHolderOf(world, found);
return holder ? collisionFilterOf(holder.props).layer : null;
},
renderLayerOf: (target) => {
const found = resolve(target);
if (!found || !hasRenderLayer(found.type)) return null;
return renderLayerAlong(
found,
(at) => at.props,
(at) => at.parent ?? null,
);
},
ignoreOneWay: (target, seconds) => {
const found = resolve(target);
if (found) world.ignoreOneWay(found, seconds);
},
isTilemap: (target) => {
const found = resolve(target);
return found ? world.isTilemap(found) : false;
},
tileAt: (target, layer, cell) => {
const found = resolve(target);
return found ? world.tileAt(found, layer, cell) : null;
},
setTile: (target, layer, cell, tileId) => {
const found = resolve(target);
return found ? world.setTile(found, layer, cell, tileId) : false;
},
physics: collisions instanceof PhysicsSystem ? physicsControl(world, collisions, resolve) : undefined,
log: (message) => {
world.log.push(message);
// The `Log` node's whole purpose: the author wants this in the browser console.
// eslint-disable-next-line no-console
console.log(`[behavior ${entity.name}]`, message);
},
playSound: (assetId, volume, loop, detached, fx, fxParams) =>
playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams),
soundLength: (assetId) => {
const file = soundFileOf(project, assetId);
return file ? run.audio.length(file) : 0;
},
// An id nothing resolves stops nothing, rather than silencing the whole run by accident;
// only the empty id means "everything", which is what the node promises.
stopSound: (assetId) => {
const file = assetId === '' ? '' : soundFileOf(project, assetId);
if (assetId === '' || file) run.audio.stop(file);
},
tuneSound: (voice, fxParams, time, ease) => run.audio.tune(voice, fxParams, time, ease),
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
addJoint: (assetId, owner, connected, at) => {
const on = resolve(owner);
if (!on || findAsset(project, assetId)?.type !== 'joint') return null;
const joint = world.spawn(assetId, { ...at, z: 0 }, on);
const to = resolve(connected);
// By id, as a pick in the inspector stores it: the scene view of the run then draws the link too.
if (joint && to) joint.props[JOINT_CONNECTED_KEY] = objectReference(to.objectId);
return joint;
},
destroy: (target, keepChildren) => {
const found = resolve(target);
if (!found) return [];
if (keepChildren) return world.removeKeepingChildren(found);
world.remove(found);
return [];
},
loadScene: (sceneId) => {
// An id naming no scene is dropped here rather than half way through the swap, where the
// run would already have torn down the world it is still standing in.
if (findScene(project, sceneId)) world.loadSceneRequested = sceneId;
else world.log.push(`Load scene: no scene ${sceneId}`);
},
stop: () => {
world.stopRequested = true;
},
restart: () => {
world.restartRequested = true;
},
timeScale: () => clock?.scale ?? 1,
setTimeScale: (scale) => {
if (clock) clock.scale = scale;
else world.log.push(`Set time scale is ignored in the editor run (${scale})`);
},
isPaused: () => world.paused,
setPaused: (paused) => {
world.paused = paused;
},
};
}
/**
* What the graph asked for, as the sink understands it: the asset's own volume scaled by the
* node's, and its loop flag unless the node overrules it either way. The asset is what a sound
* *is* – a node that says nothing therefore plays it as the project describes it. An FX asset that
* does not compile plays the sound dry: a broken effect should not silence the game. Its parameters
* take the values handed in, the others their defaults. Returns the number of the voice, 0 for none.
*/
function playSound(
{ project, audio }: ContextSources,
assetId: string,
volume: number,
loop: boolean | undefined,
owner: string,
fxId: string,
fxParams: FxParams,
): number {
const asset = findAssetOf(project, assetId, 'sound');
if (!asset?.file) return 0;
const props = resolveAssetProps(project, asset);
const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined;
const plan = fx && soundFxBuildFor(fx).compiled;
return audio.play(
asset.file,
level,
loop ?? loopsOf(props),
owner,
plan ? { plan, params: fxParams } : DRY_VOICE,
);
}
/** Where a ray of that length ends; where it has no direction, where it starts. */
function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 {
const d = Math.hypot(direction.x, direction.y);
if (!(d > 0) || !(length > 0)) return from;
return { x: from.x + (direction.x / d) * length, y: from.y + (direction.y / d) * length };
}
/** The physics nodes' way into the played game's engine, with the handles of the graph resolved. */
function physicsControl(
world: World,
physics: PhysicsSystem,
resolve: (handle: EntityHandle | null) => Entity | undefined,
): PhysicsControl {
const act = (handle: EntityHandle, action: (entity: Entity) => boolean): boolean => {
const found = resolve(handle);
if (!found) return false;
if (action(found)) return true;
physics.hint(world, found, NO_DYNAMIC_BODY);
return false;
};
return {
velocityOf: (handle) => {
const found = resolve(handle);
const v = found ? physics.velocityOf(found) : null;
return found && v ? { ...v, angular: physics.angularVelocityOf(found) ?? 0 } : null;
},
setVelocity: (handle, x, y) => act(handle, (e) => physics.setVelocity(e, { x, y })),
setAngularVelocity: (handle, degrees) => act(handle, (e) => physics.setAngularVelocity(e, degrees)),
applyImpulse: (handle, x, y) => act(handle, (e) => physics.applyImpulse(e, { x, y })),
setGravity: (x, y) => physics.setGravity(world, { x, y }),
jointStateOf: (handle) => {
const found = resolve(handle);
return found?.type === 'joint' ? physics.jointStateOf(found) : null;
},
};
}