A game should be able to stop a video on a sprite, jump to a spot in it and start it again, and react when it has run out. Until now a video on a sprite either ran free on its own or followed a composition, and no behavior could touch it. - Five new nodes: Play video, Pause video and Seek video (time in s from the clip start), On video finished with a Video field, and the data node Video time with time, length and playing. - Play, Pause and Seek act on the owner when object is not linked. - On video finished with an empty field hears every video. A chosen video also hears its clips. - A new VideoSystem keeps a clock for every sprite with a video. It counts from the moment the sprite appears, as long as it is on. For a Driven sprite the clock is its Video time. A paused game holds all clocks. - A free sprite that one of the nodes touches gets the clock "steered" at run time. It then has its own player, and other sprites with the same clip play on. Pause of the game and sound go the existing Driven way. - On a Driven sprite and in the editor run the nodes do nothing and write a line to the log. - Until its own player has a frame, a steered sprite shows the frame of the shared player. Without that it would blink out on the first Pause video. - Play at the end of a clip without loop starts it from the start. The issue does not say, this is my choice. Found, not fixed: with a Driven sprite in the scene the run waits up to 3 s for its frame at the start. The world does not tick then, but the free videos already play. Their clocks lag behind the picture by that wait, so On video finished comes that much late. Tests: - Unit: 23 new (video-steer 16, node catalog 7), affected groups 4820/4820. - Counter-probes: 16 bends, all red. - e2e 66 on atx: ALL PASSED, 39 checks. New 66s: Pause at 0.15 s held its own player at 0.2 s, the picture stayed red for 1 s. Seek landed on 0.5 s exactly, green, held. Play showed green, blue, white, and white stayed at the end. On video finished came once. Without the clock "steered" all three 66s checks are red. - Gate on atx: CHECK EXIT 0, 336 files, 5199 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
866 lines
34 KiB
TypeScript
866 lines
34 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, 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<ContextSources, 'project' | 'audio'>,
|
||
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<ContextSources, 'project' | 'audio'>,
|
||
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<SequencePlay, 'bus' | 'sends'> {
|
||
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<ContextSources, 'project' | 'audio'>,
|
||
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<AudioSink, Map<number, SongAsk>>();
|
||
/** 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<number, SongAsk>()));
|
||
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<ContextSources, 'project' | 'audio'>, 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<ContextSources, 'project' | 'audio'>,
|
||
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<ContextSources, 'project' | 'audio'>,
|
||
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<ContextSources, 'project' | 'audio'>,
|
||
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<ContextSources, 'project' | 'audio'>,
|
||
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<ContextSources, 'project' | 'audio'>,
|
||
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<ContextSources, 'project' | 'audio'>,
|
||
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<ContextSources, 'project' | 'audio'>,
|
||
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<ContextSources, 'project' | 'audio'>,
|
||
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;
|
||
},
|
||
};
|
||
}
|