Files
feuerwerk/tests/helpers.ts
T
rootandClaude Opus 5 58f19a3451 Replace the property nodes with one Properties node
Get/Set property, Get/Set properties, Set enabled and Set text are gone.
A single Properties node takes a fixed "object" input; every chosen
property is an output that reads the current value, and one switched to
Set is also an input written when the flow arrives. Exec pins only exist
while at least one entry writes.

The node carries a type (Sprite, Text, Collider, Camera, Container,
Behavior, Any) that decides what "+ Property" offers: the fields of that
type, the variables of the chosen behavior, or for Any a kind to pick
and a name to type. Each port takes the default and field of its type,
so a Camera width starts at 640. Switching the type keeps the ports and
marks those the new type lacks. At runtime the type is only an editor
aid; the node writes to whatever object it gets.

Old project files are converted on load. The type is inferred from the
keys: the first object type that has them all, else the one behavior
that declares them all, else Any. enabled belongs to every object, so a
converted Set enabled lands on Any.

The jump-run and tetris seeders use Properties throughout; draw-piece
switches its cell on in the same node that sets the colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:40:47 +02:00

272 lines
9.9 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 { AddVariableCommand } from '../src/core/commands/variableCommands';
import {
type DeclaredPort,
ENTITY_HANDLE,
type EntityHandle,
type GraphNode,
type NodeContext,
type PointerState,
type Value,
requireNodeDef,
type RunResult,
portKindOf,
PROPERTIES,
VARIABLES,
} from '../src/core/graph';
import type { BehaviorAsset, SceneAsset } from '../src/core/model/Asset';
import { findObject, worldPosition } from '../src/core/model/hierarchy';
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 { BehaviorSystem } from '../src/runtime/systems/BehaviorSystem';
import { CollisionSystem } from '../src/runtime/systems/CollisionSystem';
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;
}
/** An empty project with an undo stack. */
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(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 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 };
}
export type FakeContext = NodeContext & { logs: string[]; stopped: 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 },
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),
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 = {},
): RunResult {
const def = requireNodeDef(type);
return def.run!(ctx, inputs, params, def);
}
/** 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;
behaviors?: boolean;
}
export interface StartedWorld {
world: World;
collisions: CollisionSystem | 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 a CollisionSystem and a BehaviorSystem (fed by those collisions). */
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();
if (collisions) world.addSystem(collisions);
if (options.behaviors) world.addSystem(new BehaviorSystem(fixture.project, input, collisions));
return {
world,
collisions,
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));
}