346 lines
13 KiB
TypeScript
346 lines
13 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,
|
||
} from '../src/core/graph';
|
||
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 } from '../src/runtime/systems/BehaviorSystem';
|
||
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 => {
|
||
const declared = behavior.variables.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,
|
||
};
|
||
|
||
/** 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 }[];
|
||
silenced: string[];
|
||
/** The scenes the context was asked to load, in order. */
|
||
loaded: string[];
|
||
};
|
||
|
||
/** 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,
|
||
isKeyDown: () => false,
|
||
pointer: { ...NO_POINTER },
|
||
hitTest: () => false,
|
||
isTouching: () => false,
|
||
moveAndSlide: (target, dx, dy) => {
|
||
target.x += dx;
|
||
target.y += dy;
|
||
return { blockedX: false, blockedY: false };
|
||
},
|
||
log: (message) => logs.push(message),
|
||
played: [],
|
||
silenced: [],
|
||
loaded: [],
|
||
playSound: (assetId, volume, loop, detached) => ctx.played.push({ assetId, volume, loop, detached }),
|
||
stopSound: (assetId) => ctx.silenced.push(assetId),
|
||
loadScene: (sceneId) => ctx.loaded.push(sceneId),
|
||
spawn: () => null,
|
||
destroy: () => {},
|
||
stop: () => {
|
||
ctx.stopped = true;
|
||
},
|
||
restart: () => {},
|
||
...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 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;
|
||
}
|
||
|
||
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 })
|
||
: 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));
|
||
}
|