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>
288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
/**
|
||
* 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;
|
||
},
|
||
};
|
||
}
|