/** * 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 { playedSoundById, playedSoundOf } from '../../core/properties/playedSound'; 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 { instrumentBuildFor, instrumentSourcesOf, throughScopedFx } from '../../core/instrument/compile'; import { VELOCITY_PARAM } from '../../core/instrument/nodes'; import { heldNote } from '../../core/instrument/notes'; import { clipFx, fxVoiceOf, sequenceNeeds } from '../../core/music/needs'; import { patternInstrument, patternLength, patternSequence, patternTempo } from '../../core/music/pattern'; import type { ScheduledClip, ScheduledNote } from '../../core/music/Scheduler'; import { dbToGain, songLength, songLoop, songSequence, songTempo, SOUND_ITEM_MAX_DB, } from '../../core/music/song'; import { songBuses } from '../../core/music/songBuses'; import { DRY_VOICE, type FxParams, type FxVoice } from '../../core/soundfx/nodes'; import type { Entity } from '../Entity'; import { PhysicsSystem } from '../physics/PhysicsSystem'; import type { World } from '../World'; import type { AudioSink, NoteTiming } 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, tempo) => playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams, tempo), playNote: (instrumentId, note, velocity, volume, duration, detached, params, tempo, fx, fxParams) => playNote( run, instrumentId, { note, velocity, volume, duration }, detached ? '' : entity.id, params, { tempo }, fxVoiceOf(project, fx, tempo, fxParams), ), playPattern: (patternId, volume, loop, detached, fx, fxParams) => playPattern(run, patternId, volume, loop, detached ? '' : entity.id, fx, fxParams), stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing), playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams) => playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id, fx, fxParams), stopSong: (songId, fadeOut) => run.audio.stopSequence(songVoiceName(songId), false, heldFade(fadeOut)), stopPlayback: (playback, fadeOut) => run.audio.stopPlayback(playback, heldFade(fadeOut)), soundLength: (assetId) => { const file = playedSoundById(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. soundTail: (voice) => run.audio.tail(voice), stopSound: (assetId, letRing) => { const file = playedSoundById(project, assetId); if (assetId === '' || file) run.audio.stop(file, letRing); }, stopVoice: (voice, letRing) => run.audio.stopVoice(voice, letRing), tuneSound: (voice, fxParams, time, ease) => run.audio.tune(voice, withScoped(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, tempo: number, ): 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( playedSoundOf(project, asset), level, loop ?? loopsOf(props), owner, plan ? { plan, params: fxParams, tempo } : DRY_VOICE, ); } /** * One note of an instrument (#331), as the sink understands it: the layers the instrument makes of * it, the velocity as loudness and as the value of its `velocity`, and the voice's volume held to * 0..1 like a sound's. An instrument that plays nothing for that note plays nothing at all. * `timing` says when it starts and by which tempo (#332). `fx` is a sound FX of the note's own * (#346), its parameters in `FX_SCOPE`. */ export function playNote( { project, audio }: Pick, instrumentId: string, struck: { note: number; velocity: number; volume: number; duration: number }, owner: string, params: FxParams, timing: NoteTiming = {}, fx: FxVoice | null = null, ): number { const asset = findAssetOf(project, instrumentId, 'instrument'); if (!asset) return 0; const built = instrumentBuildFor(asset, heldNote(struck.note), instrumentSourcesOf(project)).layers; if (built.length === 0) return 0; const layers = fx ? built.map((layer) => throughScopedFx(fx.plan, layer, FX_SCOPE)) : built; const velocity = Number.isFinite(struck.velocity) ? Math.max(0, Math.min(1, struck.velocity)) : 1; const level = Math.max(0, Math.min(1, struck.volume * velocity)); const duration = Number.isFinite(struck.duration) ? Math.max(0, struck.duration) : 0; const values = { ...params, [VELOCITY_PARAM]: velocity, ...scoped(fx?.params ?? {}) }; return audio.playNote(instrumentVoiceName(instrumentId), layers, values, level, owner, duration, timing); } /** Where the parameters of a note's own sound FX go by (#346): no name of a parameter has a colon. */ export const FX_SCOPE = 'fx:'; function scoped(params: FxParams): FxParams { return Object.fromEntries(Object.entries(params).map(([key, value]) => [`${FX_SCOPE}${key}`, value])); } /** * Values handed to a playing voice: for a note with a sound FX of its own (#346) they reach both its * instrument and its FX, a name both have changes both. Anything else has no scoped names to reach. */ function withScoped(params: FxParams): FxParams { return { ...params, ...scoped(params) }; } /** What a note's voice is named by, where a sound's is named by its file. */ export function instrumentVoiceName(instrumentId: string): string { return `instrument:${instrumentId}`; } /** * A pattern played by a game: its notes through its instrument at its tempo, the whole of it once or * round and round – the loop set on its ruler is for editing. Its notes take the volume and the owner * of the playing. Returns the number of the playing, 0 where there is no pattern or nothing to hear. */ function playPattern( { project, audio }: Pick, patternId: string, volume: number, loop: boolean, owner: string, fxId: string, fxParams: FxParams, ): number { const asset = findAssetOf(project, patternId, 'pattern'); if (!asset) return 0; const sequence = patternSequence(asset); const instrument = findAssetOf(project, patternInstrument(asset.props), 'instrument'); if (!instrument || sequence.notes.length === 0) return 0; const level = heldLevel(volume); const length = patternLength(asset); const tempo = patternTempo(asset.props); const fx = fxVoiceOf(project, fxId, tempo, fxParams); return audio.playSequence( patternVoiceName(patternId), { sequence, tempo, length, loop: loop ? { start: 0, end: length } : null, needs: sequenceNeeds(project, sequence), ...(fx && { fx }), strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner), }, owner, ); } /** * A song played by a game (#336): its tracks at their level, pan, mute and solo, at its tempo. With * `loop` it plays from its start and then runs round the loop of its ruler, else once to its end. * `volume` and `fadeIn` are the level of the whole playing. Returns the number of the playing, 0 * where there is no song or nothing in it to play. */ function playSong( { project, audio }: Pick, songId: string, volume: number, fadeIn: number, loop: boolean, owner: string, fxId: string, fxParams: FxParams, ): number { const song = findAssetOf(project, songId, 'song'); if (!song) return 0; const sequence = songSequence(project, song); const tempo = songTempo(song.props); const fx = fxVoiceOf(project, fxId, tempo, fxParams); return audio.playSequence( songVoiceName(songId), { sequence, tempo, length: songLength(song), loop: loop ? songLoop(song) : null, needs: sequenceNeeds(project, sequence), volume: heldLevel(volume), fadeIn: heldFade(fadeIn), buses: songBuses(project, song), ...(fx && { fx }), strike: (note) => strikeNote({ project, audio }, note, owner), strikeClip: (clip) => playClip({ project, audio }, clip), }, owner, ); } /** A note the scheduler planned (#332), struck through its instrument at its volume, on its bus. */ export function strikeNote( sources: Pick, note: ScheduledNote, owner: string, ): number { const struck = { note: note.note, velocity: note.velocity, volume: note.volume ?? 1, duration: note.duration, }; const timing = { at: note.at, tempo: note.tempo, ...(note.bus && { bus: note.bus }) }; return playNote(sources, note.instrument, struck, owner, {}, timing); } /** A clip of a song (#334): its sound at its own volume times the item's, through its sound FX. */ export function playClip( { project, audio }: Pick, clip: ScheduledClip, ): number { const asset = findAssetOf(project, clip.sound, 'sound'); if (!asset?.file) return 0; const volume = Math.max( 0, Math.min(dbToGain(SOUND_ITEM_MAX_DB), volumeOf(resolveAssetProps(project, asset)) * clip.volume), ); return audio.playClip( playedSoundOf(project, asset), { at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus }, clipFx(project, clip.fx, clip.tempo), ); } function heldLevel(volume: number): number { return Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1; } function heldFade(seconds: number): number { return Number.isFinite(seconds) ? Math.max(0, seconds) : 0; } /** `pattern:` alone stands for every pattern (see `goesBy`). */ export function patternVoiceName(patternId: string): string { return `pattern:${patternId}`; } /** `song:` alone stands for every song. */ export function songVoiceName(songId: string): string { return `song:${songId}`; } /** 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; }, }; }