Destroying a container used to take everything below it along. Now Destroy can remove just the object itself and pass its direct children on, so a behavior can keep working with what was inside. - New input keep children (bool, default off). When on, only the object goes. Its direct children move up to the object's parent, or to the top level if there is none. Grandchildren stay where they are. - New output children (object list) with the direct children that were kept. It is empty when keep children is off. - The children keep their place on screen. carriedTransform folds the destroyed object's position, rotation and scale into their own, the same as reparenting in the editor. - Behaviors and the rigidbody of the object are always destroyed with it and are not in the list. Moved up they would steer or simulate the parent. - World remembers which children it reparented, and syncToModel writes their new parent into the scene. A reload of the model keeps it. - Limit, read from the code only: in the editor run the contents of a placed container are not baked. Moved children there last for the run only. Tests: - ./check.sh: 170 files, 2478 tests, CHECK EXIT 0 - e2e 34 (the module with a Destroy node): ALL PASSED, 21 checks - world-sync: world matrix of child and grandchild unchanged under a rotated and scaled parent, parent survives syncFromModel, top level case, already destroyed object gives [] - destroy-keep-children: real graph Destroy(Owner) -> Log(children) logs Lid, Label without the grandchild Nail - 3 counter-probes, all caught: no world transform (1 test fails), no parent in the scene (1), behaviors and rigidbody moved up (2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
382 lines
14 KiB
TypeScript
382 lines
14 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 { 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,
|
||
VARIABLES_PARAM,
|
||
} 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, type TimeScaled } from '../src/runtime/systems/BehaviorSystem';
|
||
import type { SaveArea } from '../src/core/graph/savedValues';
|
||
import { readyPhysics } from '../src/runtime/physics/engine';
|
||
import { PhysicsSystem } from '../src/runtime/physics/PhysicsSystem';
|
||
import { CollisionSystem } from '../src/runtime/systems/CollisionSystem';
|
||
import type { CollisionSource } from '../src/runtime/systems/solids';
|
||
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 => {
|
||
// A node pointed at a Variables asset declares that asset's variables, not the behavior's.
|
||
const source = fixture.project.assets.find((a) => a.id === params[VARIABLES_PARAM]);
|
||
const declarations = source?.type === 'variables' ? source.variables : behavior.variables;
|
||
const declared = declarations.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[];
|
||
/** The time scale and the pause the context holds, as the game nodes left them. */
|
||
scale: number;
|
||
paused: 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 },
|
||
states: {},
|
||
shared: emptySharedState(),
|
||
changes: [],
|
||
stateChanged: (machineId, from, to) => ctx.changes.push({ machineId, from, to }),
|
||
time: 0,
|
||
dt: 0.5,
|
||
logs,
|
||
stopped: false,
|
||
findByName: () => null,
|
||
isAlive: () => true,
|
||
childByName: () => null,
|
||
isKeyDown: () => false,
|
||
pointer: { ...NO_POINTER },
|
||
hitTest: () => false,
|
||
isTouching: () => false,
|
||
raycast: () => null,
|
||
raycastAll: () => [],
|
||
ignoreOneWay: () => {},
|
||
isTilemap: () => false,
|
||
tileAt: () => null,
|
||
setTile: () => false,
|
||
moveAndSlide: (target, dx, dy) => {
|
||
target.x += dx;
|
||
target.y += dy;
|
||
return { blockedX: false, blockedY: false, hit: false, normal: { x: 0, y: 0 } };
|
||
},
|
||
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: () => {},
|
||
scale: 1,
|
||
paused: false,
|
||
timeScale: () => ctx.scale,
|
||
setTimeScale: (scale) => {
|
||
ctx.scale = scale;
|
||
},
|
||
isPaused: () => ctx.paused,
|
||
setPaused: (paused) => {
|
||
ctx.paused = paused;
|
||
},
|
||
...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;
|
||
/**
|
||
* With `collisions`: the played game's physics engine rather than the editor run's detection
|
||
* (#146). The engine has to be loaded first – `await loadPhysics()` in a `beforeAll`.
|
||
*/
|
||
physics?: boolean;
|
||
animations?: boolean;
|
||
behaviors?: boolean;
|
||
/** Where `Play sound` ends up; without one the run plays to nobody (see `SILENT_AUDIO`). */
|
||
audio?: AudioSink;
|
||
/** The clock `Set time scale` turns; without one the run is treated like the editor run. */
|
||
clock?: TimeScaled;
|
||
/** Where persistent Variables assets keep their values; without one nothing outlives the run. */
|
||
saves?: SaveArea;
|
||
}
|
||
|
||
export interface StartedWorld {
|
||
world: World;
|
||
collisions: CollisionSource | 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: CollisionSource };
|
||
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
|
||
? options.physics
|
||
? new PhysicsSystem(readyPhysics() ?? fail('load the physics engine before starting a world with it'))
|
||
: 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,
|
||
clock: options.clock,
|
||
saves: options.saves,
|
||
})
|
||
: null;
|
||
if (behaviors) world.addSystem(behaviors);
|
||
return {
|
||
world,
|
||
collisions,
|
||
behaviors,
|
||
input,
|
||
frame(dt = 1 / 60) {
|
||
world.tick(dt);
|
||
input.endFrame();
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Both ways a run detects collisions: the editor run's own and the played game's engine (#146).
|
||
* Everything without a rigidbody must come out the same on either.
|
||
*/
|
||
export const COLLISION_SYSTEMS = [
|
||
{ system: 'CollisionSystem', physics: false },
|
||
{ system: 'PhysicsSystem', physics: true },
|
||
] as const;
|
||
|
||
function fail(message: string): never {
|
||
throw new Error(message);
|
||
}
|
||
|
||
/** The project as it comes back after being saved and loaded again. */
|
||
export function roundTrip(project: Project): Project {
|
||
return deserializeProject(serializeProject(project));
|
||
}
|