The Outputs setting is gone. A Sequence now keeps one free output below the last connected one: nothing plugged in is one output, connecting the spare grows a new one, unplugging the bottom edge shrinks it back. It counts the last *connected* port rather than the number of edges, so unplugging a middle one leaves a gap instead of silently dropping an edge. Removing the setting also removes a bug it caused. Typing in the Outputs field did nothing and the arrow keys did nothing, while dragging the value worked: onInput fires per keystroke, the preview writes into node.params, that emits 'project', and Inspector.refreshValues() sees the node's signature change -- the port list is part of it -- so it rebuilds and destroys the row being typed in. A drag survives because it keeps receiving globalpointermove on the old widget. refreshValues() has no focus guard where refreshLive() does; the trap is still there for the next typed field that changes a node's shape. Ports that depend on the edges around a node mean derivePorts needs the graph, not just the params, so resolveDef and portSignature take it too. And a node whose ports are derived cannot know how many it has from its type, so run is handed the definition resolved for it.
242 lines
8.4 KiB
TypeScript
242 lines
8.4 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 { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands';
|
||
import { AddVariableCommand } from '../src/core/commands/variableCommands';
|
||
import {
|
||
ENTITY_HANDLE,
|
||
type EntityHandle,
|
||
type GraphNode,
|
||
type NodeContext,
|
||
type PointerState,
|
||
type Value,
|
||
requireNodeDef,
|
||
type RunResult,
|
||
} 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;
|
||
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;
|
||
},
|
||
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));
|
||
}
|