- Graph: nodes.ts split into nodes/<category>/<Type>.ts (62 files) with shared.ts factories and index.ts registry; NodeContext/values/NodeRegistry separated, writeTyped() replaces duplicated typed writes - Comments: one style (JSDoc on non-obvious exports, // for decisions and traps), wrong/stale comments fixed or removed, ids.ts now real 10 base-36 chars - Core: chainOf, shared worldPosition/editorPosition core, commands/require.ts, commands/naming.ts, util/list.ts, setBagValue/toPosition/propDef; dead code removed (isBehaviorAsset, SCENE_OBJECT_SCHEMA, CommandStack.subscribe, Input.axis, setMode, titleText, World.worldPosition, unused ctor params) - Editor: Inspector split into inspector/ sections, App.ts into Editor.ts + debugHook.ts + shortcuts.ts, Viewport into items.ts + symbols.ts, shared "Add collider" action, unified type labels/swatches - UI toolkit: Widget.trackHover/onPointerRelease, drawControlBox, math helpers, Popup.openBelow, Menu/Dialog/ColorPicker/ListRow in own files, named Theme sizes - Tools: seed-demo/smoke-demo and demo-check project removed, tools/lib (harness, projects, builders), e2e.mjs as runner over tools/e2e/*.mjs sections - Tests: tests/helpers.ts replaces copied fixtures and NodeContext doubles Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
166 lines
7.3 KiB
TypeScript
166 lines
7.3 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 { type EntityHandle, type GraphNode, type NodeContext, type PointerState, type Value, requireNodeDef } from '../src/core/graph';
|
||
import type { BehaviorAsset, SceneAsset } from '../src/core/model/Asset';
|
||
import { findObject, worldPosition } from '../src/core/model/hierarchy';
|
||
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;
|
||
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 object = (id: string) => findObject(sceneOf(), id)!;
|
||
return { ...fixture, sceneId: scene.asset.id, spriteId: sprite.asset.id, sceneOf, object, world: (id) => worldPosition(sceneOf(), 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 { 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 { 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 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 requireNodeDef(type).run!(ctx, inputs, params).outputs ?? {};
|
||
}
|
||
|
||
export interface WorldOptions {
|
||
collisions?: boolean;
|
||
behaviors?: boolean;
|
||
}
|
||
|
||
/** 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 }): { world: World; collisions: CollisionSystem };
|
||
export function startWorld(fixture: SceneFixture, options?: WorldOptions): { world: World; collisions: CollisionSystem | null };
|
||
export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): { world: World; collisions: CollisionSystem | null } {
|
||
const world = World.instantiate(fixture.project, fixture.sceneId);
|
||
const collisions = options.collisions ? new CollisionSystem() : null;
|
||
if (collisions) world.addSystem(collisions);
|
||
if (options.behaviors) world.addSystem(new BehaviorSystem(fixture.project, new Input(), collisions));
|
||
return { world, collisions };
|
||
}
|
||
|
||
/** The project as it comes back after being saved and loaded again. */
|
||
export function roundTrip(project: Project): Project {
|
||
return deserializeProject(serializeProject(project));
|
||
}
|