Files
feuerwerk/tests/helpers.ts
T
admin 1e9d21cd5d
game-maker / suite (push) Failing after 11m6s
A game that is more than one scene needs a way from one to the next, so the graph can load one (#39)
Load scene sets a flag on the World like Stop and Restart do; the swap
happens after the tick, and the last wish of a tick wins. Exactly one
thing crosses over: the SharedState, so global variables and global
machines keep what they know while everything local is built anew.
Loading the scene that is already running rebuilds it, which is what
"reset the level" means when the score must survive.

A run starts either from the start scene (a new project field, set from
the asset panel and marked with an arrow in the list) or from whatever
scene is open. The toggle next to Play says which question is asked and
remembers the answer in localStorage: it is a way of working, not part
of the game.

Sounds no longer end because a scene ends. A voice belongs to the
behavior that started it and stops right after On destroy, so the scene
swap is no longer a special case at all. For the enemy that plays its
explosion and destroys itself in the same breath there is now a
detached input on Play sound.

The scene selector stays usable while a run plays, because a double
click in the asset panel already did the same thing unguarded. It moves
what the editor looks at, not what the run plays.
2026-09-15 13:24:26 +02:00

321 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { CollisionSystem } from '../src/runtime/systems/CollisionSystem';
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;
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: CollisionSystem | 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: CollisionSystem };
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 ? 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();
},
};
}
/** The project as it comes back after being saved and loaded again. */
export function roundTrip(project: Project): Project {
return deserializeProject(serializeProject(project));
}