A sound FX could only be put on a single sound. Now a track, a pattern item, a Play pattern and a Play song send everything they play through one chain of it, as an insert in a DAW. A Play note gets one of its own. - Track: an FX box in the head next to M and S opens a menu of the project's sound FX, and the track's inspector has the row too. Order: items, track FX, volume, pan. - Pattern item: Sound FX in its inspector. Its notes play on a bus of their own into the track's bus. - Play pattern and Play song take fx and fx params. The chain sits on the master of the playing, after the tracks and before the level and the fades. Set sound FX parameters reaches it by the playback. - Play note takes fx and fx params next to the instrument's params. The FX parameters go by "fx:" so the two sets stay apart. Set sound FX parameters on a note changes a name both have in both. - A Pitch on a bus is a new PitchShifter: the phase vocoder of the PitchPlayer, fed while it plays. It cannot look ahead and is exactly one frame late at every pitch, 2048 samples. - That latency is made up. The other way of a Mix is held back by a delay of the same length. Every note and clip starts as much earlier as the buses it passes are late. The scheduler plans that much further ahead, and the first note waits that much after Play. - The Song tab waits for the chains before it plays and lets its buses go a moment after Stop. A playing in a game lets them go after its echoes and what the shifters still hold. - songBuses moved to its own file. Kept in song.ts it pulled the FX compiler into the node registry through an import cycle. Decisions of the user: one chain for the whole (1a), FX everywhere asked for (2a), Set sound FX parameters on playbacks (3a), and a pitch shifter of its own with exact latency compensation for a Pitch on a bus. Tests: - Unit pitch-shifter (new): 11 green. At 0 semitones the output is the input 2048 samples later, off by at most 1.2e-7. - Unit sound-library: 84 green, 8 new. Counter-checks (no early start, no Mix delay, chain made every time, tune misses the master) each turned their test red. - Unit bus-fx (new): 6 green, 4 counter-checks red. Scheduler ahead: 1 new, its counter-check red. - e2e 65 (melektaus-atx): ALL PASSED, 35 checks, 4 new, first run. Chrome at 44.1 kHz: the bus was 0.046440 s late, the note started exactly that much early. Offline the shifter moved 1 kHz to 2000 Hz and passed a click 2048 samples later. - Gate (melektaus-atx): CHECK EXIT 0, 259 files, 4021 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
501 lines
20 KiB
TypeScript
501 lines
20 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 { 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<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 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<ContextSources, 'project' | 'audio'>,
|
||
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<ContextSources, 'project' | 'audio'>,
|
||
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<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 }) };
|
||
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<ContextSources, 'project' | 'audio'>,
|
||
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;
|
||
},
|
||
};
|
||
}
|