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, } 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 = {}): 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, params: PropertyBag = {}, ctx: NodeContext = fakeContext(), ): Record { return requireNodeDef(type).run!(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)); }