/** * 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, MusicSync, 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 { findSound } from '../../core/model/sound'; import { renderLayerAlong } from '../../core/model/renderLayers'; import { pickedFx } from '../../core/properties/assetFx'; 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 { instrumentBuildFor, instrumentSourcesOf, throughScopedFx } from '../../core/instrument/compile'; import { VELOCITY_PARAM } from '../../core/instrument/nodes'; import { heldNote } from '../../core/instrument/notes'; import { bakePlayback, validBake } from '../../core/music/bake'; 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 { type AutomationLane, laneOf, laneRange, targetNamed } from '../../core/music/automation'; import { nextSpot, type Quantize, quantizeSpots } from '../../core/music/beats'; import { heldSongMarkers } from '../../core/music/markers'; import { dbToGain, songLength, songLoop, songSequence, songTempo, SOUND_ITEM_MAX_DB, TRACK_MAX_DB, TRACK_MIN_DB, trackBus, } from '../../core/music/song'; import type { LandingAt, SequencePlay } from '../../core/music/playbacks'; import { findSongMarker } from '../../core/properties/assetRef'; import { songBuses } from '../../core/music/songBuses'; import { type MixRoute, playedBus, playedRoute, reachedBus } from '../../core/music/mixer'; 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 { type HeardPlaying, heardPlayings, positionOf } from './musicPulses'; import type { RecordingSink } from '../../core/recording/recording'; import { bodyOf, type CollisionSource, layerHolderOf, NOT_BLOCKED } from './solids'; import type { VideoSystem } from './VideoSystem'; export interface TimeScaled { scale: number; } /** Hears a `Break` node, with the name of the object whose behavior ran it. */ export type Breakpoint = (where: string) => void; /** What the running `BehaviorSystem` lends every context it makes. */ export interface ContextSources { project: Project; input: Input; collisions: CollisionSource | null; /** What counts and steers the videos the sprites wear (#410); absent, nothing does. */ videos?: VideoSystem | null; audio: AudioSink; clock: TimeScaled | null; /** Where a recording of the game view goes (#405); only a played game has one. */ recorder: RecordingSink | null; /** Where `Break` pauses; only the editor's Play has one. */ breakpoint: Breakpoint | 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); }, steerVideo: (target, steer) => { const found = resolve(target); const said = found && run.videos ? run.videos.steer(world, found, steer, entity) : null; if (said) world.log.push(said); }, videoReading: (target) => { const found = resolve(target); return (found && run.videos?.reading(world, found)) ?? null; }, playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus, sync) => playSound( run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams, tempo, bus, syncedStart(run, sync)?.at, ), playNote: ( instrumentId, note, velocity, volume, duration, detached, params, tempo, fx, fxParams, bus, sync, ) => { const synced = syncedStart(run, sync); return playNote( run, instrumentId, { note, velocity, volume, duration }, detached ? '' : entity.id, params, { tempo, bus: instrumentBus(project, instrumentId, bus), ...(synced && { at: synced.at }) }, fxVoiceOf(project, instrumentFx(project, instrumentId, fx), tempo, fxParams), ); }, playPattern: (patternId, volume, loop, detached, fx, fxParams, bus, sync) => playPattern( run, patternId, volume, loop, detached ? '' : entity.id, fx, fxParams, bus, syncedStart(run, sync), ), stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing), playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) => playSong(run, songId, { volume, fadeIn, loop, owner: detached ? '' : entity.id, fx, fxParams, bus, live, }), setSongTrack: (playback, track, volumeDb, fade, quantize) => setSongTrack(run, world, playback, track, volumeDb, heldFade(fade), quantize), setSongLane: (playback, track, lane, value, fade, quantize) => setSongLane(run, world, playback, track, lane, value, heldFade(fade), quantize), releaseSongLane: (playback, track, lane, fade) => releaseSongLane(run, world, playback, track, lane, heldFade(fade)), switchSong: (playback, songId, marker, quantize, fade) => switchSong(run, world, playback, songId, marker, quantize, heldFade(fade)), 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), tuneBus: (bus, fxParams, time, ease) => { const reached = reachedBus(project, bus); if (reached) run.audio.tuneMix(reached, fxParams, time, ease); }, setBusVolume: (bus, volume, time, ease) => { const reached = reachedBus(project, bus); if (reached) run.audio.setMixVolume(reached, volume, time, ease); }, busVolume: (bus) => { const reached = reachedBus(project, bus); return reached ? run.audio.mixVolume(reached) : 0; }, songPosition: (playback, song, pattern) => positionOf(heardPlayings(project, run.audio), playback, song, pattern), 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; }, startRecording: (settings) => tell(world, run.recorder?.start(settings) ?? NO_RECORDER), stopRecording: () => tell(world, run.recorder?.stop() ?? NO_RECORDER), breakpoint: () => run.breakpoint?.(entity.name), }; } const NO_RECORDER = 'Recording works only while the game is played, not in the editor run'; /** A line of the host for the log, as the `Log` node writes one; nothing for an empty one. */ function tell(world: World, line: string): void { if (!line) return; world.log.push(line); console.warn('[recording]', line); } /** * 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, bus: string, at?: number, ): number { const ref = findSound(project, assetId); if (!ref?.asset.file) return 0; const props = resolveAssetProps(project, ref.asset); const level = Math.max(0, Math.min(1, volumeOf(props) * volume)); const fx = fxVoiceOf(project, pickedFx(fxId, props), tempo, fxParams); return audio.play( playedSoundOf(ref), level, loop ?? loopsOf(props), owner, fx ?? DRY_VOICE, playedBus(project, bus, ref.asset.id), at, ); } /** Where a sound, note or pattern synced to a playing starts (#338), and the tempo that one plays at. */ interface SyncedStart { at: number; tempo: number; } /** * The start of a stinger (#338): on the spot `sync.quantize` names of the playing it is synced to, * as that is heard. Null where it is not synced, or nothing of that number is heard: it starts at once. */ function syncedStart( { project, audio }: Pick, sync: MusicSync, ): SyncedStart | null { if (!(sync.playback > 0)) return null; const playing = heardPlayings(project, audio).find((p) => p.id === sync.playback); if (!playing) return null; const at = audio.landOn?.(sync.playback, landing(sync.quantize, playing)) ?? null; return at === null ? null : { at, tempo: playing.timeline.tempo }; } /** The sound FX a note of that instrument goes through, by the pick of a node (#352); '' for none. */ export function instrumentFx(project: Project, instrumentId: string, picked: string): string { return pickedFx(picked, findAssetOf(project, instrumentId, 'instrument')?.props ?? {}); } /** The bus of the mix a note of that instrument is heard on, by the pick of a node (#325). */ function instrumentBus(project: Project, instrumentId: string, picked: string): string { return playedBus(project, picked, instrumentId); } /** * 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 note = heldNote(struck.note); const built = instrumentBuildFor(asset, 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, note, }); } /** Where the parameters of a note's own sound FX go by (#346): no name of a parameter has a colon. */ 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. */ function instrumentVoiceName(instrumentId: string): string { return `instrument:${instrumentId}`; } /** Where a playing of a pattern or a song goes: into one bus, or along its links (#351). */ function heardOn(route: MixRoute): Pick { return typeof route === 'string' ? { bus: route } : { sends: route }; } /** * 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; its bake (#340) takes the volume where it still holds, and is played instead. * `synced` starts it in time with another playing (#338), at that one's tempo: its bake is of its * own tempo, so only that plays it. 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, bus: string, synced: SyncedStart | null = null, ): 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 own = patternTempo(asset.props); const tempo = synced?.tempo ?? own; const fx = fxVoiceOf(project, pickedFx(fxId, asset.props), tempo, fxParams); const bake = tempo === own ? validBake(project, asset) : null; return audio.playSequence( patternVoiceName(patternId), { sequence, tempo, length, loop: loop ? { start: 0, end: length } : null, needs: sequenceNeeds(project, sequence), ...(fx && { fx }), ...heardOn(playedRoute(project, bus, patternId)), strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner), ...(bake && { bake: { file: bake.file, gain: bake.peak * level, playback: bakePlayback(bake, loop) } }), ...(synced && { at: synced.at }), }, owner, ); } /** How a Play song asked for a song (#338): what a Switch song plays the next one by. */ interface SongAsk { volume: number; fadeIn: number; loop: boolean; owner: string; fx: string; fxParams: FxParams; bus: string; /** Its notes, even where it has a bake: its tracks can be turned then. */ live: boolean; } /** What each playing of a song was asked for, by its number, in the sink it plays in. */ const songAsks = new WeakMap>(); /** So many asks are kept before those of playings gone are let go. */ const ASKS_KEPT = 64; function keepAsk(audio: AudioSink, id: number, ask: SongAsk): void { let asks = songAsks.get(audio); if (!asks) songAsks.set(audio, (asks = new Map())); if (asks.size >= ASKS_KEPT) { const playing = new Set((audio.playingTimes?.() ?? []).map((time) => time.id)); for (const known of asks.keys()) if (!playing.has(known)) asks.delete(known); } asks.set(id, ask); } /** * 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. Its bake (#340) is played instead while * it holds, unless `live` (#338). Returns the number of the playing, 0 where there is no song or * nothing in it to play. */ function playSong(sources: Pick, songId: string, ask: SongAsk): number { const play = songPlay(sources, songId, ask, 0); const id = play ? sources.audio.playSequence(songVoiceName(songId), play, ask.owner) : 0; if (id !== 0) keepAsk(sources.audio, id, ask); return id; } /** * What a song plays as, asked for so (see `playSong`), from `from` ticks on (#338): a bake plays only * from the start, so one asked to start further in plays its notes. */ function songPlay( { project, audio }: Pick, songId: string, ask: SongAsk, from: number, ): SequencePlay | null { const song = findAssetOf(project, songId, 'song'); if (!song) return null; const sequence = songSequence(project, song); const tempo = songTempo(song.props); const fx = fxVoiceOf(project, pickedFx(ask.fx, song.props), tempo, ask.fxParams); const bake = ask.live || from > 0 ? null : validBake(project, song); return { sequence, tempo, length: songLength(song), loop: ask.loop ? songLoop(song) : null, needs: sequenceNeeds(project, sequence), volume: heldLevel(ask.volume), fadeIn: heldFade(ask.fadeIn), buses: songBuses(project, song), ...(fx && { fx }), ...heardOn(playedRoute(project, ask.bus, songId)), strike: (note) => strikeNote({ project, audio }, note, ask.owner), strikeClip: (clip) => playClip({ project, audio }, clip), ...(bake && { bake: { file: bake.file, gain: bake.peak, playback: bakePlayback(bake, ask.loop) } }), ...(from > 0 && { from }), }; } /** Where a change to a playing lands (#338): as soon as it can be heard, or on the next spot `quantize` names. */ function landing(quantize: Quantize, playing: HeardPlaying): LandingAt { return ({ time, startedAt, soonest }) => { if (quantize === 'now' || startedAt === null) return soonest; const spots = quantizeSpots(quantize, playing.timeline.meter, playing.markers); const next = nextSpot({ ...time, meter: playing.timeline.meter }, soonest - startedAt, spots); return next === null ? soonest : startedAt + next; }; } /** The song playing numbered `playback` as it is heard now; null for none, or a pattern. */ function heardSong(project: Project, audio: AudioSink, playback: number): HeardPlaying | null { if (!(playback > 0)) return null; return heardPlayings(project, audio).find((p) => p.id === playback && p.kind === 'song') ?? null; } /** Turns a track of a playing song (#338); where it plays its bake there is nothing to turn, and the log says so. */ function setSongTrack( { project, audio }: Pick, world: World, playback: number, track: string, volumeDb: number, fade: number, quantize: Quantize, ): void { const playing = heardSong(project, audio, playback); if (!playing || track === '') return; const db = Number.isFinite(volumeDb) ? Math.max(TRACK_MIN_DB, Math.min(TRACK_MAX_DB, volumeDb)) : 0; const done = audio.setSongTrack?.( playback, trackBus(playing.asset, track), dbToGain(db), fade, landing(quantize, playing), ); if (done === 'baked') { const name = findAssetOf(project, playing.asset, 'song')?.name ?? playing.asset; world.log.push(`Set song track: song "${name}" plays its bake; switch on "live tracks" in Play song`); } } /** * The lane `lane` names of a track of the song playing numbered `playback` (#338), with what its bus * goes by; null where nothing of it plays or the song has no such track. A track without that lane * is logged by `node`. */ function songLaneOf( { project, audio }: Pick, world: World, node: string, playback: number, track: string, lane: string, ): { playing: HeardPlaying; song: string; bus: string; lane: AutomationLane } | null { const playing = heardSong(project, audio, playback); const song = playing && findAssetOf(project, playing.asset, 'song'); const held = song?.tracks.find((t) => t.id === track); if (!playing || !song || !held) return null; const target = targetNamed(lane); const found = target && laneOf(held, target); if (!found) { world.log.push(`${node}: track "${held.name}" of song "${song.name}" has no lane "${lane}"`); return null; } return { playing, song: song.name, bus: trackBus(song.id, track), lane: found }; } /** The log of a song played from its bake, which has no lanes to steer (#338). */ function bakedLane(world: World, node: string, song: string): void { world.log.push(`${node}: song "${song}" plays its bake; switch on "live tracks" in Play song`); } /** Holds a lane of a track of a playing song on `value` (#338), held to its range. */ function setSongLane( sources: Pick, world: World, playback: number, track: string, lane: string, value: number, fade: number, quantize: Quantize, ): void { const found = songLaneOf(sources, world, 'Set song lane', playback, track, lane); if (!found) return; const { min, max } = laneRange(found.lane); const held = Math.max(min, Math.min(max, Number.isFinite(value) ? value : 0)); const done = sources.audio.holdSongLane?.( playback, found.bus, found.lane.target, held, fade, landing(quantize, found.playing), ); if (done === 'baked') bakedLane(world, 'Set song lane', found.song); } /** Lets a held lane of a track of a playing song go (#338), back to its curve over `fade` seconds. */ function releaseSongLane( sources: Pick, world: World, playback: number, track: string, lane: string, fade: number, ): void { const found = songLaneOf(sources, world, 'Release song lane', playback, track, lane); if (!found) return; const done = sources.audio.releaseSongLane?.(playback, found.bus, found.lane.target, fade); if (done === 'baked') bakedLane(world, 'Release song lane', found.song); } /** * Plays another song, or the same one from a marker (#338), in place of a playing song: asked for * as that one was, where `quantize` lands for the one heard now. A marker the song does not have is * logged, and nothing changes. */ function switchSong( sources: Pick, world: World, playback: number, songId: string, markerId: string, quantize: Quantize, fade: number, ): void { const { project, audio } = sources; const playing = heardSong(project, audio, playback); if (!playing) return; const target = songId || playing.asset; const song = findAssetOf(project, target, 'song'); if (!song) return; const marker = markerId ? heldSongMarkers(song.markers).find((m) => m.id === markerId) : undefined; if (markerId && !marker) { const named = findSongMarker(project, markerId)?.name ?? markerId; world.log.push(`Switch song: song "${song.name}" has no marker "${named}"`); return; } const ask = songAsks.get(audio)?.get(playback) ?? { volume: 1, fadeIn: 0, loop: true, owner: '', fx: '', fxParams: {}, bus: '', live: false, }; const play = songPlay(sources, target, ask, marker?.tick ?? 0); if (play) audio.switchSequence?.(playback, songVoiceName(target), play, landing(quantize, playing), fade); } /** * A note the scheduler planned (#332), struck through its instrument and the sound FX that names * (#352) 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 }) }; // What the lanes of its track start it with (#337) reaches its instrument and its sound FX alike. const params = note.params ?? {}; const fx = fxVoiceOf( sources.project, instrumentFx(sources.project, note.instrument, ''), note.tempo, params, ); return playNote(sources, note.instrument, struck, owner, params, timing, fx); } /** 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 ref = findSound(project, clip.sound); if (!ref?.asset.file) return 0; const volume = Math.max( 0, Math.min(dbToGain(SOUND_ITEM_MAX_DB), volumeOf(resolveAssetProps(project, ref.asset)) * clip.volume), ); return audio.playClip( playedSoundOf(ref), { at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus }, clipFx(project, clip.fx, clip.tempo, clip.params), ); } 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`). */ function patternVoiceName(patternId: string): string { return `pattern:${patternId}`; } /** `song:` alone stands for every song. */ 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; }, }; }