game-maker / suite (push) Successful in 12m1s
Sounds follow the road fonts already paved: the file goes to the server content-addressed under its sha256, the project keeps a named asset that points at it, and nothing is ever re-encoded. The format is read from the file's own signature, not from what the browser claims - the same .m4a arrives as audio/mp4, audio/x-m4a or as nothing at all. Playing is two nodes. "Play sound" takes the asset and may raise or lower its volume; every call is its own voice, so two jumps in a row are two jumps heard. Its loop input is a choice of three - from the asset, on, off - because a checkbox has to spend one of its two positions on "not set" and would lose a case: an asset that loops could then never be played once. Below the graph core stays browser-free: NodeContext hands out an asset id, BehaviorSystem resolves it against the project and folds the asset's own volume and loop into the call, and only viewport/SoundLibrary knows about Web Audio. A run without a sink plays nothing instead of throwing. The CI browser is muted, so the ear is no witness: the debug hook counts started and stopped voices, and step 34 reads that counter.
545 lines
22 KiB
TypeScript
545 lines
22 KiB
TypeScript
import {
|
||
adoptDeclarations,
|
||
asNumber,
|
||
buildSharedState,
|
||
declaredVariables,
|
||
emptySharedState,
|
||
type EntityHandle,
|
||
type Graph,
|
||
type GraphNode,
|
||
GraphInterpreter,
|
||
isHandleEnabled,
|
||
type NodeContext,
|
||
type SharedState,
|
||
startStates,
|
||
type TraceListener,
|
||
type Value,
|
||
} from '../../core/graph';
|
||
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||
import { graphLookup } from '../../core/model/graphLookup';
|
||
import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||
import { soundFileOf } from '../../core/model/sound';
|
||
import { parentChain } from '../../core/model/walk';
|
||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||
import { loopsOf, volumeOf } from '../../core/properties/sounds';
|
||
import { type AudioSink, SILENT_AUDIO } from './Audio';
|
||
import type { Entity } from '../Entity';
|
||
import type { World } from '../World';
|
||
import { bodyOf, type CollisionPair, CollisionSystem } from './CollisionSystem';
|
||
import { AnimationSystem } from './AnimationSystem';
|
||
import type { Input } from './Input';
|
||
import type { System } from './System';
|
||
|
||
/** How long (seconds) a traversed edge/node counts as "hot" for the editor's live highlight. */
|
||
export const TRACE_FADE = 0.35;
|
||
|
||
/** Recency of exec edges and nodes, keyed by id, in world time. */
|
||
export interface Trace {
|
||
edges: Map<string, number>;
|
||
nodes: Map<string, number>;
|
||
values: Map<string, Record<string, Value>>;
|
||
}
|
||
|
||
interface Produced {
|
||
time: number;
|
||
values: Record<string, Value>;
|
||
}
|
||
|
||
/**
|
||
* A move one graph made, waiting for its events. `source` is the instance that caused it, and who
|
||
* hears it when the machine is local; a `global` machine is heard by every instance, so the move it
|
||
* reports has no owner (`null` for the start state entered before any instance existed).
|
||
*/
|
||
interface StateChange {
|
||
machineId: string;
|
||
from: string;
|
||
to: string;
|
||
source: Instance | null;
|
||
}
|
||
|
||
/** How often state events may cause the next move before the run is called endless. */
|
||
const MAX_STATE_ROUNDS = 100;
|
||
|
||
interface Instance {
|
||
entity: Entity;
|
||
interpreter: GraphInterpreter;
|
||
trace: Omit<Trace, 'values'>;
|
||
values: Map<Graph, Map<string, Produced>>;
|
||
/** Current state per state machine – this instance's alone, like its variables. */
|
||
states: Record<string, string>;
|
||
/** Whether the behavior was effectively on when last looked at; a change fires On enable/On disable. */
|
||
active: boolean;
|
||
}
|
||
|
||
/**
|
||
* Runs the graph of every behavior entity. Each instance has its own interpreter (and
|
||
* therefore its own timers and variable state); the project is only read to find graphs.
|
||
*/
|
||
const ERROR_PREFIX = 'Error in behavior';
|
||
|
||
/** Collisions and animations are ticked first, so behaviors see this frame's events. */
|
||
export function addGameSystems(
|
||
world: World,
|
||
project: Project,
|
||
input: Input,
|
||
shouldRun?: (entity: Entity) => boolean,
|
||
audio: AudioSink = SILENT_AUDIO,
|
||
): BehaviorSystem {
|
||
const collisions = new CollisionSystem();
|
||
const animations = new AnimationSystem();
|
||
const behaviors = new BehaviorSystem(project, input, { collisions, animations, audio }, shouldRun);
|
||
world.addSystem(collisions);
|
||
world.addSystem(animations);
|
||
world.addSystem(behaviors);
|
||
return behaviors;
|
||
}
|
||
|
||
/** The systems whose per-frame results become events; a missing one means no such events. */
|
||
export interface EventSources {
|
||
collisions?: CollisionSystem | null;
|
||
animations?: AnimationSystem | null;
|
||
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
|
||
audio?: AudioSink | null;
|
||
}
|
||
|
||
/** The behavior failure that stopped a run, if that is why it stopped. */
|
||
export function behaviorError(world: World): string | undefined {
|
||
return world.log.find((line) => line.startsWith(ERROR_PREFIX));
|
||
}
|
||
|
||
export class BehaviorSystem implements System {
|
||
private instances: Instance[] = [];
|
||
private world: World | null = null;
|
||
private sharedState: SharedState = emptySharedState();
|
||
/** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */
|
||
private pending: StateChange[] = [];
|
||
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
|
||
private declared: Record<string, PropertyBag> = {};
|
||
private readonly collisions: CollisionSystem | null;
|
||
private readonly animations: AnimationSystem | null;
|
||
private readonly audio: AudioSink;
|
||
|
||
constructor(
|
||
private readonly project: Project,
|
||
private readonly input: Input,
|
||
sources: EventSources = {},
|
||
/**
|
||
* Which behaviors this system is responsible for. Playing runs them all; the editor run
|
||
* takes only those marked "run in editor" (see `properties/editorRun.ts`).
|
||
*/
|
||
private readonly shouldRun: (entity: Entity) => boolean = () => true,
|
||
) {
|
||
this.collisions = sources.collisions ?? null;
|
||
this.animations = sources.animations ?? null;
|
||
this.audio = sources.audio ?? SILENT_AUDIO;
|
||
}
|
||
|
||
/** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */
|
||
get shared(): Readonly<SharedState> {
|
||
return this.sharedState;
|
||
}
|
||
|
||
/** The values a Variables asset shares this run; null when this run holds none for it. */
|
||
liveVariables(assetId: string): PropertyBag | null {
|
||
return this.sharedState.variables[assetId] ?? null;
|
||
}
|
||
|
||
/** An edit to a declaration while the run is on reaches the running values through here. */
|
||
syncDeclarations(): void {
|
||
const now = declaredVariables(this.project);
|
||
adoptDeclarations(this.sharedState.variables, this.declared, now);
|
||
this.declared = now;
|
||
}
|
||
|
||
start(world: World): void {
|
||
this.world = world;
|
||
this.instances = [];
|
||
this.pending = [];
|
||
// Built fresh for every run: a shared variable starts at its declared value each time Play does.
|
||
this.sharedState = buildSharedState(this.project);
|
||
this.declared = declaredVariables(this.project);
|
||
// A `global` machine is entered once for the whole run, before the first instance exists – so
|
||
// the move is queued here and every instance the first sync creates hears that one entry.
|
||
for (const [machineId, start] of Object.entries(this.sharedState.states))
|
||
if (start) this.pending.push({ machineId, from: '', to: start, source: null });
|
||
this.syncInstances(world);
|
||
}
|
||
|
||
/** Tearing the world down (stop, restart) destroys every object, so each instance hears it. */
|
||
stop(): void {
|
||
for (const instance of this.instances) this.finish(instance);
|
||
this.instances = [];
|
||
this.pending = [];
|
||
this.world = null;
|
||
}
|
||
|
||
/**
|
||
* One instance per behavior entity. A switched-off behavior gets its instance and `On start` only when
|
||
* switched on, which is what lets a hidden screen wake up when shown.
|
||
*/
|
||
private syncInstances(world: World): void {
|
||
this.reap(world);
|
||
// Read once, and only where an instance is actually born: most frames create none at all.
|
||
let starts: Record<string, string> | null = null;
|
||
for (const entity of world.entities) {
|
||
if (
|
||
entity.type !== 'behavior' ||
|
||
!isHandleEnabled(entity) ||
|
||
!this.shouldRun(entity) ||
|
||
this.instances.some((i) => i.entity === entity)
|
||
)
|
||
continue;
|
||
const asset = findAssetOf(this.project, entity.assetId, 'behavior');
|
||
if (!asset) continue;
|
||
const trace = { edges: new Map<string, number>(), nodes: new Map<string, number>() };
|
||
const values = new Map<Graph, Map<string, Produced>>();
|
||
const interpreter = new GraphInterpreter(asset.graph, graphLookup(this.project));
|
||
interpreter.trace = {
|
||
onEdge: (id) => trace.edges.set(id, world.time),
|
||
onNode: (id) => trace.nodes.set(id, world.time),
|
||
onValues: (graph, id, produced) => {
|
||
let byNode = values.get(graph);
|
||
if (!byNode) values.set(graph, (byNode = new Map<string, Produced>()));
|
||
byNode.set(id, { time: world.time, values: produced });
|
||
},
|
||
} satisfies TraceListener;
|
||
const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true };
|
||
this.instances.push(instance);
|
||
this.enterStartStates(instance, (starts ??= startStates(this.project)));
|
||
this.run(
|
||
instance,
|
||
(i, ctx) => {
|
||
i.fire('OnStart', ctx);
|
||
i.fire('OnEnable', ctx);
|
||
},
|
||
0,
|
||
);
|
||
}
|
||
this.flushStateChanges();
|
||
}
|
||
|
||
/**
|
||
* A fresh instance begins in each local machine's start state, and hears `On state entered` for
|
||
* it – after its `On start`, like every other move. A `global` machine is not entered here: it
|
||
* was entered once when the run began, and a later instance joins it where it already is.
|
||
*/
|
||
private enterStartStates(instance: Instance, starts: Record<string, string>): void {
|
||
for (const [machineId, start] of Object.entries(starts)) {
|
||
if (machineId in this.sharedState.states) continue;
|
||
instance.states[machineId] = start;
|
||
this.pending.push({ machineId, from: '', to: start, source: instance });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The events every queued move owes, and those the events themselves cause, until none are left.
|
||
* Each move is delivered whole – `exited` to everyone who hears it, then `entered` – so the two
|
||
* never interleave across instances.
|
||
*/
|
||
private flushStateChanges(): void {
|
||
const world = this.world;
|
||
if (!world) {
|
||
this.pending = [];
|
||
return;
|
||
}
|
||
for (let round = 0; this.pending.length > 0; round++) {
|
||
if (round >= MAX_STATE_ROUNDS) {
|
||
this.pending = [];
|
||
world.log.push(`${ERROR_PREFIX} graphs: state changes keep causing each other (endless loop?)`);
|
||
world.stopRequested = true;
|
||
return;
|
||
}
|
||
const batch = this.pending;
|
||
this.pending = [];
|
||
for (const change of batch) this.deliver(change);
|
||
}
|
||
}
|
||
|
||
private deliver(change: StateChange): void {
|
||
const global = change.machineId in this.sharedState.states;
|
||
const targets = (global ? this.instances : [change.source]).filter(
|
||
(i): i is Instance => i !== null && this.instances.includes(i),
|
||
);
|
||
const fire = (type: string, state: string) => {
|
||
for (const instance of targets)
|
||
this.run(instance, (i, ctx) => i.fireStateEvent(type, ctx, change.machineId, state), 0);
|
||
};
|
||
fire('OnStateExited', change.from);
|
||
fire('OnStateEntered', change.to);
|
||
}
|
||
|
||
/**
|
||
* Which state each instance holds of one machine, for the State machine tab's live highlight.
|
||
* A `global` machine answers even with no instance running: its state belongs to the run.
|
||
*/
|
||
machineStates(machineId: string): { state: string; objectId: string; ownerId: string }[] {
|
||
const shared = this.sharedState.states[machineId];
|
||
const held = this.instances
|
||
.map((i) => ({
|
||
state: shared ?? i.states[machineId] ?? '',
|
||
objectId: i.entity.objectId,
|
||
ownerId: i.entity.parent?.objectId ?? '',
|
||
}))
|
||
.filter((entry) => entry.state !== '');
|
||
if (held.length === 0 && shared) return [{ state: shared, objectId: '', ownerId: '' }];
|
||
return held;
|
||
}
|
||
|
||
/** Instances whose entity left the world get their last events and are dropped. */
|
||
private reap(world: World): void {
|
||
const gone = this.instances.filter((i) => !world.entities.includes(i.entity));
|
||
if (gone.length === 0) return;
|
||
this.instances = this.instances.filter((i) => !gone.includes(i));
|
||
for (const instance of gone) this.finish(instance);
|
||
}
|
||
|
||
private finish(instance: Instance): void {
|
||
this.run(
|
||
instance,
|
||
(i, ctx) => {
|
||
if (instance.active) i.fire('OnDisable', ctx);
|
||
instance.active = false;
|
||
i.fire('OnDestroy', ctx);
|
||
},
|
||
0,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Follows the effective switch – off when the behavior or a parent is off, or when "run in editor"
|
||
* was taken back while running – and fires the edge it just crossed.
|
||
*/
|
||
private switched(instance: Instance): boolean {
|
||
const on = isHandleEnabled(instance.entity) && this.shouldRun(instance.entity);
|
||
if (on !== instance.active) {
|
||
instance.active = on;
|
||
this.run(instance, (i, ctx) => i.fire(on ? 'OnEnable' : 'OnDisable', ctx), 0);
|
||
}
|
||
return on;
|
||
}
|
||
|
||
update(world: World, dt: number): void {
|
||
this.syncInstances(world);
|
||
const keyOf = (node: { params: PropertyBag }) => textOf(node.params.key ?? '');
|
||
for (const instance of this.instances) {
|
||
if (!world.entities.includes(instance.entity)) continue; // destroyed this frame, reaped below
|
||
if (!this.switched(instance)) continue;
|
||
this.run(
|
||
instance,
|
||
(i, ctx) => {
|
||
for (const node of i.eventNodes('OnKeyDown')) this.fireKeyDown(i, ctx, node, dt);
|
||
for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key);
|
||
for (const key of this.input.heldKeys) i.fire('OnKeyHeld', ctx, { dt }, (n) => keyOf(n) === key);
|
||
for (const node of i.eventNodes('OnTimer')) {
|
||
const state = i.nodeState.get(node.id) ?? { elapsed: 0 };
|
||
i.nodeState.set(node.id, state);
|
||
const interval = Math.max(0.01, Number(node.params.interval ?? 1));
|
||
state.elapsed = (state.elapsed ?? 0) + dt;
|
||
while (state.elapsed >= interval) {
|
||
state.elapsed -= interval;
|
||
i.fire('OnTimer', ctx, {}, (n) => n.id === node.id);
|
||
}
|
||
}
|
||
this.fireCollisions(instance, i, ctx);
|
||
this.fireAnimationsFinished(instance, i, ctx);
|
||
// With an owner the click has to land on it; a behavior without one has no shape to
|
||
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
|
||
const pointer = world.pointer;
|
||
const owner = instance.entity.parent;
|
||
if (pointer.pressed && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) {
|
||
i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
|
||
}
|
||
i.fire('OnUpdate', ctx, { dt });
|
||
},
|
||
dt,
|
||
);
|
||
// After this instance's frame, not inside it: a state entered halfway through `On update`
|
||
// must not push its events between the nodes that are still to run.
|
||
this.flushStateChanges();
|
||
}
|
||
this.reap(world);
|
||
this.flushStateChanges();
|
||
}
|
||
|
||
/**
|
||
* The press plus whatever repeats holding the key has earned. Each node keeps its own hold clock
|
||
* in `nodeState`, so two nodes on the same key repeat on their own schedules.
|
||
*/
|
||
private fireKeyDown(i: GraphInterpreter, ctx: NodeContext, node: GraphNode, dt: number): void {
|
||
const key = textOf(node.params.key ?? '');
|
||
const stored = i.nodeState.get(node.id);
|
||
const { state, fires } = stepKeyRepeat(
|
||
stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null,
|
||
{
|
||
pressed: this.input.justPressed.has(key),
|
||
down: this.input.heldKeys.has(key),
|
||
dt,
|
||
// The node asks for milliseconds so the fields stay whole numbers; the clock runs in seconds.
|
||
after: asNumber(node.params.repeatAfter ?? 0) / 1000,
|
||
interval: asNumber(node.params.repeatInterval ?? 0) / 1000,
|
||
},
|
||
);
|
||
if (state) i.nodeState.set(node.id, { held: state.held, repeats: state.repeats });
|
||
else i.nodeState.delete(node.id);
|
||
for (let fired = 0; fired < fires; fired++) i.fire('OnKeyDown', ctx, {}, (n) => n.id === node.id);
|
||
}
|
||
|
||
/**
|
||
* Collision events for this behavior: every pair in which one collider belongs to the
|
||
* behavior's body (its owner, or itself when it has none) fires once, with `other` being
|
||
* the body on the far side.
|
||
*/
|
||
private fireCollisions(instance: Instance, interpreter: GraphInterpreter, ctx: NodeContext): void {
|
||
if (!this.collisions) return;
|
||
const body = instance.entity.parent ?? instance.entity;
|
||
const fire = (type: string, pairs: CollisionPair[]) => {
|
||
for (const { a, b } of pairs) {
|
||
const own = bodyOf(a) === body ? a : bodyOf(b) === body ? b : null;
|
||
if (!own) continue;
|
||
const other = own === a ? b : a;
|
||
interpreter.fire(type, ctx, { other: bodyOf(other), otherCollider: other, collider: own });
|
||
}
|
||
};
|
||
fire('OnCollisionEnter', this.collisions.enters);
|
||
fire('OnCollisionStay', this.collisions.stays);
|
||
fire('OnCollisionExit', this.collisions.exits);
|
||
}
|
||
|
||
/**
|
||
* A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below
|
||
* it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view.
|
||
*/
|
||
private fireAnimationsFinished(instance: Instance, interpreter: GraphInterpreter, ctx: NodeContext): void {
|
||
const owner = instance.entity.parent;
|
||
for (const { entity, animationId } of this.animations?.finished ?? []) {
|
||
if (owner && ![...parentChain(entity, (e) => e.parent)].includes(owner)) continue;
|
||
interpreter.fire('OnAnimationFinished', ctx, { object: entity }, (node) => {
|
||
const wanted = textOf(node.params.animation ?? '');
|
||
return wanted === '' || wanted === animationId;
|
||
});
|
||
}
|
||
}
|
||
|
||
runningIds(): string[] {
|
||
return this.instances.map((i) => i.entity.objectId);
|
||
}
|
||
|
||
/**
|
||
* Live highlight data for the graph editor: merged over all instances of the asset – and over
|
||
* those that ran it as a node, which is the only way a nested behavior's graph ever lights up.
|
||
*/
|
||
traceFor(assetId: string): Trace {
|
||
const merged: Trace = { edges: new Map(), nodes: new Map(), values: new Map() };
|
||
const latest = (into: Map<string, number>, from: Map<string, number>) => {
|
||
for (const [id, t] of from) into.set(id, Math.max(t, into.get(id) ?? -Infinity));
|
||
};
|
||
const graph = findAssetOf(this.project, assetId, 'behavior')?.graph;
|
||
const newest = new Map<string, Produced>();
|
||
for (const instance of this.instances) {
|
||
if (instance.entity.assetId !== assetId && !instance.interpreter.calledBehaviors.has(assetId)) continue;
|
||
latest(merged.edges, instance.trace.edges);
|
||
latest(merged.nodes, instance.trace.nodes);
|
||
for (const [id, produced] of (graph && instance.values.get(graph)) ?? []) {
|
||
const known = newest.get(id);
|
||
if (!known || produced.time >= known.time) newest.set(id, produced);
|
||
}
|
||
}
|
||
for (const [id, produced] of newest) merged.values.set(id, produced.values);
|
||
return merged;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
private playSound(assetId: string, volume: number, loop: boolean | undefined): void {
|
||
const asset = findAssetOf(this.project, assetId, 'sound');
|
||
if (!asset?.file) return;
|
||
const props = resolveAssetProps(this.project, asset);
|
||
const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
|
||
this.audio.play(asset.file, level, loop ?? loopsOf(props));
|
||
}
|
||
|
||
/** Runs graph code for one instance; an exception stops play and is logged, never thrown. */
|
||
private run(
|
||
instance: Instance,
|
||
body: (interpreter: GraphInterpreter, ctx: NodeContext) => void,
|
||
dt: number,
|
||
): void {
|
||
const world = this.world;
|
||
if (!world) return;
|
||
const ctx = this.context(instance, world, dt);
|
||
try {
|
||
body(instance.interpreter, ctx);
|
||
} catch (error) {
|
||
world.log.push(
|
||
`${ERROR_PREFIX} "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||
);
|
||
world.stopRequested = true;
|
||
}
|
||
}
|
||
|
||
private context(instance: Instance, world: World, dt: number): NodeContext {
|
||
const { entity, states } = instance;
|
||
// 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: entity.props,
|
||
states,
|
||
shared: this.sharedState,
|
||
stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }),
|
||
time: world.time,
|
||
dt,
|
||
findByName: (name) => world.findByName(name) ?? null,
|
||
isKeyDown: (key) => this.input.isDown(key),
|
||
pointer: world.pointer,
|
||
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(this.collisions && ea && eb && this.collisions.touching(world, ea, eb));
|
||
},
|
||
moveAndSlide: (target, dx, dy) => {
|
||
const found = resolve(target);
|
||
if (!found) return { blockedX: false, blockedY: false };
|
||
if (this.collisions) return this.collisions.moveAndSlide(world, found, dx, dy);
|
||
found.x += dx;
|
||
found.y += dy;
|
||
return { blockedX: false, blockedY: false };
|
||
},
|
||
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) => this.playSound(assetId, volume, loop),
|
||
// An id nothing resolves stops nothing, rather than silencing the whole run by accident;
|
||
// only the empty id means "everything", which is what the node promises.
|
||
stopSound: (assetId) => {
|
||
const file = assetId === '' ? '' : soundFileOf(this.project, assetId);
|
||
if (assetId === '' || file) this.audio.stop(file);
|
||
},
|
||
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
|
||
destroy: (target) => {
|
||
const found = resolve(target);
|
||
if (found) world.remove(found);
|
||
},
|
||
stop: () => {
|
||
world.stopRequested = true;
|
||
},
|
||
restart: () => {
|
||
world.restartRequested = true;
|
||
},
|
||
};
|
||
}
|
||
}
|