Until now every sound went straight to the speakers. There was no point where all of them came together, so nothing could turn the music down against the effects, and a compressor or a shared echo over everything had nowhere to sit. The bus technique of #346 was there already, only the end of the chain was missing. - Bus asset (Audio): Volume, Sound FX and Into. Into is empty for the master and never offers the bus itself nor a bus that already goes into it. The list is worked out when the menu opens, so a change made meanwhile counts. A loop that is in a project anyway sends the buses on it into the master. - The master is no asset. Its volume and sound FX are two new rows under Master in Project settings, one undo step each. A project written before has 1 and none, which is how it sounded. - Sound, instrument, pattern and song have a row Bus, empty for the master. Play sound, Play note, Play pattern and Play song have an input bus: From the asset, Master or a bus. Inside a song or a pattern only the bus of the song or pattern counts. - Set bus volume (bus, volume, time, easing) and Bus volume. An empty bus is the master. The volume set holds for the whole run, across scenes, over that of the asset. GameRun resets it at start and end. Set sound FX parameters without a voice now reaches the sound FX of the bus picked in its new bus input. - SoundLibrary: the master is the bus "mix", each bus "mix:<id>". Every bus, the playing masters of #346 included, ends there, and only the master is heard. setMix sets them up once a context exists, so an editor nobody listened to opens no audio device. The same mix again changes nothing, which lets the editor hand it over on every change of the project. - The tabs play as the game does: Sound, Sound FX, Instrument, Pattern and Song tab through the bus of their asset and the master. - Pause game does nothing to the buses (the user's answer 4b). A game that wants quiet in its pause menu sets a bus volume itself. - Streaming long music files is #349, split off as asked. Finding on the way: the Into menu first worked from a list made when the inspector row was built. After another bus was sent into the open one, e2e 34 saw it still offered. It is now asked on open. The SoundLibrary tests expected voices to end at the destination. They now end in the master, which adds its input, level and pan to every wiring and two gains to every list. Those expectations are updated, nothing else in them changed. Tests: - Unit buses (new): 15 green. Ten counter-checks, each red on its own: loop check, bus picked, reset at run start, bus of Set sound FX parameters, master volume in the settings, and in SoundLibrary default into the master, voices into the master, run volume over the mix, no context before a play, a playing on its bus. - Unit sound-library: 90 green (5 new). Node catalog: 416 green (9 new cases). - e2e 34 (melektaus-atx): ALL PASSED, 75 checks (3 new). The voice of the Sound tab was heard on mix:<bus>, that bus into the other, that one at 0.5 into the master. Master volume 0.6 via the dialog, undo back to 1. - Gate (melektaus-atx): CHECK EXIT 0, 262 files, 4072 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
517 lines
19 KiB
TypeScript
517 lines
19 KiB
TypeScript
import { CreateAssetCommand } from '../src/core/commands/assetCommands';
|
||
import { CommandStack } from '../src/core/commands/CommandStack';
|
||
import { AddNodeCommand, ConnectCommand } from '../src/core/commands/graphCommands';
|
||
import { AddPortsCommand } from '../src/core/commands/portCommands';
|
||
import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands';
|
||
import { requireNodeDef } from '../src/core/commands/require';
|
||
import { AddVariableCommand } from '../src/core/commands/variableCommands';
|
||
import {
|
||
type DeclaredPort,
|
||
emptySharedState,
|
||
ENTITY_HANDLE,
|
||
type EntityHandle,
|
||
type GraphLookup,
|
||
type GraphNode,
|
||
NO_LOOKUP,
|
||
type NodeContext,
|
||
type PointerState,
|
||
type Value,
|
||
type RunResult,
|
||
portKindOf,
|
||
PROPERTIES,
|
||
VARIABLES,
|
||
VARIABLES_PARAM,
|
||
} from '../src/core/graph';
|
||
import type { GraphInterpreter } from '../src/core/graph/GraphInterpreter';
|
||
import type { EaseShare } from '../src/core/model/animation';
|
||
import type { BehaviorAsset, SceneAsset, VariablesAsset } from '../src/core/model/Asset';
|
||
import { findObject } from '../src/core/model/hierarchy';
|
||
import { worldPosition } from '../src/core/properties/transform';
|
||
import { resolvedHolder } from '../src/core/model/instance';
|
||
import type { Position, PropertyBag, Vec2 } from '../src/core/model/Properties';
|
||
import { createEmptyProject, findScene, type Project } from '../src/core/model/Project';
|
||
import type { SceneObject } from '../src/core/model/SceneObject';
|
||
import { deserializeProject, serializeProject } from '../src/core/store/serialize';
|
||
import type { AudioSink } from '../src/runtime/systems/Audio';
|
||
import { BehaviorSystem, type TimeScaled } from '../src/runtime/systems/BehaviorSystem';
|
||
import type { SaveArea } from '../src/core/graph/savedValues';
|
||
import { readyPhysics } from '../src/runtime/physics/engine';
|
||
import { PhysicsSystem } from '../src/runtime/physics/PhysicsSystem';
|
||
import { CollisionSystem } from '../src/runtime/systems/CollisionSystem';
|
||
import type { CollisionSource } from '../src/runtime/systems/solids';
|
||
import { AnimationSystem } from '../src/runtime/systems/AnimationSystem';
|
||
import { Input } from '../src/runtime/systems/Input';
|
||
import { World } from '../src/runtime/World';
|
||
|
||
export interface ProjectFixture {
|
||
project: Project;
|
||
stack: CommandStack;
|
||
}
|
||
|
||
export interface SceneFixture extends ProjectFixture {
|
||
sceneId: string;
|
||
spriteId: string;
|
||
/** The scene asset, looked up fresh (undo may replace it). */
|
||
sceneOf(): SceneAsset;
|
||
/** The scene with the content of placed containers resolved in – what the editor shows. */
|
||
viewOf(): SceneAsset;
|
||
object(id: string): SceneObject;
|
||
/** World position of a scene object, ancestors included. */
|
||
world(id: string): Position;
|
||
}
|
||
|
||
export function makeProject(): ProjectFixture {
|
||
const project = createEmptyProject('p', 'P');
|
||
return { project, stack: new CommandStack(project) };
|
||
}
|
||
|
||
/** A project with a scene "Level" and a 100×100 sprite (named "Box" unless told otherwise), plus lookups into the scene. */
|
||
export function makeSpriteScene(spriteName = 'Box'): SceneFixture {
|
||
const fixture = makeProject();
|
||
const scene = new CreateAssetCommand('scene', 'Level');
|
||
const sprite = new CreateAssetCommand('sprite', spriteName);
|
||
fixture.stack.execute(scene);
|
||
fixture.stack.execute(sprite);
|
||
const sceneOf = () => findScene(fixture.project, scene.asset.id)!;
|
||
const viewOf = () => resolvedHolder(fixture.project, sceneOf()) as SceneAsset;
|
||
const object = (id: string) => findObject(viewOf(), id)!;
|
||
return {
|
||
...fixture,
|
||
sceneId: scene.asset.id,
|
||
spriteId: sprite.asset.id,
|
||
sceneOf,
|
||
viewOf,
|
||
object,
|
||
world: (id) => worldPosition(fixture.project, viewOf(), object(id)),
|
||
};
|
||
}
|
||
|
||
/** Places an asset into the fixture's scene at a world position (optionally below a parent) and returns the new object's id. */
|
||
export function place(
|
||
fixture: SceneFixture,
|
||
assetId: string,
|
||
position: Vec2 | Position,
|
||
parentId = '',
|
||
): string {
|
||
const command = new PlaceAssetCommand(fixture.sceneId, assetId, position, parentId);
|
||
fixture.stack.execute(command);
|
||
return command.objectId;
|
||
}
|
||
|
||
/** Adds a Variables asset declaring the given number variables – what a run shares. */
|
||
export function makeVariables(
|
||
fixture: ProjectFixture,
|
||
name: string,
|
||
keys: readonly string[],
|
||
): VariablesAsset {
|
||
const command = new CreateAssetCommand('variables', name);
|
||
fixture.stack.execute(command);
|
||
for (const key of keys) fixture.stack.execute(new AddVariableCommand(command.asset.id, key, 'number'));
|
||
return command.asset as VariablesAsset;
|
||
}
|
||
|
||
/** Adds a behavior asset declaring the given number variables. */
|
||
export function makeBehavior(
|
||
fixture: ProjectFixture,
|
||
name = 'B',
|
||
variables: readonly string[] = [],
|
||
): BehaviorAsset {
|
||
const command = new CreateAssetCommand('behavior', name);
|
||
fixture.stack.execute(command);
|
||
for (const key of variables) fixture.stack.execute(new AddVariableCommand(command.asset.id, key, 'number'));
|
||
return command.asset as BehaviorAsset;
|
||
}
|
||
|
||
export type Edge = readonly [from: GraphNode, fromPort: string, to: GraphNode, toPort: string];
|
||
|
||
export interface GraphEditor {
|
||
/** Adds a node with inline params (graph coordinates do not matter to tests). */
|
||
addNode(type: string, params?: PropertyBag): GraphNode;
|
||
/** A Variables node naming `keys`, typed by their declaration; `write` sets all of them or the listed. */
|
||
variables(
|
||
keys: readonly string[],
|
||
options?: { write?: boolean | readonly string[]; params?: PropertyBag },
|
||
): GraphNode;
|
||
/** A Properties node with `params` (its type) declaring `ports`. */
|
||
properties(ports: readonly DeclaredPort[], params?: PropertyBag): GraphNode;
|
||
connect(from: GraphNode, fromPort: string, to: GraphNode, toPort: string): void;
|
||
wire(edges: readonly Edge[]): void;
|
||
}
|
||
|
||
/** Command-driven editing of one behavior's graph, the way the editor does it. */
|
||
export function graphEditor(fixture: ProjectFixture, behavior: BehaviorAsset): GraphEditor {
|
||
const { stack } = fixture;
|
||
const connect: GraphEditor['connect'] = (from, fromPort, to, toPort) => {
|
||
stack.execute(
|
||
new ConnectCommand(behavior.id, { node: from.id, port: fromPort }, { node: to.id, port: toPort }),
|
||
);
|
||
};
|
||
return {
|
||
addNode(type, params = {}) {
|
||
const command = new AddNodeCommand(behavior.id, type, 0, 0, params);
|
||
stack.execute(command);
|
||
return command.node;
|
||
},
|
||
variables(keys, { write = false, params = {} } = {}) {
|
||
const command = new AddNodeCommand(behavior.id, VARIABLES, 0, 0, params);
|
||
stack.execute(command);
|
||
const writes = (key: string) => write === true || (Array.isArray(write) && write.includes(key));
|
||
const ports = keys.map((key): DeclaredPort => {
|
||
// A node pointed at a Variables asset declares that asset's variables, not the behavior's.
|
||
const source = fixture.project.assets.find((a) => a.id === params[VARIABLES_PARAM]);
|
||
const declarations = source?.type === 'variables' ? source.variables : behavior.variables;
|
||
const declared = declarations.find((v) => v.key === key);
|
||
const kind = declared ? portKindOf(declared) : 'number';
|
||
return writes(key) ? { key, kind, write: true } : { key, kind };
|
||
});
|
||
if (ports.length > 0) stack.execute(new AddPortsCommand(behavior.id, command.node.id, ports));
|
||
return command.node;
|
||
},
|
||
properties(ports, params = {}) {
|
||
const command = new AddNodeCommand(behavior.id, PROPERTIES, 0, 0, params);
|
||
stack.execute(command);
|
||
if (ports.length > 0) stack.execute(new AddPortsCommand(behavior.id, command.node.id, ports));
|
||
return command.node;
|
||
},
|
||
connect,
|
||
wire(edges) {
|
||
for (const [from, fromPort, to, toPort] of edges) connect(from, fromPort, to, toPort);
|
||
},
|
||
};
|
||
}
|
||
|
||
export const NO_POINTER: PointerState = {
|
||
x: 0,
|
||
y: 0,
|
||
inside: false,
|
||
down: false,
|
||
pressed: false,
|
||
released: false,
|
||
wheel: 0,
|
||
};
|
||
|
||
/** A top-level entity handle named after itself, with empty props. */
|
||
export function fakeEntity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle {
|
||
return { [ENTITY_HANDLE]: true, id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null };
|
||
}
|
||
|
||
/** An entity handle "h" carrying the given props, optionally below a parent. */
|
||
export function fakeHandle(props: PropertyBag, parent: EntityHandle | null = null): EntityHandle {
|
||
return { [ENTITY_HANDLE]: true, id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent };
|
||
}
|
||
|
||
/** `changes` records the state moves the context was told about, in the order they were reported. */
|
||
export type FakeContext = NodeContext & {
|
||
logs: string[];
|
||
stopped: boolean;
|
||
changes: { machineId: string; from: string; to: string }[];
|
||
/** What the context was asked to play and to silence, in order – nothing here makes a noise. */
|
||
played: {
|
||
assetId: string;
|
||
volume: number;
|
||
loop: boolean | undefined;
|
||
detached: boolean;
|
||
fx: string;
|
||
fxParams: Readonly<Record<string, number>>;
|
||
tempo: number;
|
||
/** The bus picked (#325); left out for none. */
|
||
bus?: string;
|
||
}[];
|
||
/** The notes the context was asked to play (#331); a voice's number is how many have been played. */
|
||
notes: {
|
||
instrumentId: string;
|
||
note: number;
|
||
velocity: number;
|
||
volume: number;
|
||
duration: number;
|
||
detached: boolean;
|
||
instrumentParams: Readonly<Record<string, number>>;
|
||
tempo: number;
|
||
fx: string;
|
||
fxParams: Readonly<Record<string, number>>;
|
||
bus?: string;
|
||
}[];
|
||
/** The patterns the context was asked to play; a playing's number is how many have been played. */
|
||
patterns: {
|
||
patternId: string;
|
||
volume: number;
|
||
loop: boolean;
|
||
detached: boolean;
|
||
fx: string;
|
||
fxParams: Readonly<Record<string, number>>;
|
||
bus?: string;
|
||
}[];
|
||
/** The songs the context was asked to play, numbered the same way. */
|
||
songs: {
|
||
songId: string;
|
||
volume: number;
|
||
fadeIn: number;
|
||
loop: boolean;
|
||
detached: boolean;
|
||
fx: string;
|
||
fxParams: Readonly<Record<string, number>>;
|
||
bus?: string;
|
||
}[];
|
||
silenced: string[];
|
||
tuned: { voice: number; fxParams: Readonly<Record<string, number>>; time: number; ease: EaseShare }[];
|
||
/** What buses the context was told to change (#325): a volume or FX parameters, in order. */
|
||
buses: {
|
||
bus: string;
|
||
volume?: number;
|
||
fxParams?: Readonly<Record<string, number>>;
|
||
time: number;
|
||
ease: EaseShare;
|
||
}[];
|
||
/** The scenes the context was asked to load, in order. */
|
||
loaded: string[];
|
||
/** The time scale and the pause the context holds, as the game nodes left them. */
|
||
scale: number;
|
||
paused: boolean;
|
||
};
|
||
|
||
/** A NodeContext double: self/owner handles, `speed = 100`, dt 0.5, moves applied directly, `log` and `stop` recorded. */
|
||
export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||
const logs: string[] = [];
|
||
const ctx: FakeContext = {
|
||
self: fakeEntity('self'),
|
||
owner: fakeEntity('owner'),
|
||
variables: { speed: 100 },
|
||
states: {},
|
||
shared: emptySharedState(),
|
||
changes: [],
|
||
stateChanged: (machineId, from, to) => ctx.changes.push({ machineId, from, to }),
|
||
time: 0,
|
||
dt: 0.5,
|
||
logs,
|
||
stopped: false,
|
||
findByName: () => null,
|
||
isAlive: () => true,
|
||
findReferenced: () => null,
|
||
childByName: () => null,
|
||
objects: () => [],
|
||
isKeyDown: () => false,
|
||
wasKeyPressed: () => false,
|
||
wasKeyReleased: () => false,
|
||
pointer: { ...NO_POINTER },
|
||
pointerThrough: () => ({ x: 0, y: 0, inside: false }),
|
||
hitTest: () => false,
|
||
isTouching: () => false,
|
||
raycast: () => null,
|
||
raycastAll: () => [],
|
||
collisionLayerOf: () => null,
|
||
renderLayerOf: () => null,
|
||
ignoreOneWay: () => {},
|
||
isTilemap: () => false,
|
||
tileAt: () => null,
|
||
setTile: () => false,
|
||
moveAndSlide: (target, dx, dy) => {
|
||
target.x += dx;
|
||
target.y += dy;
|
||
return { blockedX: false, blockedY: false, hit: false, normal: { x: 0, y: 0 } };
|
||
},
|
||
log: (message) => logs.push(message),
|
||
played: [],
|
||
silenced: [],
|
||
loaded: [],
|
||
// The number of a voice is how many have been played: unique, and never 0.
|
||
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus) =>
|
||
ctx.played.push({ assetId, volume, loop, detached, fx, fxParams, tempo, ...(bus && { bus }) }),
|
||
notes: [],
|
||
playNote: (
|
||
instrumentId,
|
||
note,
|
||
velocity,
|
||
volume,
|
||
duration,
|
||
detached,
|
||
instrumentParams,
|
||
tempo,
|
||
fx,
|
||
fxParams,
|
||
bus,
|
||
) =>
|
||
ctx.notes.push({
|
||
instrumentId,
|
||
note,
|
||
velocity,
|
||
volume,
|
||
duration,
|
||
detached,
|
||
instrumentParams,
|
||
tempo,
|
||
fx,
|
||
fxParams,
|
||
...(bus && { bus }),
|
||
}),
|
||
patterns: [],
|
||
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus) =>
|
||
ctx.patterns.push({ patternId, volume, loop, detached, fx, fxParams, ...(bus && { bus }) }),
|
||
stopPattern: (patternId, letRing) =>
|
||
ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`),
|
||
songs: [],
|
||
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus) =>
|
||
ctx.songs.push({ songId, volume, fadeIn, loop, detached, fx, fxParams, ...(bus && { bus }) }),
|
||
stopSong: (songId, fadeOut) => ctx.silenced.push(`song ${songId} (fade ${fadeOut})`),
|
||
stopPlayback: (playback, fadeOut) => ctx.silenced.push(`playback ${playback} (fade ${fadeOut})`),
|
||
// Every sound lasts a quarter of a second, as if it had been loaded.
|
||
soundLength: () => 0.25,
|
||
// The tail of a voice is its number in tenths of a second, so a test can tell one from another.
|
||
soundTail: (voice) => voice / 10,
|
||
// A voice by its number, and letting ring, are written into the line, so one list says it all.
|
||
stopSound: (assetId, letRing) => ctx.silenced.push(letRing ? `${assetId} (let ring)` : assetId),
|
||
stopVoice: (voice, letRing) => ctx.silenced.push(`voice ${voice}${letRing ? ' (let ring)' : ''}`),
|
||
tuned: [],
|
||
tuneSound: (voice, fxParams, time, ease) => ctx.tuned.push({ voice, fxParams, time, ease }),
|
||
buses: [],
|
||
tuneBus: (bus, fxParams, time, ease) => ctx.buses.push({ bus, fxParams, time, ease }),
|
||
setBusVolume: (bus, volume, time, ease) => ctx.buses.push({ bus, volume, time, ease }),
|
||
// The last volume set, else that of an asset nobody touched.
|
||
busVolume: (bus) =>
|
||
[...ctx.buses].reverse().find((change) => change.bus === bus && change.volume !== undefined)?.volume ??
|
||
1,
|
||
loadScene: (sceneId) => ctx.loaded.push(sceneId),
|
||
spawn: () => null,
|
||
addJoint: () => null,
|
||
destroy: () => [],
|
||
stop: () => {
|
||
ctx.stopped = true;
|
||
},
|
||
restart: () => {},
|
||
scale: 1,
|
||
paused: false,
|
||
timeScale: () => ctx.scale,
|
||
setTimeScale: (scale) => {
|
||
ctx.scale = scale;
|
||
},
|
||
isPaused: () => ctx.paused,
|
||
setPaused: (paused) => {
|
||
ctx.paused = paused;
|
||
},
|
||
...overrides,
|
||
};
|
||
return ctx;
|
||
}
|
||
|
||
/** Runs a node on its registered definition: the shape it has while sitting in no graph. */
|
||
export function runDef(
|
||
type: string,
|
||
ctx: NodeContext,
|
||
inputs: Record<string, Value>,
|
||
params: PropertyBag = {},
|
||
entry = 'exec',
|
||
lookup: GraphLookup = NO_LOOKUP,
|
||
): RunResult {
|
||
const def = requireNodeDef(type);
|
||
return def.run!(ctx, inputs, params, def, entry, lookup);
|
||
}
|
||
|
||
/**
|
||
* Runs every event node of `type` in the graph, in the order the graph holds them, and answers how
|
||
* many ran. A game orders its events across all behaviors instead (#264); a test of one graph does not.
|
||
*/
|
||
export function fire(
|
||
interpreter: GraphInterpreter,
|
||
type: string,
|
||
ctx: NodeContext,
|
||
outputs: Record<string, Value> = {},
|
||
filter?: (node: GraphNode) => boolean,
|
||
): number {
|
||
const nodes = interpreter.eventNodes(type).filter((node) => !filter || filter(node));
|
||
for (const node of nodes) interpreter.pulse(node, ctx, outputs);
|
||
return nodes.length;
|
||
}
|
||
|
||
/** Runs one node definition in isolation and returns its data outputs. */
|
||
export function runNode(
|
||
type: string,
|
||
inputs: Record<string, Value>,
|
||
params: PropertyBag = {},
|
||
ctx: NodeContext = fakeContext(),
|
||
): Record<string, Value> {
|
||
return runDef(type, ctx, inputs, params).outputs ?? {};
|
||
}
|
||
|
||
export interface WorldOptions {
|
||
collisions?: boolean;
|
||
/**
|
||
* With `collisions`: the played game's physics engine rather than the editor run's detection
|
||
* (#146). The engine has to be loaded first – `await loadPhysics()` in a `beforeAll`.
|
||
*/
|
||
physics?: boolean;
|
||
animations?: boolean;
|
||
behaviors?: boolean;
|
||
/** Where `Play sound` ends up; without one the run plays to nobody (see `SILENT_AUDIO`). */
|
||
audio?: AudioSink;
|
||
/** The clock `Set time scale` turns; without one the run is treated like the editor run. */
|
||
clock?: TimeScaled;
|
||
/** Where persistent Variables assets keep their values; without one nothing outlives the run. */
|
||
saves?: SaveArea;
|
||
}
|
||
|
||
export interface StartedWorld {
|
||
world: World;
|
||
collisions: CollisionSource | null;
|
||
behaviors: BehaviorSystem | null;
|
||
/** Feed keys with `input.press('a')`; `frame()` clears the edges the way the editor does. */
|
||
input: Input;
|
||
/** One tick plus the end-of-frame bookkeeping the editor's play loop performs. */
|
||
frame(dt?: number): void;
|
||
}
|
||
|
||
/** Instantiates the fixture's scene, optionally with collisions, animations and a BehaviorSystem fed by both. */
|
||
export function startWorld(
|
||
fixture: SceneFixture,
|
||
options: WorldOptions & { collisions: true },
|
||
): StartedWorld & { collisions: CollisionSource };
|
||
export function startWorld(fixture: SceneFixture, options?: WorldOptions): StartedWorld;
|
||
export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): StartedWorld {
|
||
const world = World.instantiate(fixture.project, fixture.sceneId);
|
||
const collisions = options.collisions
|
||
? options.physics
|
||
? new PhysicsSystem(readyPhysics() ?? fail('load the physics engine before starting a world with it'))
|
||
: new CollisionSystem()
|
||
: null;
|
||
const input = new Input();
|
||
const animations = options.animations ? new AnimationSystem() : null;
|
||
if (collisions) world.addSystem(collisions);
|
||
if (animations) world.addSystem(animations);
|
||
const behaviors = options.behaviors
|
||
? new BehaviorSystem(fixture.project, input, {
|
||
collisions,
|
||
animations,
|
||
audio: options.audio,
|
||
clock: options.clock,
|
||
saves: options.saves,
|
||
})
|
||
: null;
|
||
if (behaviors) world.addSystem(behaviors);
|
||
return {
|
||
world,
|
||
collisions,
|
||
behaviors,
|
||
input,
|
||
frame(dt = 1 / 60) {
|
||
world.tick(dt);
|
||
input.endFrame();
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Both ways a run detects collisions: the editor run's own and the played game's engine (#146).
|
||
* Everything without a rigidbody must come out the same on either.
|
||
*/
|
||
export const COLLISION_SYSTEMS = [
|
||
{ system: 'CollisionSystem', physics: false },
|
||
{ system: 'PhysicsSystem', physics: true },
|
||
] as const;
|
||
|
||
function fail(message: string): never {
|
||
throw new Error(message);
|
||
}
|
||
|
||
/** The project as it comes back after being saved and loaded again. */
|
||
export function roundTrip(project: Project): Project {
|
||
return deserializeProject(serializeProject(project));
|
||
}
|