Refactoring: one file per node, consistent comments, shared helpers, split large modules

- 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>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-08 08:37:22 +02:00
1 parent 3234eb480f
commit 1eb28314cf
189 files changed
+6231 -5543

No files matched your search

+28 -40
View File
@@ -6,45 +6,34 @@ import {
RevertOverrideToTemplateCommand,
RevertPositionToTemplateCommand,
} from '../../src/core/commands/applyCommands';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { MoveSceneObjectCommand, PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { MoveSceneObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import type { ContainerAsset } from '../../src/core/model/Asset';
import { templateLinkOf } from '../../src/core/model/container';
import { createEmptyProject, findAsset, findScene } from '../../src/core/model/Project';
import { findAsset } from '../../src/core/model/Project';
import { makeSpriteScene, place } from '../helpers';
/**
* A box asset, a container made from two boxes (a at (100,100), b at (10,0)), and a second
* placed instance of that container. `a` is the original, `a2` its copy in the instance.
* A container made from two boxes (a at (100,100), b at (10,0)) and a second placed instance
* of it. `a` is the original, `a2` its copy in the instance.
*/
function setup() {
const project = createEmptyProject('p', 'Apply');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Box');
stack.execute(scene);
stack.execute(sprite);
const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 100, y: 100 });
const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 10, y: 0 });
stack.execute(a);
stack.execute(b);
const create = new CreateContainerCommand(scene.asset.id, [a.objectId, b.objectId]);
stack.execute(create);
const place = new PlaceAssetCommand(scene.asset.id, create.assetId, { x: 1000, y: 0 });
stack.execute(place);
const sceneOf = () => findScene(project, scene.asset.id)!;
const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!;
const container = () => findAsset(project, create.assetId) as ContainerAsset;
const templateOfA = () => container().objects.find((t) => t.id === object(a.objectId).templateId)!;
const a2 = sceneOf().objects.find((o) => o.parentId === place.objectId && o.templateId === object(a.objectId).templateId)!.id;
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, a2, object, container, templateOfA };
const f = makeSpriteScene();
const a = place(f, f.spriteId, { x: 100, y: 100 });
const b = place(f, f.spriteId, { x: 10, y: 0 });
const create = new CreateContainerCommand(f.sceneId, [a, b]);
f.stack.execute(create);
const instance = place(f, create.assetId, { x: 1000, y: 0 });
const container = () => findAsset(f.project, create.assetId) as ContainerAsset;
const templateOfA = () => container().objects.find((t) => t.id === f.object(a).templateId)!;
const a2 = f.sceneOf().objects.find((o) => o.parentId === instance && o.templateId === f.object(a).templateId)!.id;
return { ...f, a, b, a2, container, templateOfA };
}
describe('template link', () => {
it('resolves the container and template for instance objects only', () => {
const { project, sceneId, a, a2, object, container, templateOfA } = setup();
const scene = findScene(project, sceneId)!;
const { project, a, a2, sceneOf, object, container, templateOfA } = setup();
const scene = sceneOf();
const link = templateLinkOf(project, scene, object(a2));
expect(link?.asset.id).toBe(container().id);
expect(link?.template).toBe(templateOfA());
@@ -89,11 +78,10 @@ describe('position apply / revert', () => {
});
it('rejects objects that are not part of a container', () => {
const { project, stack, sceneId, spriteId } = setup();
const loose = new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 });
stack.execute(loose);
expect(() => stack.execute(new ApplyPositionToContainerCommand(sceneId, loose.objectId))).toThrow(/not part of a placed container/);
expect(findScene(project, sceneId)!.objects.find((o) => o.id === loose.objectId)).toBeDefined();
const f = setup();
const loose = place(f, f.spriteId, { x: 0, y: 0 });
expect(() => f.stack.execute(new ApplyPositionToContainerCommand(f.sceneId, loose))).toThrow(/not part of a placed container/);
expect(f.sceneOf().objects.find((o) => o.id === loose)).toBeDefined();
});
});
@@ -138,14 +126,14 @@ describe('override apply / revert', () => {
});
it('applies to the asset for a plain object too', () => {
const { project, stack, sceneId, spriteId, object } = setup();
const loose = new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 });
stack.execute(loose);
stack.execute(new SetOverrideCommand(sceneId, loose.objectId, 'height', 33));
stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose.objectId, 'height'));
const f = setup();
const { project, stack, sceneId, spriteId, object } = f;
const loose = place(f, spriteId, { x: 0, y: 0 });
stack.execute(new SetOverrideCommand(sceneId, loose, 'height', 33));
stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'height'));
expect(findAsset(project, spriteId)!.props.height).toBe(33);
expect(object(loose.objectId).overrides.height).toBeUndefined();
expect(() => stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose.objectId, 'nope'))).toThrow(/not defined/);
expect(object(loose).overrides.height).toBeUndefined();
expect(() => stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'nope'))).toThrow(/not defined/);
});
it('reverts an override to the template value (or removes it when the template has none)', () => {
+75 -103
View File
@@ -1,17 +1,10 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { createEmptyProject, findScene } from '../../src/core/model/Project';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { findAsset } from '../../src/core/model/Project';
import { axisCorrection, axisSeparation, colliderShape, shapesOverlap } from '../../src/core/properties/collider';
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';
import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
const box = (x: number, y: number, w: number, h: number) => ({ kind: 'box' as const, x, y, w, h });
const circle = (x: number, y: number, r: number) => ({ kind: 'circle' as const, x, y, r });
@@ -56,44 +49,43 @@ describe('collider geometry', () => {
});
});
/** A scene with two crates (100×60) 300 apart, each with a collider child; a behavior on crate A. */
/** Two crates (100×60) 300 apart, each with a collider child; a behavior "Hit" placed below crate a. */
function setup() {
const project = createEmptyProject('p', 'Colliders');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Crate');
const behavior = new CreateAssetCommand('behavior', 'Hit');
for (const c of [scene, sprite, behavior]) stack.execute(c);
sprite.asset.props.height = 60;
const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 300, y: 0 });
stack.execute(a);
stack.execute(b);
const colA = new AddColliderCommand(scene.asset.id, a.objectId);
const colB = new AddColliderCommand(scene.asset.id, b.objectId);
stack.execute(colA);
stack.execute(colB);
const place = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 }, a.objectId);
stack.execute(place);
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, behavior: behavior.asset as BehaviorAsset, a: a.objectId, b: b.objectId, colA, colB };
const f = makeSpriteScene('Crate');
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
const behavior = makeBehavior(f, 'Hit');
const a = place(f, f.spriteId, { x: 0, y: 0 });
const b = place(f, f.spriteId, { x: 300, y: 0 });
const colA = new AddColliderCommand(f.sceneId, a);
const colB = new AddColliderCommand(f.sceneId, b);
f.stack.execute(colA);
f.stack.execute(colB);
place(f, behavior.id, { x: 0, y: 0 }, a);
return { ...f, behavior, a, b, colA, colB };
}
/** The crates in a running world with collision detection. */
function runtime() {
const s = setup();
const { world, collisions } = startWorld(s, { collisions: true });
return { ...s, world, collisions, crateA: world.findByObjectId(s.a)!, crateB: world.findByObjectId(s.b)! };
}
describe('AddColliderCommand', () => {
it('creates a collider asset sized like the sprite, parented to it; undo removes both', () => {
const { project, stack, sceneId, a, colA } = setup();
const asset = project.assets.find((x) => x.id === colA.assetId)!;
const { project, stack, sceneOf, object, a, colA } = setup();
const asset = findAsset(project, colA.assetId)!;
expect(asset.type).toBe('collider');
expect(asset.name).toBe('Crate collider');
expect(asset.props).toMatchObject({ shape: 'box', width: 100, height: 60 });
const object = findScene(project, sceneId)!.objects.find((o) => o.id === colA.objectId)!;
expect(object.parentId).toBe(a);
expect(object.position).toEqual({ x: 0, y: 0, z: 0 });
expect(object(colA.objectId).parentId).toBe(a);
expect(object(colA.objectId).position).toEqual({ x: 0, y: 0, z: 0 });
// Undo of the last three commands: behavior placement, collider B, collider A.
stack.undo();
stack.undo();
stack.undo();
expect(project.assets.some((x) => x.id === colA.assetId)).toBe(false);
expect(findScene(project, sceneId)!.objects.some((o) => o.id === colA.objectId)).toBe(false);
expect(sceneOf().objects.some((o) => o.id === colA.objectId)).toBe(false);
stack.redo();
expect(project.assets.some((x) => x.id === colA.assetId)).toBe(true);
});
@@ -101,19 +93,13 @@ describe('AddColliderCommand', () => {
it('refuses a collider on a collider and survives a round trip', () => {
const { project, stack, sceneId, colA } = setup();
expect(() => stack.execute(new AddColliderCommand(sceneId, colA.objectId))).toThrow(/cannot have a collider/);
const loaded = deserializeProject(serializeProject(project));
expect(loaded.assets.find((x) => x.id === colA.assetId)?.props).toMatchObject({ shape: 'box', width: 100, height: 60, radius: 50 });
expect(findAsset(roundTrip(project), colA.assetId)?.props).toMatchObject({ shape: 'box', width: 100, height: 60, radius: 50 });
});
});
describe('CollisionSystem', () => {
it('reports enter, stay and exit as colliders move (parents carry their colliders)', () => {
const { project, sceneId, a, b } = setup();
const world = World.instantiate(project, sceneId);
const collisions = new CollisionSystem();
world.addSystem(collisions);
const crateA = world.findByObjectId(a)!;
const crateB = world.findByObjectId(b)!;
const { world, collisions, crateA, crateB } = runtime();
world.tick(0.1);
expect(collisions.enters).toHaveLength(0);
crateB.x = 90; // boxes of width 100 overlap once the centres are closer than 100
@@ -132,14 +118,13 @@ describe('CollisionSystem', () => {
});
it('respects overrides and circles; two colliders of the same body never collide', () => {
const { project, stack, sceneId, a, b, colA, colB } = setup();
const s = setup();
const { stack, sceneId, a, b, colA, colB } = s;
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'shape', 'circle'));
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'radius', 30));
const second = new AddColliderCommand(sceneId, a);
stack.execute(second); // a second collider on crate A, overlapping colA completely
const world = World.instantiate(project, sceneId);
const collisions = new CollisionSystem();
world.addSystem(collisions);
const { world, collisions } = startWorld(s, { collisions: true });
world.tick(0.1);
expect(collisions.enters).toHaveLength(0); // colA and the second collider share a body
world.findByObjectId(b)!.x = 75; // circle r=30 at 75 vs box half-width 50 → overlap
@@ -152,14 +137,6 @@ describe('CollisionSystem', () => {
describe('move and slide', () => {
const SKIN = 0.05; // mirrors CollisionSystem: overlaps this shallow count as contact
function runtime() {
const s = setup();
const world = World.instantiate(s.project, s.sceneId);
const collisions = new CollisionSystem();
world.addSystem(collisions);
return { ...s, world, collisions, crateA: world.findByObjectId(s.a)!, crateB: world.findByObjectId(s.b)! };
}
it('stops flush at a solid collider and reports the blocked axis', () => {
const { world, collisions, crateA } = runtime();
// Crates are 100 wide, 300 apart: 250 of travel is possible before they touch (centres 100 apart).
@@ -179,12 +156,11 @@ describe('move and slide', () => {
});
it('triggers report but never block; own and descendant colliders are ignored', () => {
const { project, stack, sceneId, a, b, colB } = setup();
const s = setup();
const { stack, sceneId, a, b, colB } = s;
// Make B's collider a trigger → A passes through, but the CollisionSystem still sees the overlap.
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'isTrigger', true));
const world = World.instantiate(project, sceneId);
const collisions = new CollisionSystem();
world.addSystem(collisions);
const { world, collisions } = startWorld(s, { collisions: true });
const crateA = world.findByObjectId(a)!;
expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(250);
@@ -229,24 +205,22 @@ describe('move and slide', () => {
});
it('the Move node with "Collide with solids" moves the owner and outputs the blocked axes', () => {
const { project, stack, sceneId, behavior, a } = setup();
const s = setup();
const { behavior, a } = s;
// On update → Move(Owner, dx = 1000, dt, collide) → Log(blocked x)
const update = new AddNodeCommand(behavior.id, 'OnUpdate', 0, 0);
const owner = new AddNodeCommand(behavior.id, 'Owner', 0, 0);
const move = new AddNodeCommand(behavior.id, 'Move', 0, 0, { dx: 1000, collide: true });
const log = new AddNodeCommand(behavior.id, 'Log', 0, 0);
for (const c of [update, owner, move, log]) stack.execute(c);
const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
stack.execute(new ConnectCommand(behavior.id, { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
connect(update, 'exec', move, 'exec');
connect(update, 'dt', move, 'dt');
connect(owner, 'object', move, 'object');
connect(move, 'exec', log, 'exec');
connect(move, 'blockedX', log, 'message');
const world = World.instantiate(project, sceneId);
const collisions = new CollisionSystem();
world.addSystem(collisions);
world.addSystem(new BehaviorSystem(project, new Input(), collisions));
const edit = graphEditor(s, behavior);
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const move = edit.addNode('Move', { dx: 1000, collide: true });
const log = edit.addNode('Log');
edit.wire([
[update, 'exec', move, 'exec'],
[update, 'dt', move, 'dt'],
[owner, 'object', move, 'object'],
[move, 'exec', log, 'exec'],
[move, 'blockedX', log, 'message'],
]);
const { world } = startWorld(s, { collisions: true, behaviors: true });
const crateA = world.findByObjectId(a)!;
world.tick(0.1); // 100 units: free
expect(crateA.x).toBe(100);
@@ -258,33 +232,31 @@ describe('move and slide', () => {
describe('collision events in behaviors', () => {
it('fires On collision enter/exit on the body\'s behavior with the other body; Is touching agrees', () => {
const { project, stack, sceneId, behavior, b } = setup();
// Graph: On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B"))
const enter = new AddNodeCommand(behavior.id, 'OnCollisionEnter', 0, 0);
const logEnter = new AddNodeCommand(behavior.id, 'Log', 0, 0);
const exit = new AddNodeCommand(behavior.id, 'OnCollisionExit', 0, 0);
const logExit = new AddNodeCommand(behavior.id, 'Log', 0, 0, { message: 'bye' });
const update = new AddNodeCommand(behavior.id, 'OnUpdate', 0, 0);
const owner = new AddNodeCommand(behavior.id, 'Owner', 0, 0);
const byName = new AddNodeCommand(behavior.id, 'ObjectByName', 0, 0, { name: 'B' });
const touching = new AddNodeCommand(behavior.id, 'IsTouching', 0, 0);
const logTouch = new AddNodeCommand(behavior.id, 'Log', 0, 0);
for (const c of [enter, logEnter, exit, logExit, update, owner, byName, touching, logTouch]) stack.execute(c);
const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
stack.execute(new ConnectCommand(behavior.id, { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
connect(enter, 'exec', logEnter, 'exec');
connect(enter, 'other', logEnter, 'message');
connect(exit, 'exec', logExit, 'exec');
connect(update, 'exec', logTouch, 'exec');
connect(owner, 'object', touching, 'a');
connect(byName, 'object', touching, 'b');
connect(touching, 'result', logTouch, 'message');
findScene(project, sceneId)!.objects.find((o) => o.id === b)!.name = 'B';
const s = setup();
const { behavior, b, object } = s;
// On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B"))
const edit = graphEditor(s, behavior);
const enter = edit.addNode('OnCollisionEnter');
const logEnter = edit.addNode('Log');
const exit = edit.addNode('OnCollisionExit');
const logExit = edit.addNode('Log', { message: 'bye' });
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const byName = edit.addNode('ObjectByName', { name: 'B' });
const touching = edit.addNode('IsTouching');
const logTouch = edit.addNode('Log');
edit.wire([
[enter, 'exec', logEnter, 'exec'],
[enter, 'other', logEnter, 'message'],
[exit, 'exec', logExit, 'exec'],
[update, 'exec', logTouch, 'exec'],
[owner, 'object', touching, 'a'],
[byName, 'object', touching, 'b'],
[touching, 'result', logTouch, 'message'],
]);
object(b).name = 'B';
const world = World.instantiate(project, sceneId);
const collisions = new CollisionSystem();
world.addSystem(collisions);
world.addSystem(new BehaviorSystem(project, new Input(), collisions));
const { world } = startWorld(s, { collisions: true, behaviors: true });
const crateB = world.findByObjectId(b)!;
world.tick(0.1);
expect(world.log).toEqual(['false']);
+44 -54
View File
@@ -7,7 +7,6 @@ import {
SetActiveSceneCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
@@ -15,31 +14,22 @@ import {
RemoveSceneObjectCommand,
SetOverrideCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { createEmptyProject, findScene } from '../../src/core/model/Project';
function setup() {
const project = createEmptyProject('p1', 'Test');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level 1');
const sprite = new CreateAssetCommand('sprite', 'Box');
stack.execute(scene);
stack.execute(sprite);
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id };
}
import { findAsset } from '../../src/core/model/Project';
import { makeSpriteScene, place } from '../helpers';
describe('asset commands', () => {
it('creates assets with defaults and activates the first scene', () => {
const { project, sceneId, spriteId } = setup();
const { project, sceneId, spriteId } = makeSpriteScene();
expect(project.assets).toHaveLength(2);
expect(project.activeSceneId).toBe(sceneId);
expect(project.assets.find((a) => a.id === spriteId)?.props).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f });
expect(findAsset(project, spriteId)?.props).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f });
});
it('renames and sets properties with undo/redo', () => {
const { project, stack, spriteId } = setup();
const { project, stack, spriteId } = makeSpriteScene();
const sprite = () => findAsset(project, spriteId)!;
stack.execute(new RenameAssetCommand(spriteId, 'Crate'));
stack.execute(new SetAssetPropertyCommand(spriteId, 'width', 250));
const sprite = () => project.assets.find((a) => a.id === spriteId)!;
expect(sprite().name).toBe('Crate');
expect(sprite().props.width).toBe(250);
stack.undo();
@@ -53,14 +43,14 @@ describe('asset commands', () => {
});
it('rejects unknown properties', () => {
const { stack, spriteId } = setup();
const { stack, spriteId } = makeSpriteScene();
expect(() => stack.execute(new SetAssetPropertyCommand(spriteId, 'x', 5))).toThrow(/not defined/);
expect(() => stack.execute(new ResetAssetPropertyCommand(spriteId, 'x'))).toThrow(/not defined/);
});
it('resets a property to its schema default with undo', () => {
const { project, stack, spriteId } = setup();
const sprite = () => project.assets.find((a) => a.id === spriteId)!;
const { project, stack, spriteId } = makeSpriteScene();
const sprite = () => findAsset(project, spriteId)!;
stack.execute(new SetAssetPropertyCommand(spriteId, 'height', 60));
stack.execute(new ResetAssetPropertyCommand(spriteId, 'height'));
expect(sprite().props.height).toBe(100);
@@ -71,23 +61,24 @@ describe('asset commands', () => {
});
it('deletes an asset together with its scene objects and restores both on undo', () => {
const { project, stack, sceneId, spriteId } = setup();
stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 1, y: 2 }));
stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 3, y: 4 }));
expect(findScene(project, sceneId)!.objects).toHaveLength(2);
const f = makeSpriteScene();
const { project, stack, spriteId, sceneOf } = f;
place(f, spriteId, { x: 1, y: 2 });
place(f, spriteId, { x: 3, y: 4 });
expect(sceneOf().objects).toHaveLength(2);
stack.execute(new DeleteAssetCommand(spriteId));
expect(project.assets).toHaveLength(1);
expect(findScene(project, sceneId)!.objects).toHaveLength(0);
expect(sceneOf().objects).toHaveLength(0);
stack.undo();
expect(project.assets).toHaveLength(2);
expect(findScene(project, sceneId)!.objects.map((o) => o.position)).toEqual([
expect(sceneOf().objects.map((o) => o.position)).toEqual([
{ x: 1, y: 2, z: 0 },
{ x: 3, y: 4, z: 0 },
]);
});
it('switches the active scene when the active one is deleted', () => {
const { project, stack, sceneId } = setup();
const { project, stack, sceneId } = makeSpriteScene();
const second = new CreateAssetCommand('scene', 'Level 2');
stack.execute(second);
stack.execute(new SetActiveSceneCommand(second.asset.id));
@@ -100,52 +91,51 @@ describe('asset commands', () => {
describe('scene object commands', () => {
it('places assets and refuses scenes', () => {
const { project, stack, sceneId, spriteId } = setup();
const place = new PlaceAssetCommand(sceneId, spriteId, { x: 5, y: 6 });
stack.execute(place);
const object = findScene(project, sceneId)!.objects[0]!;
expect(object.id).toBe(place.objectId);
const f = makeSpriteScene();
const { stack, sceneId, spriteId, sceneOf } = f;
const box = place(f, spriteId, { x: 5, y: 6 });
const object = sceneOf().objects[0]!;
expect(object.id).toBe(box);
expect(object.assetId).toBe(spriteId);
expect(object.position).toEqual({ x: 5, y: 6, z: 0 });
expect(object.overrides).toEqual({});
expect(() => stack.execute(new PlaceAssetCommand(sceneId, sceneId, { x: 0, y: 0 }))).toThrow(/cannot be placed/);
expect(findScene(project, sceneId)!.objects).toHaveLength(1);
expect(sceneOf().objects).toHaveLength(1);
});
it('moves, overrides, clears and removes with undo', () => {
const { project, stack, sceneId, spriteId } = setup();
const place = new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 });
stack.execute(place);
const object = () => findScene(project, sceneId)!.objects.find((o) => o.id === place.objectId)!;
const f = makeSpriteScene();
const { stack, sceneId, spriteId, sceneOf, object } = f;
const box = place(f, spriteId, { x: 0, y: 0 });
stack.execute(new MoveSceneObjectCommand(sceneId, place.objectId, { x: 40, y: 50, z: 3 }));
expect(object().position).toEqual({ x: 40, y: 50, z: 3 });
stack.execute(new MoveSceneObjectCommand(sceneId, box, { x: 40, y: 50, z: 3 }));
expect(object(box).position).toEqual({ x: 40, y: 50, z: 3 });
stack.undo();
expect(object().position).toEqual({ x: 0, y: 0, z: 0 });
expect(object(box).position).toEqual({ x: 0, y: 0, z: 0 });
stack.redo();
stack.execute(new SetOverrideCommand(sceneId, place.objectId, 'color', 0x123456));
expect(object().overrides).toEqual({ color: 0x123456 });
expect(() => stack.execute(new SetOverrideCommand(sceneId, place.objectId, 'nope', 1))).toThrow();
stack.execute(new SetOverrideCommand(sceneId, box, 'color', 0x123456));
expect(object(box).overrides).toEqual({ color: 0x123456 });
expect(() => stack.execute(new SetOverrideCommand(sceneId, box, 'nope', 1))).toThrow();
stack.execute(new ClearOverrideCommand(sceneId, place.objectId, 'color'));
expect(object().overrides).toEqual({});
stack.execute(new ClearOverrideCommand(sceneId, box, 'color'));
expect(object(box).overrides).toEqual({});
stack.undo();
expect(object().overrides).toEqual({ color: 0x123456 });
expect(object(box).overrides).toEqual({ color: 0x123456 });
stack.execute(new RemoveSceneObjectCommand(sceneId, place.objectId));
expect(findScene(project, sceneId)!.objects).toHaveLength(0);
stack.execute(new RemoveSceneObjectCommand(sceneId, box));
expect(sceneOf().objects).toHaveLength(0);
stack.undo();
expect(object().position).toEqual({ x: 40, y: 50, z: 3 });
expect(object(box).position).toEqual({ x: 40, y: 50, z: 3 });
});
it('clears the redo stack on a new command', () => {
const { stack, sceneId, spriteId } = setup();
stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 }));
stack.undo();
expect(stack.canRedo).toBe(true);
stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 1, y: 1 }));
expect(stack.canRedo).toBe(false);
const f = makeSpriteScene();
place(f, f.spriteId, { x: 0, y: 0 });
f.stack.undo();
expect(f.stack.canRedo).toBe(true);
place(f, f.spriteId, { x: 1, y: 1 });
expect(f.stack.canRedo).toBe(false);
});
});
+40 -52
View File
@@ -1,35 +1,22 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { CompoundCommand } from '../../src/core/commands/CompoundCommand';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { MoveSceneObjectCommand, PlaceAssetCommand, RemoveSceneObjectsCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { MoveSceneObjectCommand, RemoveSceneObjectsCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { worldPositionOf } from '../../src/core/graph';
import type { ContainerAsset } from '../../src/core/model/Asset';
import { subtreeRoots, treeOrder, worldPosition } from '../../src/core/model/hierarchy';
import { createEmptyProject, findScene } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { World } from '../../src/runtime/World';
import { subtreeRoots, treeOrder } from '../../src/core/model/hierarchy';
import { findAsset, findScene } from '../../src/core/model/Project';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
/**
* Scene with a sprite asset and three boxes: a at (100,100,z1), b at (10,0) and c at (0,5)
* parented under b (local (-10,5)).
*/
/** Three boxes: a at (100,100,z1), b at (10,0) and c at (0,5) parented under b (local (-10,5)). */
function setup() {
const project = createEmptyProject('p', 'Containers');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Box');
stack.execute(scene);
stack.execute(sprite);
const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 100, y: 100, z: 1 });
const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 10, y: 0 });
const c = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 5 });
for (const cmd of [a, b, c]) stack.execute(cmd);
stack.execute(new SetParentCommand(scene.asset.id, c.objectId, b.objectId));
const sceneOf = () => findScene(project, scene.asset.id)!;
const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!;
const world = (id: string) => worldPosition(sceneOf(), object(id));
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, c: c.objectId, sceneOf, object, world };
const f = makeSpriteScene();
const a = place(f, f.spriteId, { x: 100, y: 100, z: 1 });
const b = place(f, f.spriteId, { x: 10, y: 0 });
const c = place(f, f.spriteId, { x: 0, y: 5 });
f.stack.execute(new SetParentCommand(f.sceneId, c, b));
return { ...f, a, b, c };
}
describe('subtree roots', () => {
@@ -60,7 +47,7 @@ describe('CreateContainerCommand', () => {
expect({ a: world(a), b: world(b), c: world(c) }).toEqual(before);
// The asset holds the same structure with private ids; the members point at it.
const asset = project.assets.find((x) => x.id === command.assetId) as ContainerAsset;
const asset = findAsset(project, command.assetId) as ContainerAsset;
expect(asset.type).toBe('container');
expect(asset.objects).toHaveLength(3);
const template = (id: string) => asset.objects.find((t) => t.id === object(id).templateId)!;
@@ -83,17 +70,17 @@ describe('CreateContainerCommand', () => {
});
it('places the container below the parent all roots share', () => {
const { stack, sceneId, spriteId, b, c, object, world } = setup();
const d = new PlaceAssetCommand(sceneId, spriteId, { x: 30, y: 25 }, b);
stack.execute(d);
const command = new CreateContainerCommand(sceneId, [c, d.objectId]);
const f = setup();
const { stack, sceneId, spriteId, b, c, object, world } = f;
const d = place(f, spriteId, { x: 30, y: 25 }, b);
const command = new CreateContainerCommand(sceneId, [c, d]);
stack.execute(command);
const container = object(command.objectId);
expect(container.parentId).toBe(b);
// World centre of c (0,5) and d (30,25) is (15,15); b is at (10,0) → local (5,15).
expect(container.position).toEqual({ x: 5, y: 15, z: 0 });
expect(world(c)).toEqual({ x: 0, y: 5, z: 0 });
expect(world(d.objectId)).toEqual({ x: 30, y: 25, z: 0 });
expect(world(d)).toEqual({ x: 30, y: 25, z: 0 });
});
it('rejects an empty or unknown selection', () => {
@@ -105,19 +92,19 @@ describe('CreateContainerCommand', () => {
describe('placing a container', () => {
it('instantiates the template with fresh ids below the placed object', () => {
const { project, stack, sceneId, a, b, c, sceneOf, object, world } = setup();
const f = setup();
const { project, stack, sceneId, a, b, c, sceneOf, object, world } = f;
const create = new CreateContainerCommand(sceneId, [a, b]);
stack.execute(create);
const place = new PlaceAssetCommand(sceneId, create.assetId, { x: 1000, y: 0 });
stack.execute(place);
const placed = place(f, create.assetId, { x: 1000, y: 0 });
const copies = sceneOf().objects.filter((o) => ![a, b, c, create.objectId].includes(o.id));
expect(copies).toHaveLength(4); // container + 3 template objects
const root = object(place.objectId);
const root = object(placed);
expect(root.assetId).toBe(create.assetId);
const asset = project.assets.find((x) => x.id === create.assetId) as ContainerAsset;
const asset = findAsset(project, create.assetId) as ContainerAsset;
const copyOf = (id: string) => copies.find((o) => o.templateId === object(id).templateId)!;
expect(copyOf(a).parentId).toBe(place.objectId);
expect(copyOf(a).parentId).toBe(placed);
expect(world(copyOf(a).id)).toEqual({ x: 1045, y: 50, z: 1 });
expect(copyOf(c).parentId).toBe(copyOf(b).id);
expect(world(copyOf(c).id)).toEqual({ x: 945, y: -45, z: 0 });
@@ -126,15 +113,15 @@ describe('placing a container', () => {
stack.undo();
expect(sceneOf().objects).toHaveLength(4);
expect(sceneOf().objects.some((o) => o.id === place.objectId)).toBe(false);
expect(sceneOf().objects.some((o) => o.id === placed)).toBe(false);
});
it('survives a save/load round trip', () => {
const { project, stack, sceneId, a, b } = setup();
const create = new CreateContainerCommand(sceneId, [a, b]);
stack.execute(create);
const loaded = deserializeProject(serializeProject(project));
const asset = loaded.assets.find((x) => x.id === create.assetId) as ContainerAsset;
const loaded = roundTrip(project);
const asset = findAsset(loaded, create.assetId) as ContainerAsset;
expect(asset.type).toBe('container');
expect(asset.objects).toHaveLength(3);
const scene = findScene(loaded, sceneId)!;
@@ -143,43 +130,44 @@ describe('placing a container', () => {
});
it('deleting an asset removes its template objects from containers too', () => {
const { project, stack, sceneId, spriteId, a, b } = setup();
const { project, stack, sceneId, spriteId, a, b, sceneOf } = setup();
const create = new CreateContainerCommand(sceneId, [a, b]);
stack.execute(create);
const asset = () => project.assets.find((x) => x.id === create.assetId) as ContainerAsset;
const asset = () => findAsset(project, create.assetId) as ContainerAsset;
stack.execute(new DeleteAssetCommand(spriteId));
expect(asset().objects).toHaveLength(0);
expect(findScene(project, sceneId)!.objects.map((o) => o.id)).toEqual([create.objectId]);
expect(sceneOf().objects.map((o) => o.id)).toEqual([create.objectId]);
stack.undo();
expect(asset().objects).toHaveLength(3);
expect(findScene(project, sceneId)!.objects).toHaveLength(4);
expect(sceneOf().objects).toHaveLength(4);
});
it('spawns a container with its content at runtime', () => {
const { project, stack, sceneId, a, b } = setup();
const f = setup();
const { stack, sceneId, a, b, sceneOf } = f;
const create = new CreateContainerCommand(sceneId, [a, b]);
stack.execute(create);
const world = World.instantiate(project, sceneId);
const { world } = startWorld(f);
const spawned = world.spawn(create.assetId, { x: 500, y: 500, z: 0 }, null)!;
expect(spawned.type).toBe('container');
const children = world.entities.filter((e) => e.parent === spawned);
expect(children).toHaveLength(2);
const grandchild = world.entities.find((e) => e.parent && children.includes(e.parent))!;
expect(world.worldPosition(children.find((e) => e.z === 1)!)).toEqual({ x: 545, y: 550, z: 1 });
expect(world.worldPosition(grandchild)).toEqual({ x: 445, y: 455, z: 0 });
expect(worldPositionOf(children.find((e) => e.z === 1)!)).toEqual({ x: 545, y: 550, z: 1 });
expect(worldPositionOf(grandchild)).toEqual({ x: 445, y: 455, z: 0 });
expect(world.syncToModel()).toBe(true);
expect(findScene(project, sceneId)!.objects).toHaveLength(8);
expect(sceneOf().objects).toHaveLength(8);
});
});
describe('multi-selection commands', () => {
it('removes several subtrees as one step, covering nested ids', () => {
const { stack, sceneId, a, b, c, sceneOf } = setup();
const { stack, sceneId, a, b, c, sceneOf, object } = setup();
stack.execute(new RemoveSceneObjectsCommand(sceneId, [b, c, a]));
expect(sceneOf().objects).toHaveLength(0);
stack.undo();
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c]);
expect(sceneOf().objects.find((o) => o.id === c)?.parentId).toBe(b);
expect(object(c).parentId).toBe(b);
});
it('a compound move rolls back entirely when one part fails', () => {
+45 -231
View File
@@ -1,30 +1,23 @@
import { describe, expect, it } from 'vitest';
import { buildAsset, CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { PlaceAssetCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { type EntityHandle, isHandleEnabled, requireNodeDef, type NodeContext, type Value } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import type { PropertyBag } from '../../src/core/model/Properties';
import { createEmptyProject, findScene } from '../../src/core/model/Project';
import { footprintOf, textSize } from '../../src/core/properties/bounds';
import { isHandleEnabled } from '../../src/core/graph';
import { findAsset, findScene } from '../../src/core/model/Project';
import { schemaFor } from '../../src/core/properties/PropertySchema';
import { resolveSceneObjectProps } from '../../src/core/properties/resolve';
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';
import { fakeHandle, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, roundTrip, runNode, startWorld } from '../helpers';
/** Runs one node definition in isolation – enough for the small data nodes. */
function run(type: string, inputs: Record<string, Value>, params: PropertyBag = {}, ctx: Partial<NodeContext> = {}): Record<string, Value> {
return requireNodeDef(type).run!(ctx as NodeContext, inputs, params).outputs ?? {};
}
function handle(props: PropertyBag, parent: EntityHandle | null = null): EntityHandle {
return { id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent };
/** A 100×100 wall at x = 200 and a 100×100 mover at the origin, both with box colliders, in a running world. */
function wallAndMover() {
const f = makeSpriteScene();
const moverId = place(f, f.spriteId, { x: 0, y: 0 });
const wallId = place(f, f.spriteId, { x: 200, y: 0 });
const wallCollider = new AddColliderCommand(f.sceneId, wallId);
f.stack.execute(new AddColliderCommand(f.sceneId, moverId));
f.stack.execute(wallCollider);
const { world, collisions } = startWorld(f, { collisions: true });
return { world, collisions, mover: world.findByObjectId(moverId)!, wall: world.findByObjectId(wallCollider.objectId)! };
}
describe('the enabled switch', () => {
@@ -33,47 +26,37 @@ describe('the enabled switch', () => {
expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled']);
expect(schemaFor('scene').some((d) => d.key === 'enabled')).toBe(false);
const project = createEmptyProject('p', 'P');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Box');
stack.execute(scene);
stack.execute(sprite);
const placed = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
stack.execute(placed);
const object = findScene(project, scene.asset.id)!.objects[0]!;
expect(resolveSceneObjectProps(sprite.asset, object).enabled).toBe(true);
const f = makeSpriteScene();
const { project, stack, sceneId, spriteId, object } = f;
const box = place(f, spriteId, { x: 0, y: 0 });
expect(resolveSceneObjectProps(findAsset(project, spriteId)!, object(box)).enabled).toBe(true);
// Switching an instance off is an ordinary override – and survives a round trip.
stack.execute(new SetOverrideCommand(scene.asset.id, placed.objectId, 'enabled', false));
expect(object.overrides).toEqual({ enabled: false });
const loaded = deserializeProject(serializeProject(project), 'p');
expect(findScene(loaded, scene.asset.id)!.objects[0]!.overrides).toEqual({ enabled: false });
stack.execute(new SetOverrideCommand(sceneId, box, 'enabled', false));
expect(object(box).overrides).toEqual({ enabled: false });
expect(findScene(roundTrip(project), sceneId)!.objects[0]!.overrides).toEqual({ enabled: false });
stack.undo();
expect(object.overrides).toEqual({});
expect(object(box).overrides).toEqual({});
});
it('is inherited: a child of a switched-off parent is off as well', () => {
const parent = handle({ enabled: false });
const child = handle({ enabled: true }, parent);
const parent = fakeHandle({ enabled: false });
const child = fakeHandle({ enabled: true }, parent);
expect(isHandleEnabled(child)).toBe(false);
expect(child.props.enabled).toBe(true); // its own switch is untouched
parent.props.enabled = true;
expect(isHandleEnabled(child)).toBe(true);
expect(run('IsEnabled', { object: child })).toEqual({ result: true, own: true });
expect(runNode('IsEnabled', { object: child })).toEqual({ result: true, own: true });
});
it('cannot be shadowed by a behavior variable', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
project.assets.push(behavior);
expect(() => new AddVariableCommand('b', 'enabled', 'number').execute(project)).toThrow();
const f = makeProject();
const behavior = makeBehavior(f);
expect(() => new AddVariableCommand(behavior.id, 'enabled', 'number').execute(f.project)).toThrow();
});
it('takes a collider out of the collision system while it is off', () => {
const { world, wall, mover } = collisionSetup();
const collisions = new CollisionSystem();
world.addSystem(collisions);
const { world, collisions, wall, mover } = wallAndMover();
// The wall blocks: moving 200 to the right stops at 100 units of distance (two 100-wide boxes).
collisions.moveAndSlide(world, mover, 200, 0);
@@ -87,36 +70,28 @@ describe('the enabled switch', () => {
});
it('stops a behavior while it is off and runs On start when it is switched on', () => {
const project = createEmptyProject('p', 'P');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const behavior = new CreateAssetCommand('behavior', 'Counter');
stack.execute(scene);
stack.execute(behavior);
stack.execute(new AddVariableCommand(behavior.asset.id, 'ticks', 'number'));
const start = new AddNodeCommand(behavior.asset.id, 'OnStart', 0, 0);
const update = new AddNodeCommand(behavior.asset.id, 'OnUpdate', 0, 100);
const one = new AddNodeCommand(behavior.asset.id, 'Number', 0, 200, { value: 1 });
const add = new AddNodeCommand(behavior.asset.id, 'Add', 100, 200);
const get = new AddNodeCommand(behavior.asset.id, 'GetVariable', 0, 300, { variable: 'ticks' });
const set = new AddNodeCommand(behavior.asset.id, 'SetVariable', 200, 100, { variable: 'ticks' });
for (const c of [start, update, one, add, get, set]) stack.execute(c);
for (const [from, fromPort, to, toPort] of [
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'Counter', ['ticks']);
// On start and On update both add one to `ticks`.
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const update = edit.addNode('OnUpdate');
const one = edit.addNode('Number', { value: 1 });
const add = edit.addNode('Add');
const get = edit.addNode('GetVariable', { variable: 'ticks' });
const set = edit.addNode('SetVariable', { variable: 'ticks' });
edit.wire([
[update, 'exec', set, 'exec'],
[get, 'value', add, 'a'],
[one, 'value', add, 'b'],
[add, 'result', set, 'value'],
[start, 'exec', set, 'exec'],
] as const) {
stack.execute(new ConnectCommand(behavior.asset.id, { node: from.node.id, port: fromPort }, { node: to.node.id, port: toPort }));
}
const placed = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 });
stack.execute(placed);
stack.execute(new SetOverrideCommand(scene.asset.id, placed.objectId, 'enabled', false));
]);
const counter = place(f, behavior.id, { x: 0, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, counter, 'enabled', false));
const world = World.instantiate(project, scene.asset.id);
world.addSystem(new BehaviorSystem(project, new Input(), null));
const entity = world.findByObjectId(placed.objectId)!;
const { world } = startWorld(f, { behaviors: true });
const entity = world.findByObjectId(counter)!;
world.tick(0.1);
world.tick(0.1);
expect(entity.props.ticks).toBe(0); // off: no On start, no ticks
@@ -132,164 +107,3 @@ describe('the enabled switch', () => {
expect(entity.props.ticks).toBe(3); // frozen again, state kept
});
});
describe('text objects and the mouse', () => {
it('measures a text footprint from its content and font size', () => {
expect(textSize({ text: 'ab', fontSize: 100 }).w).toBeCloseTo(110, 5);
expect(textSize({ text: 'ab', fontSize: 100 }).h).toBeCloseTo(125, 5);
expect(footprintOf('text', { text: 'abcd', fontSize: 100 }).w).toBeCloseTo(220, 5);
expect(run('SetText', { object: handle({ text: 'old' }), text: 42 })).toEqual({});
});
it('hit-tests an object through its colliders, else through its sprites and texts', () => {
const project = createEmptyProject('p', 'P');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Button');
stack.execute(scene);
stack.execute(sprite);
const button = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
stack.execute(button);
const label = new CreateAssetCommand('text', 'Label');
stack.execute(label);
const text = new PlaceAssetCommand(scene.asset.id, label.asset.id, { x: 0, y: 200 });
stack.execute(text);
stack.execute(new SetParentCommand(scene.asset.id, text.objectId, button.objectId));
const world = World.instantiate(project, scene.asset.id);
const entity = world.findByObjectId(button.objectId)!;
expect(world.hitTest(entity, 10, 10)).toBe(true); // inside the 100×100 sprite
expect(world.hitTest(entity, 10, 200)).toBe(true); // on the label parented below it
expect(world.hitTest(entity, 400, 400)).toBe(false);
// A collider takes over the hit test for the whole subtree.
stack.execute(new AddColliderCommand(scene.asset.id, button.objectId));
stack.execute(new SetOverrideCommand(scene.asset.id, findScene(project, scene.asset.id)!.objects.at(-1)!.id, 'width', 20));
world.syncFromModel();
expect(world.hitTest(entity, 5, 5)).toBe(true);
expect(world.hitTest(entity, 40, 0)).toBe(false); // outside the 20-wide collider
expect(world.hitTest(entity, 10, 200)).toBe(false); // the label no longer answers
// Switched off, nothing of it can be hit.
entity.props.enabled = false;
expect(world.hitTest(entity, 5, 5)).toBe(false);
});
it('reports pointer edges for exactly one tick', () => {
const project = createEmptyProject('p', 'P');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
stack.execute(scene);
const world = World.instantiate(project, scene.asset.id);
world.movePointer(12, 34, true);
world.setPointerDown(true);
expect(world.pointer).toMatchObject({ x: 12, y: 34, inside: true, down: true, pressed: true });
world.tick(0.1);
expect(world.pointer).toMatchObject({ down: true, pressed: false, released: false });
world.setPointerDown(false);
expect(world.pointer).toMatchObject({ down: false, released: true });
});
});
describe('nodes added for game flow', () => {
it('formats numbers and joins text', () => {
expect(run('ToText', { value: 12.345, decimals: 1 })).toEqual({ text: '12.3' });
expect(run('ToText', { value: 'x', decimals: 0 })).toEqual({ text: 'x' });
expect(run('Join', { a: 'Coins: ', b: 3 })).toEqual({ text: 'Coins: 3' });
expect(run('Modulo', { a: 125, b: 60 })).toEqual({ result: 5 });
expect(run('Modulo', { a: 5, b: 0 })).toEqual({ result: 0 });
expect(run('Floor', { value: 2.9 })).toEqual({ result: 2 });
});
it('compares values and identifies assets', () => {
const coin = handle({});
coin.assetId = 'coin';
expect(run('IsAsset', { object: coin }, { asset: 'coin' })).toEqual({ result: true });
expect(run('IsAsset', { object: coin }, { asset: 'other' })).toEqual({ result: false });
expect(run('IsAsset', { object: null }, { asset: 'coin' })).toEqual({ result: false });
expect(run('Equals', { a: 'a', b: 'a' })).toEqual({ result: true });
expect(run('Equals', { a: coin, b: coin })).toEqual({ result: true });
expect(run('Equals', { a: coin, b: null })).toEqual({ result: false });
expect(run('NameOf', { object: coin })).toEqual({ name: 'h' });
});
it('switches objects on and off and asks for a restart', () => {
const target = handle({ enabled: true });
requireNodeDef('SetEnabled').run!({} as NodeContext, { object: target, enabled: false }, {});
expect(target.props.enabled).toBe(false);
let restarted = false;
requireNodeDef('RestartGame').run!({ restart: () => { restarted = true; } } as NodeContext, {}, {});
expect(restarted).toBe(true);
});
it('fires On click when the mouse is pressed on the owner', () => {
const project = createEmptyProject('p', 'P');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Button');
const behavior = new CreateAssetCommand('behavior', 'OnPress');
for (const c of [scene, sprite, behavior]) stack.execute(c);
stack.execute(new AddVariableCommand(behavior.asset.id, 'clicks', 'number'));
const click = new AddNodeCommand(behavior.asset.id, 'OnClick', 0, 0);
const get = new AddNodeCommand(behavior.asset.id, 'GetVariable', 0, 100, { variable: 'clicks' });
const one = new AddNodeCommand(behavior.asset.id, 'Number', 0, 200, { value: 1 });
const add = new AddNodeCommand(behavior.asset.id, 'Add', 100, 150);
const set = new AddNodeCommand(behavior.asset.id, 'SetVariable', 200, 0, { variable: 'clicks' });
for (const c of [click, get, one, add, set]) stack.execute(c);
for (const [from, fromPort, to, toPort] of [
[click, 'exec', set, 'exec'],
[get, 'value', add, 'a'],
[one, 'value', add, 'b'],
[add, 'result', set, 'value'],
] as const) {
stack.execute(new ConnectCommand(behavior.asset.id, { node: from.node.id, port: fromPort }, { node: to.node.id, port: toPort }));
}
const button = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
stack.execute(button);
const logic = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 });
stack.execute(logic);
stack.execute(new SetParentCommand(scene.asset.id, logic.objectId, button.objectId));
const world = World.instantiate(project, scene.asset.id);
world.addSystem(new BehaviorSystem(project, new Input(), null));
const entity = world.findByObjectId(logic.objectId)!;
world.movePointer(500, 500, true); // beside the button
world.setPointerDown(true);
world.tick(0.1);
expect(entity.props.clicks).toBe(0);
world.setPointerDown(false);
world.movePointer(10, 10, true); // on the button
world.setPointerDown(true);
world.tick(0.1);
expect(entity.props.clicks).toBe(1);
world.tick(0.1); // still held – the press was one frame
expect(entity.props.clicks).toBe(1);
});
});
/** A 100×100 wall at x = 200 and a 100×100 mover at the origin, both with box colliders. */
function collisionSetup() {
const project = createEmptyProject('p', 'P');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Box');
stack.execute(scene);
stack.execute(sprite);
const moverObject = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
const wallObject = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 200, y: 0 });
stack.execute(moverObject);
stack.execute(wallObject);
const moverCollider = new AddColliderCommand(scene.asset.id, moverObject.objectId);
const wallCollider = new AddColliderCommand(scene.asset.id, wallObject.objectId);
stack.execute(moverCollider);
stack.execute(wallCollider);
const world = World.instantiate(project, scene.asset.id);
return {
world,
mover: world.findByObjectId(moverObject.objectId)!,
wall: world.findByObjectId(wallCollider.objectId)!,
};
}
+145 -275
View File
@@ -1,69 +1,36 @@
import { describe, expect, it } from 'vitest';
import { buildAsset } from '../../src/core/commands/assetCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { AddNodeCommand, ConnectCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import { PlaceAssetCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { type EntityHandle, type Graph, GraphInterpreter, type NodeContext, allNodeDefs, portsCompatible } from '../../src/core/graph';
import { GraphInterpreter, allNodeDefs, findNode, portsCompatible } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { createEmptyProject } from '../../src/core/model/Project';
import { findAsset } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { type ProjectFixture, fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, roundTrip } from '../helpers';
function entity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle {
return { id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null };
/** Adds "On key held(ArrowRight) → Move(owner, dx = speed × dt)" to a project, built through commands like the editor would. */
function withMover<F extends ProjectFixture>(fixture: F) {
const behavior = makeBehavior(fixture, 'Mover', ['speed']);
const edit = graphEditor(fixture, behavior);
const held = edit.addNode('OnKeyHeld', { key: 'ArrowRight' });
const owner = edit.addNode('Owner');
const speed = edit.addNode('GetVariable', { variable: 'speed' });
const mul = edit.addNode('Multiply');
const move = edit.addNode('Move');
edit.wire([
[held, 'exec', move, 'exec'],
[owner, 'object', move, 'object'],
[speed, 'value', mul, 'a'],
[held, 'dt', mul, 'b'],
[mul, 'result', move, 'dx'],
]);
return { ...fixture, behavior, edit, nodes: { held, owner, speed, mul, move } };
}
const NO_POINTER = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false };
function context(overrides: Partial<NodeContext> = {}): NodeContext & { logs: string[]; stopped: boolean } {
const logs: string[] = [];
const ctx = {
self: entity('self'),
owner: entity('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: EntityHandle, dx: number, dy: number) => {
target.x += dx;
target.y += dy;
return { blockedX: false, blockedY: false };
},
log: (m: string) => logs.push(m),
spawn: () => null,
destroy: () => {},
stop: () => { ctx.stopped = true; },
restart: () => {},
...overrides,
};
return ctx;
}
/** Builds "On key held → Move(owner, dx = speed × dt)" through commands, like the editor would. */
function buildMoverProject() {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'Mover', 'mover') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
stack.execute(new AddVariableCommand('mover', 'speed', 'number'));
const held = new AddNodeCommand('mover', 'OnKeyHeld', 0, 0, { key: 'ArrowRight' });
const owner = new AddNodeCommand('mover', 'Owner', 0, 100);
const speed = new AddNodeCommand('mover', 'GetVariable', 0, 200, { variable: 'speed' });
const mul = new AddNodeCommand('mover', 'Multiply', 200, 150);
const move = new AddNodeCommand('mover', 'Move', 400, 0);
for (const c of [held, owner, speed, mul, move]) stack.execute(c);
stack.execute(new ConnectCommand('mover', { node: held.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' }));
stack.execute(new ConnectCommand('mover', { node: owner.node.id, port: 'object' }, { node: move.node.id, port: 'object' }));
stack.execute(new ConnectCommand('mover', { node: speed.node.id, port: 'value' }, { node: mul.node.id, port: 'a' }));
stack.execute(new ConnectCommand('mover', { node: held.node.id, port: 'dt' }, { node: mul.node.id, port: 'b' }));
stack.execute(new ConnectCommand('mover', { node: mul.node.id, port: 'result' }, { node: move.node.id, port: 'dx' }));
return { project, behavior, stack, nodes: { held, owner, speed, mul, move } };
/** A behavior "B" with the given number variables and an empty graph to edit. */
function emptyBehavior(variables: string[] = []) {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', variables);
return { ...fixture, behavior, edit: graphEditor(fixture, behavior) };
}
describe('node registry', () => {
@@ -92,9 +59,9 @@ describe('node registry', () => {
describe('GraphInterpreter', () => {
it('runs an event pulse and pulls data lazily (speed × dt moves the owner)', () => {
const { behavior } = buildMoverProject();
const { behavior } = withMover(makeProject());
const interpreter = new GraphInterpreter(behavior.graph);
const ctx = context({ variables: { speed: 100 } });
const ctx = fakeContext({ variables: { speed: 100 } });
const fired = interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight');
expect(fired).toBe(1);
expect(ctx.owner!.x).toBe(50);
@@ -103,66 +70,50 @@ describe('GraphInterpreter', () => {
});
it('uses inline values for unconnected inputs', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
const self = new AddNodeCommand('b', 'Self', 0, 0);
const move = new AddNodeCommand('b', 'Move', 0, 0, { dx: 7, dy: -3 });
for (const c of [start, self, move]) stack.execute(c);
stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' }));
stack.execute(new ConnectCommand('b', { node: self.node.id, port: 'object' }, { node: move.node.id, port: 'object' }));
const ctx = context();
const { behavior, edit } = emptyBehavior();
const start = edit.addNode('OnStart');
const self = edit.addNode('Self');
const move = edit.addNode('Move', { dx: 7, dy: -3 });
edit.wire([[start, 'exec', move, 'exec'], [self, 'object', move, 'object']]);
const ctx = fakeContext();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
expect(ctx.self.x).toBe(7);
expect(ctx.self.y).toBe(-3);
});
it('uses port defaults for unconnected inputs (Move scale = 1, Clamp 0…1)', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
const self = new AddNodeCommand('b', 'Self', 0, 0);
const clamp = new AddNodeCommand('b', 'Clamp', 0, 0, { value: 5 });
const move = new AddNodeCommand('b', 'Move', 0, 0, { dx: 4 });
for (const c of [start, self, clamp, move]) stack.execute(c);
stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' }));
stack.execute(new ConnectCommand('b', { node: self.node.id, port: 'object' }, { node: move.node.id, port: 'object' }));
stack.execute(new ConnectCommand('b', { node: clamp.node.id, port: 'result' }, { node: move.node.id, port: 'dy' }));
const ctx = context();
const { behavior, edit } = emptyBehavior();
const start = edit.addNode('OnStart');
const self = edit.addNode('Self');
const clamp = edit.addNode('Clamp', { value: 5 });
const move = edit.addNode('Move', { dx: 4 });
edit.wire([[start, 'exec', move, 'exec'], [self, 'object', move, 'object'], [clamp, 'result', move, 'dy']]);
const ctx = fakeContext();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0
expect(ctx.self.y).toBe(1); // clamp(5, 0, 1)
});
it('five-node mover: Axis input → Move(dt from On update) moves speed × dt, diagonals normalized', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'Mover', 'mover') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
stack.execute(new AddVariableCommand('mover', 'speed', 'number'));
const update = new AddNodeCommand('mover', 'OnUpdate', 0, 0);
const owner = new AddNodeCommand('mover', 'Owner', 0, 0);
const axis = new AddNodeCommand('mover', 'AxisInput', 0, 0);
const speed = new AddNodeCommand('mover', 'GetVariable', 0, 0, { variable: 'speed' });
const move = new AddNodeCommand('mover', 'Move', 0, 0);
for (const c of [update, owner, axis, speed, move]) stack.execute(c);
const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
stack.execute(new ConnectCommand('mover', { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
connect(update, 'exec', move, 'exec');
connect(update, 'dt', move, 'dt');
connect(owner, 'object', move, 'object');
connect(axis, 'x', move, 'dx');
connect(axis, 'y', move, 'dy');
connect(speed, 'value', move, 'scale');
const { behavior, edit } = emptyBehavior(['speed']);
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const axis = edit.addNode('AxisInput');
const speed = edit.addNode('GetVariable', { variable: 'speed' });
const move = edit.addNode('Move');
edit.wire([
[update, 'exec', move, 'exec'],
[update, 'dt', move, 'dt'],
[owner, 'object', move, 'object'],
[axis, 'x', move, 'dx'],
[axis, 'y', move, 'dy'],
[speed, 'value', move, 'scale'],
]);
expect(behavior.graph.nodes).toHaveLength(5);
const interpreter = new GraphInterpreter(behavior.graph);
const run = (keys: string[]) => {
const ctx = context({ variables: { speed: 100 }, dt: 0.5, isKeyDown: (k) => keys.includes(k) });
const ctx = fakeContext({ variables: { speed: 100 }, dt: 0.5, isKeyDown: (k) => keys.includes(k) });
interpreter.fire('OnUpdate', ctx, { dt: 0.5 });
return { x: ctx.owner!.x, y: ctx.owner!.y };
};
@@ -178,140 +129,121 @@ describe('GraphInterpreter', () => {
});
it('vector and math helpers', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
const norm = new AddNodeCommand('b', 'Normalize', 0, 0, { x: 3, y: 4 });
const len = new AddNodeCommand('b', 'Length', 0, 0, { x: 3, y: 4 });
const sqrt = new AddNodeCommand('b', 'Sqrt', 0, 0, { value: 16 });
const abs = new AddNodeCommand('b', 'Abs', 0, 0, { value: -2 });
const min = new AddNodeCommand('b', 'Min', 0, 0, { a: 3, b: -1 });
const max = new AddNodeCommand('b', 'Max', 0, 0, { a: 3, b: -1 });
const logs = [norm, len, sqrt, abs, min, max].map(() => new AddNodeCommand('b', 'Log', 0, 0));
for (const c of [start, norm, len, sqrt, abs, min, max, ...logs]) stack.execute(c);
const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
stack.execute(new ConnectCommand('b', { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
const { behavior, edit } = emptyBehavior();
const start = edit.addNode('OnStart');
const sources = [
edit.addNode('Normalize', { x: 3, y: 4 }),
edit.addNode('Length', { x: 3, y: 4 }),
edit.addNode('Sqrt', { value: 16 }),
edit.addNode('Abs', { value: -2 }),
edit.addNode('Min', { a: 3, b: -1 }),
edit.addNode('Max', { a: 3, b: -1 }),
];
const ports = ['x', 'result', 'result', 'result', 'result', 'result'];
// One Log per source, chained after On start.
let previous = start;
for (const log of logs) {
connect(previous, 'exec', log, 'exec');
sources.forEach((source, i) => {
const log = edit.addNode('Log');
edit.wire([[previous, 'exec', log, 'exec'], [source, ports[i]!, log, 'message']]);
previous = log;
}
connect(norm, 'x', logs[0]!, 'message');
connect(len, 'result', logs[1]!, 'message');
connect(sqrt, 'result', logs[2]!, 'message');
connect(abs, 'result', logs[3]!, 'message');
connect(min, 'result', logs[4]!, 'message');
connect(max, 'result', logs[5]!, 'message');
const ctx = context();
});
const ctx = fakeContext();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
expect(ctx.logs).toEqual(['0.6', '5', '4', '2', '-1', '3']);
});
it('follows Branch, writes variables and logs', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
stack.execute(new AddVariableCommand('b', 'count', 'number'));
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
const get = new AddNodeCommand('b', 'GetVariable', 0, 0, { variable: 'count' });
const cmp = new AddNodeCommand('b', 'Compare', 0, 0, { op: '<', b: 3 });
const branch = new AddNodeCommand('b', 'Branch', 0, 0);
const add = new AddNodeCommand('b', 'Add', 0, 0, { b: 1 });
const set = new AddNodeCommand('b', 'SetVariable', 0, 0, { variable: 'count' });
const logThen = new AddNodeCommand('b', 'Log', 0, 0, { message: 'then' });
const logElse = new AddNodeCommand('b', 'Log', 0, 0, { message: 'else' });
for (const c of [start, get, cmp, branch, add, set, logThen, logElse]) stack.execute(c);
const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
stack.execute(new ConnectCommand('b', { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
connect(start, 'exec', branch, 'exec');
connect(get, 'value', cmp, 'a');
connect(cmp, 'result', branch, 'condition');
connect(branch, 'then', set, 'exec');
connect(get, 'value', add, 'a');
connect(add, 'result', set, 'value');
connect(set, 'exec', logThen, 'exec');
connect(branch, 'else', logElse, 'exec');
const { behavior, edit } = emptyBehavior(['count']);
const start = edit.addNode('OnStart');
const get = edit.addNode('GetVariable', { variable: 'count' });
const cmp = edit.addNode('Compare', { op: '<', b: 3 });
const branch = edit.addNode('Branch');
const add = edit.addNode('Add', { b: 1 });
const set = edit.addNode('SetVariable', { variable: 'count' });
const logThen = edit.addNode('Log', { message: 'then' });
const logElse = edit.addNode('Log', { message: 'else' });
edit.wire([
[start, 'exec', branch, 'exec'],
[get, 'value', cmp, 'a'],
[cmp, 'result', branch, 'condition'],
[branch, 'then', set, 'exec'],
[get, 'value', add, 'a'],
[add, 'result', set, 'value'],
[set, 'exec', logThen, 'exec'],
[branch, 'else', logElse, 'exec'],
]);
const interpreter = new GraphInterpreter(behavior.graph);
const ctx = context({ variables: { count: 0 } });
const ctx = fakeContext({ variables: { count: 0 } });
for (let i = 0; i < 5; i++) interpreter.fire('OnStart', ctx);
expect(ctx.variables.count).toBe(3);
expect(ctx.logs).toEqual(['then', 'then', 'then', 'else', 'else']);
});
it('reports traversed edges and nodes to the trace listener', () => {
const { behavior, nodes } = buildMoverProject();
const { behavior, nodes } = withMover(makeProject());
const interpreter = new GraphInterpreter(behavior.graph);
const edges: string[] = [];
const visited: string[] = [];
interpreter.trace = { onEdge: (id) => edges.push(id), onNode: (id) => visited.push(id) };
interpreter.fire('OnKeyHeld', context(), { dt: 1 });
interpreter.fire('OnKeyHeld', fakeContext(), { dt: 1 });
expect(edges).toHaveLength(1);
expect(visited).toContain(nodes.mul.node.id);
expect(visited).toContain(nodes.move.node.id);
expect(visited).toContain(nodes.mul.id);
expect(visited).toContain(nodes.move.id);
});
it('stops a runaway flow instead of hanging', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
const a = new AddNodeCommand('b', 'Sequence', 0, 0);
const b = new AddNodeCommand('b', 'Sequence', 0, 0);
for (const c of [start, a, b]) stack.execute(c);
stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: a.node.id, port: 'exec' }));
stack.execute(new ConnectCommand('b', { node: a.node.id, port: 'a' }, { node: b.node.id, port: 'exec' }));
stack.execute(new ConnectCommand('b', { node: b.node.id, port: 'a' }, { node: a.node.id, port: 'exec' }));
expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', context())).toThrow(/step limit/);
const { behavior, edit } = emptyBehavior();
const start = edit.addNode('OnStart');
const a = edit.addNode('Sequence');
const b = edit.addNode('Sequence');
edit.wire([[start, 'exec', a, 'exec'], [a, 'a', b, 'exec'], [b, 'a', a, 'exec']]);
expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(/step limit/);
});
});
describe('graph commands', () => {
it('rejects incompatible, self and duplicate connections', () => {
const { stack, nodes } = buildMoverProject();
expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'exec' }, { node: nodes.mul.node.id, port: 'a' }))).toThrow(/Cannot connect/);
expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.mul.node.id, port: 'result' }, { node: nodes.mul.node.id, port: 'a' }))).toThrow(/itself/);
expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'exec' }, { node: nodes.move.node.id, port: 'exec' }))).toThrow(/Already/);
expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'nope' }, { node: nodes.move.node.id, port: 'exec' }))).toThrow(/Port/);
const { edit, nodes: { held, mul, move } } = withMover(makeProject());
expect(() => edit.connect(held, 'exec', mul, 'a')).toThrow(/Cannot connect/);
expect(() => edit.connect(mul, 'result', mul, 'a')).toThrow(/itself/);
expect(() => edit.connect(held, 'exec', move, 'exec')).toThrow(/Already/);
expect(() => edit.connect(held, 'nope', move, 'exec')).toThrow(/Port/);
});
it('replaces the edge of a data input and restores it on undo', () => {
const { behavior, stack, nodes } = buildMoverProject();
const literal = new AddNodeCommand('mover', 'Number', 0, 0, { value: 9 });
stack.execute(literal);
const { behavior, stack, edit, nodes } = withMover(makeProject());
const literal = edit.addNode('Number', { value: 9 });
const before = behavior.graph.edges.length;
stack.execute(new ConnectCommand('mover', { node: literal.node.id, port: 'value' }, { node: nodes.move.node.id, port: 'dx' }));
edit.connect(literal, 'value', nodes.move, 'dx');
expect(behavior.graph.edges.length).toBe(before); // one replaced, one added
expect(behavior.graph.edges.find((e) => e.to.node === nodes.move.node.id && e.to.port === 'dx')!.from.node).toBe(literal.node.id);
const sourceOfDx = () => behavior.graph.edges.find((e) => e.to.node === nodes.move.id && e.to.port === 'dx')!.from.node;
expect(sourceOfDx()).toBe(literal.id);
stack.undo();
expect(behavior.graph.edges.find((e) => e.to.node === nodes.move.node.id && e.to.port === 'dx')!.from.node).toBe(nodes.mul.node.id);
expect(sourceOfDx()).toBe(nodes.mul.id);
});
it('removing a node drops its edges; undo restores both', () => {
const { behavior, stack, nodes } = buildMoverProject();
const { behavior, stack, nodes } = withMover(makeProject());
const edgesBefore = behavior.graph.edges.length;
stack.execute(new RemoveNodeCommand('mover', nodes.mul.node.id));
expect(behavior.graph.nodes.some((n) => n.id === nodes.mul.node.id)).toBe(false);
stack.execute(new RemoveNodeCommand(behavior.id, nodes.mul.id));
expect(findNode(behavior.graph, nodes.mul.id)).toBeUndefined();
expect(behavior.graph.edges.length).toBe(edgesBefore - 3);
stack.undo();
expect(behavior.graph.nodes.some((n) => n.id === nodes.mul.node.id)).toBe(true);
expect(findNode(behavior.graph, nodes.mul.id)).toBeDefined();
expect(behavior.graph.edges.length).toBe(edgesBefore);
stack.execute(new DisconnectCommand('mover', behavior.graph.edges[0]!.id));
stack.execute(new DisconnectCommand(behavior.id, behavior.graph.edges[0]!.id));
expect(behavior.graph.edges.length).toBe(edgesBefore - 1);
stack.undo();
expect(behavior.graph.edges.length).toBe(edgesBefore);
});
it('coerces declared params and keeps inline input values', () => {
const { behavior, stack, nodes } = buildMoverProject();
stack.execute(new SetNodeParamCommand('mover', nodes.held.node.id, 'key', 'ArrowLeft'));
stack.execute(new SetNodeParamCommand('mover', nodes.move.node.id, 'dy', 4));
const held = behavior.graph.nodes.find((n) => n.id === nodes.held.node.id)!;
const move = behavior.graph.nodes.find((n) => n.id === nodes.move.node.id)!;
const { behavior, stack, nodes } = withMover(makeProject());
stack.execute(new SetNodeParamCommand(behavior.id, nodes.held.id, 'key', 'ArrowLeft'));
stack.execute(new SetNodeParamCommand(behavior.id, nodes.move.id, 'dy', 4));
const held = findNode(behavior.graph, nodes.held.id)!;
const move = findNode(behavior.graph, nodes.move.id)!;
expect(held.params.key).toBe('ArrowLeft');
expect(move.params.dy).toBe(4);
stack.undo();
@@ -319,110 +251,48 @@ describe('graph commands', () => {
});
});
describe('variables', () => {
describe('variable commands', () => {
it('adds, renames (including graph references and overrides) and removes variables', () => {
const { project, behavior, stack, nodes } = buildMoverProject();
const scene = buildAsset('scene', 'S', 's');
project.assets.push(scene);
const place = new PlaceAssetCommand('s', 'mover', { x: 0, y: 0 });
stack.execute(place);
const object = (scene as { objects: { id: string; overrides: Record<string, unknown> }[] }).objects[0]!;
object.overrides.speed = 5;
const f = withMover(makeSpriteScene());
const { behavior, stack, nodes, object } = f;
const logic = place(f, behavior.id, { x: 0, y: 0 });
object(logic).overrides.speed = 5;
stack.execute(new RenameVariableCommand('mover', 'speed', 'velocity'));
stack.execute(new RenameVariableCommand(behavior.id, 'speed', 'velocity'));
expect(behavior.variables[0]!.key).toBe('velocity');
expect(behavior.props).toEqual({ enabled: true, velocity: 0 });
expect(object.overrides).toEqual({ velocity: 5 });
expect(behavior.graph.nodes.find((n) => n.id === nodes.speed.node.id)!.params.variable).toBe('velocity');
expect(object(logic).overrides).toEqual({ velocity: 5 });
expect(findNode(behavior.graph, nodes.speed.id)!.params.variable).toBe('velocity');
stack.undo();
expect(behavior.variables[0]!.key).toBe('speed');
expect(object.overrides).toEqual({ speed: 5 });
expect(object(logic).overrides).toEqual({ speed: 5 });
expect(() => stack.execute(new AddVariableCommand('mover', 'speed', 'number'))).toThrow(/already/);
expect(() => stack.execute(new AddVariableCommand('mover', '1bad', 'number'))).toThrow(/names/);
expect(() => stack.execute(new AddVariableCommand(behavior.id, 'speed', 'number'))).toThrow(/already/);
expect(() => stack.execute(new AddVariableCommand(behavior.id, '1bad', 'number'))).toThrow(/names/);
stack.execute(new RemoveVariableCommand('mover', 'speed'));
stack.execute(new RemoveVariableCommand(behavior.id, 'speed'));
expect(behavior.variables).toHaveLength(0);
expect(object.overrides).toEqual({});
expect(object(logic).overrides).toEqual({});
stack.undo();
expect(behavior.variables).toHaveLength(1);
expect(object.overrides).toEqual({ speed: 5 });
});
it('the owner is the parent: validated, kept across a round trip, dropped when dangling', () => {
const project = createEmptyProject('p', 'P');
const scene = buildAsset('scene', 'S', 's');
const sprite = buildAsset('sprite', 'Box', 'box');
const behavior = buildAsset('behavior', 'B', 'b');
project.assets.push(scene, sprite, behavior);
const stack = new CommandStack(project);
const placeBox = new PlaceAssetCommand('s', 'box', { x: 0, y: 0 });
const placeB = new PlaceAssetCommand('s', 'b', { x: 0, y: 0 });
stack.execute(placeBox);
stack.execute(placeB);
expect(() => stack.execute(new SetParentCommand('s', placeB.objectId, placeB.objectId))).toThrow(/own parent/);
stack.execute(new SetParentCommand('s', placeB.objectId, placeBox.objectId));
const objectsOf = (p: unknown) => ((p as { assets: { objects: { id: string; parentId: string }[] }[] }).assets[0]!).objects;
expect(objectsOf(project)[1]!.parentId).toBe(placeBox.objectId);
// Round trip keeps the parent; a dangling parent is dropped by the loader.
expect(objectsOf(deserializeProject(serializeProject(project)))[1]!.parentId).toBe(placeBox.objectId);
const raw = serializeProject(project) as { assets: { objects?: { parentId: string }[] }[] };
raw.assets[0]!.objects![1]!.parentId = 'ghost';
expect(objectsOf(deserializeProject(raw))[1]!.parentId).toBe('');
expect(object(logic).overrides).toEqual({ speed: 5 });
});
});
describe('behavior serialization', () => {
it('round-trips variables and graph, dropping unknown node types and dangling edges', () => {
const { project, behavior } = buildMoverProject();
const loaded = deserializeProject(serializeProject(project)).assets.find((a) => a.id === 'mover') as BehaviorAsset;
const { project, behavior } = withMover(makeProject());
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
expect(loaded.variables).toEqual(behavior.variables);
expect(loaded.graph).toEqual(behavior.graph);
expect(loaded.props).toEqual({ enabled: true, speed: 0 });
const raw = serializeProject(project) as { assets: { graph?: { nodes: unknown[]; edges: unknown[] } }[] };
raw.assets[0]!.graph!.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} });
raw.assets[0]!.graph!.edges.push({ id: 'e-bad', from: { node: 'zzz', port: 'exec' }, to: { node: behavior.graph.nodes[0]!.id, port: 'exec' } });
const cleaned = deserializeProject(raw).assets[0] as BehaviorAsset;
const raw = serializeProject(project) as { assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[] };
const rawGraph = raw.assets.find((a) => a.id === behavior.id)!.graph!;
rawGraph.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} });
rawGraph.edges.push({ id: 'e-bad', from: { node: 'zzz', port: 'exec' }, to: { node: behavior.graph.nodes[0]!.id, port: 'exec' } });
const cleaned = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset;
expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length);
expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length);
});
});
describe('Spawn node', () => {
const graph: Graph = {
nodes: [
{ id: 'start', type: 'OnStart', x: 0, y: 0, params: {} },
{ id: 'spawn', type: 'Spawn', x: 0, y: 0, params: { asset: 'crate', x: 5, y: 7 } },
{ id: 'log', type: 'Log', x: 0, y: 0, params: {} },
],
edges: [
{ id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'spawn', port: 'exec' } },
{ id: 'e2', from: { node: 'spawn', port: 'exec' }, to: { node: 'log', port: 'exec' } },
{ id: 'e3', from: { node: 'spawn', port: 'object' }, to: { node: 'log', port: 'message' } },
],
};
it('asks the context for a new instance at the inline position and outputs it', () => {
const calls: unknown[] = [];
const ctx = context({
spawn: (assetId, position, parent) => {
calls.push({ assetId, position, parent });
return entity('Crate', position.x);
},
});
new GraphInterpreter(graph).fire('OnStart', ctx);
expect(calls).toEqual([{ assetId: 'crate', position: { x: 5, y: 7, z: 0 }, parent: null }]);
expect(ctx.logs).toEqual(['Crate']); // the spawned object flowed into Log
});
it('logs a hint and continues when no asset is selected or the asset is unknown', () => {
const ctx = context({ spawn: () => null });
const noAsset: Graph = { ...graph, nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)) };
new GraphInterpreter(noAsset).fire('OnStart', ctx);
expect(ctx.logs).toEqual(['Spawn: no asset selected', '']);
const ctx2 = context({ spawn: () => null });
new GraphInterpreter(graph).fire('OnStart', ctx2);
expect(ctx2.logs[0]).toBe('Spawn: asset crate not found');
});
});
+59 -53
View File
@@ -1,46 +1,37 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { MoveSceneObjectCommand, PlaceAssetCommand, RemoveSceneObjectCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { GraphInterpreter, type NodeContext, worldPositionOf } from '../../src/core/graph';
import { ancestorIdsOf, descendantsOf, localPositionFor, treeOrder, visibleTreeOrder, worldPosition } from '../../src/core/model/hierarchy';
import { createEmptyProject, findScene } from '../../src/core/model/Project';
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { MoveSceneObjectCommand, RemoveSceneObjectCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { type Graph, GraphInterpreter, worldPositionOf } from '../../src/core/graph';
import { ancestorIdsOf, descendantsOf, findObject, localPositionFor, treeOrder, visibleTreeOrder } from '../../src/core/model/hierarchy';
import { findScene } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { World } from '../../src/runtime/World';
import { fakeContext, fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
/** Scene with a sprite asset and three placed boxes: a at (100,100), b at (10,0), c at (0,5). */
function setup() {
const project = createEmptyProject('p', 'Hierarchy');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Box');
stack.execute(scene);
stack.execute(sprite);
const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 100, y: 100, z: 1 });
const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 10, y: 0 });
const c = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 5 });
for (const cmd of [a, b, c]) stack.execute(cmd);
const sceneOf = () => findScene(project, scene.asset.id)!;
const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!;
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, c: c.objectId, sceneOf, object };
/** Three boxes, all top level: a at (100,100,z1), b at (10,0), c at (0,5). */
function threeBoxes() {
const f = makeSpriteScene();
const a = place(f, f.spriteId, { x: 100, y: 100, z: 1 });
const b = place(f, f.spriteId, { x: 10, y: 0 });
const c = place(f, f.spriteId, { x: 0, y: 5 });
return { ...f, a, b, c };
}
describe('scene hierarchy', () => {
it('re-parenting keeps the world position and moves children along with the parent', () => {
const { stack, sceneId, a, b, c, sceneOf, object } = setup();
const { stack, sceneId, a, b, c, sceneOf, object, world } = threeBoxes();
stack.execute(new SetParentCommand(sceneId, b, a));
// b stays at world (10,0): local becomes (-90,-100,-1).
expect(object(b).parentId).toBe(a);
expect(object(b).position).toEqual({ x: -90, y: -100, z: -1 });
expect(worldPosition(sceneOf(), object(b))).toEqual({ x: 10, y: 0, z: 0 });
expect(world(b)).toEqual({ x: 10, y: 0, z: 0 });
stack.execute(new SetParentCommand(sceneId, c, b));
expect(worldPosition(sceneOf(), object(c))).toEqual({ x: 0, y: 5, z: 0 });
expect(world(c)).toEqual({ x: 0, y: 5, z: 0 });
// Moving the root drags the whole subtree.
stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 200, y: 100, z: 1 }));
expect(worldPosition(sceneOf(), object(b))).toEqual({ x: 110, y: 0, z: 0 });
expect(worldPosition(sceneOf(), object(c))).toEqual({ x: 100, y: 5, z: 0 });
expect(world(b)).toEqual({ x: 110, y: 0, z: 0 });
expect(world(c)).toEqual({ x: 100, y: 5, z: 0 });
// Undo restores the previous parent and local position exactly.
stack.undo();
@@ -55,7 +46,7 @@ describe('scene hierarchy', () => {
});
it('refuses cycles and self-parenting', () => {
const { stack, sceneId, a, b, c } = setup();
const { stack, sceneId, a, b, c } = threeBoxes();
stack.execute(new SetParentCommand(sceneId, b, a));
stack.execute(new SetParentCommand(sceneId, c, b));
expect(() => stack.execute(new SetParentCommand(sceneId, a, a))).toThrow(/own parent/);
@@ -64,7 +55,7 @@ describe('scene hierarchy', () => {
});
it('lists objects in tree order and descendants depth-first', () => {
const { stack, sceneId, a, b, c, sceneOf } = setup();
const { stack, sceneId, a, b, c, sceneOf } = threeBoxes();
// Storage order is a, b, c; parent c under a → c is shown right after a, before b.
stack.execute(new SetParentCommand(sceneId, c, a));
expect(treeOrder(sceneOf()).map((e) => [e.object.id, e.depth])).toEqual([[a, 0], [c, 1], [b, 0]]);
@@ -73,7 +64,7 @@ describe('scene hierarchy', () => {
});
it('hides the subtrees of collapsed objects and marks the rows that can fold', () => {
const { stack, sceneId, a, b, c, sceneOf } = setup();
const { stack, sceneId, a, b, c, sceneOf } = threeBoxes();
// a > c > b: two levels, so folding a hides both descendants.
stack.execute(new SetParentCommand(sceneId, c, a));
stack.execute(new SetParentCommand(sceneId, b, c));
@@ -88,7 +79,7 @@ describe('scene hierarchy', () => {
});
it('lists the ancestors of an object nearest first, so a selection can be unfolded', () => {
const { stack, sceneId, a, b, c, sceneOf } = setup();
const { stack, sceneId, a, b, c, sceneOf } = threeBoxes();
stack.execute(new SetParentCommand(sceneId, c, a));
stack.execute(new SetParentCommand(sceneId, b, c));
expect(ancestorIdsOf(sceneOf(), b)).toEqual([c, a]);
@@ -96,32 +87,46 @@ describe('scene hierarchy', () => {
});
it('removing an object or deleting an asset takes the subtree along, undo restores it', () => {
const { project, stack, sceneId, spriteId, a, b, c, sceneOf } = setup();
const behavior = new CreateAssetCommand('behavior', 'Logic');
stack.execute(behavior);
const placeLogic = new PlaceAssetCommand(sceneId, behavior.asset.id, { x: 0, y: 0 }, a);
stack.execute(placeLogic);
expect(sceneOf().objects.find((o) => o.id === placeLogic.objectId)!.position).toEqual({ x: -100, y: -100, z: -1 });
const f = threeBoxes();
const { project, stack, sceneId, spriteId, a, b, c, sceneOf, object } = f;
const logic = place(f, makeBehavior(f, 'Logic').id, { x: 0, y: 0 }, a);
expect(object(logic).position).toEqual({ x: -100, y: -100, z: -1 });
stack.execute(new SetParentCommand(sceneId, b, a));
stack.execute(new RemoveSceneObjectCommand(sceneId, a));
expect(sceneOf().objects.map((o) => o.id)).toEqual([c]);
stack.undo();
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, placeLogic.objectId]);
expect(sceneOf().objects.find((o) => o.id === b)!.parentId).toBe(a);
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, logic]);
expect(object(b).parentId).toBe(a);
// Deleting the sprite asset removes a, b, c – and the behavior parented below a.
stack.execute(new DeleteAssetCommand(spriteId));
expect(sceneOf().objects).toHaveLength(0);
stack.undo();
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, placeLogic.objectId]);
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, logic]);
expect(project.assets.map((x) => x.id)).toContain(spriteId);
});
it('a behavior is parented like any object: validated, kept across a round trip, dropped when dangling', () => {
const f = makeSpriteScene();
const { project, stack, sceneId, object } = f;
const box = place(f, f.spriteId, { x: 0, y: 0 });
const logic = place(f, makeBehavior(f).id, { x: 0, y: 0 });
expect(() => stack.execute(new SetParentCommand(sceneId, logic, logic))).toThrow(/own parent/);
stack.execute(new SetParentCommand(sceneId, logic, box));
expect(object(logic).parentId).toBe(box);
expect(findObject(findScene(roundTrip(project), sceneId)!, logic)!.parentId).toBe(box);
// A dangling parent is dropped by the loader.
const raw = serializeProject(project) as { assets: { objects?: { id: string; parentId: string }[] }[] };
raw.assets[0]!.objects!.find((o) => o.id === logic)!.parentId = 'ghost';
expect(findObject(findScene(deserializeProject(raw), sceneId)!, logic)!.parentId).toBe('');
});
it('the loader accepts legacy ownerId, drops dangling parents and breaks cycles', () => {
const { project, stack, sceneId, a, b, c } = setup();
const { project, stack, sceneId, a, b, c } = threeBoxes();
stack.execute(new SetParentCommand(sceneId, b, a));
const raw = JSON.parse(JSON.stringify(serializeProject(project))) as { assets: { objects?: Record<string, unknown>[] }[] };
const raw = serializeProject(project) as { assets: { objects?: Record<string, unknown>[] }[] };
const objects = raw.assets[0]!.objects!;
// Legacy documents stored the link as `ownerId`.
delete objects[1]!.parentId;
@@ -130,7 +135,7 @@ describe('scene hierarchy', () => {
objects[0]!.parentId = c;
objects[2]!.parentId = a;
const loaded = findScene(deserializeProject(raw), sceneId)!;
const parentOf = (id: string) => loaded.objects.find((o) => o.id === id)!.parentId;
const parentOf = (id: string) => findObject(loaded, id)!.parentId;
expect(parentOf(b)).toBe(a);
expect([parentOf(a), parentOf(c)].filter(Boolean)).toHaveLength(1); // one link of the cycle survives
objects[1]!.parentId = 'ghost';
@@ -141,24 +146,26 @@ describe('scene hierarchy', () => {
describe('runtime hierarchy', () => {
it('entities mirror parents: world positions follow the parent, Destroy takes children along', () => {
const { project, stack, sceneId, a, b, c } = setup();
const f = threeBoxes();
const { stack, sceneId, a, b, c } = f;
stack.execute(new SetParentCommand(sceneId, b, a));
stack.execute(new SetParentCommand(sceneId, c, b));
const world = World.instantiate(project, sceneId);
const { world } = startWorld(f);
const entity = (id: string) => world.findByObjectId(id)!;
expect(entity(b).parent).toBe(entity(a));
expect(world.worldPosition(entity(c))).toEqual({ x: 0, y: 5, z: 0 });
expect(worldPositionOf(entity(c))).toEqual({ x: 0, y: 5, z: 0 });
entity(a).x += 50;
expect(world.worldPosition(entity(c))).toEqual({ x: 50, y: 5, z: 0 });
expect(worldPositionOf(entity(c))).toEqual({ x: 50, y: 5, z: 0 });
expect(worldPositionOf(entity(b))).toEqual({ x: 60, y: 0, z: 0 });
world.remove(entity(a));
expect(world.entities).toHaveLength(0);
});
it('Get position exposes local and world coordinates, Parent walks up', () => {
const parent = { id: 'p', assetId: 'a-p', name: 'p', x: 100, y: 20, z: 0, props: {}, parent: null };
const child = { id: 'c', assetId: 'a-c', name: 'c', x: 5, y: 5, z: 0, props: {}, parent };
const graph = { nodes: [
const parent = { ...fakeEntity('p', 100), y: 20 };
const child = { ...fakeEntity('c', 5), y: 5, parent };
// On start → Log(world x of self) → Log(local x of self) → Log(world x of self's parent)
const graph: Graph = { nodes: [
{ id: 'start', type: 'OnStart', x: 0, y: 0, params: {} },
{ id: 'self', type: 'Self', x: 0, y: 0, params: {} },
{ id: 'pos', type: 'GetPosition', x: 0, y: 0, params: {} },
@@ -178,9 +185,8 @@ describe('runtime hierarchy', () => {
{ id: 'e8', from: { node: 'pos', port: 'x' }, to: { node: 'log2', port: 'message' } },
{ id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } },
] };
const logs: string[] = [];
const ctx: NodeContext = { self: child, owner: parent, variables: {}, time: 0, dt: 0, findByName: () => null, isKeyDown: () => false, pointer: { x: 0, y: 0, inside: false, down: false, pressed: false, released: false }, hitTest: () => false, isTouching: () => false, moveAndSlide: () => ({ blockedX: false, blockedY: false }), log: (m) => logs.push(m), spawn: () => null, destroy: () => {}, stop: () => {}, restart: () => {} };
const ctx = fakeContext({ self: child, owner: parent, variables: {} });
new GraphInterpreter(graph).fire('OnStart', ctx);
expect(logs).toEqual(['105', '5', '100']);
expect(ctx.logs).toEqual(['105', '5', '100']);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import { type Graph, GraphInterpreter } from '../../src/core/graph';
import { fakeContext, fakeEntity, fakeHandle, graphEditor, makeBehavior, makeSpriteScene, place, runNode, startWorld } from '../helpers';
describe('game-flow nodes', () => {
it('formats numbers and joins text', () => {
expect(runNode('ToText', { value: 12.345, decimals: 1 })).toEqual({ text: '12.3' });
expect(runNode('ToText', { value: 'x', decimals: 0 })).toEqual({ text: 'x' });
expect(runNode('Join', { a: 'Coins: ', b: 3 })).toEqual({ text: 'Coins: 3' });
expect(runNode('Modulo', { a: 125, b: 60 })).toEqual({ result: 5 });
expect(runNode('Modulo', { a: 5, b: 0 })).toEqual({ result: 0 });
expect(runNode('Floor', { value: 2.9 })).toEqual({ result: 2 });
});
it('compares values and identifies assets', () => {
const coin = fakeHandle({});
coin.assetId = 'coin';
expect(runNode('IsAsset', { object: coin }, { asset: 'coin' })).toEqual({ result: true });
expect(runNode('IsAsset', { object: coin }, { asset: 'other' })).toEqual({ result: false });
expect(runNode('IsAsset', { object: null }, { asset: 'coin' })).toEqual({ result: false });
expect(runNode('Equals', { a: 'a', b: 'a' })).toEqual({ result: true });
expect(runNode('Equals', { a: coin, b: coin })).toEqual({ result: true });
expect(runNode('Equals', { a: coin, b: null })).toEqual({ result: false });
expect(runNode('NameOf', { object: coin })).toEqual({ name: 'h' });
});
it('switches objects on and off and asks for a restart', () => {
const target = fakeHandle({ enabled: true });
runNode('SetEnabled', { object: target, enabled: false });
expect(target.props.enabled).toBe(false);
let restarted = false;
runNode('RestartGame', {}, {}, fakeContext({ restart: () => { restarted = true; } }));
expect(restarted).toBe(true);
});
it('fires On click when the mouse is pressed on the owner', () => {
const f = makeSpriteScene('Button');
const behavior = makeBehavior(f, 'OnPress', ['clicks']);
// On click → clicks = clicks + 1
const edit = graphEditor(f, behavior);
const click = edit.addNode('OnClick');
const get = edit.addNode('GetVariable', { variable: 'clicks' });
const one = edit.addNode('Number', { value: 1 });
const add = edit.addNode('Add');
const set = edit.addNode('SetVariable', { variable: 'clicks' });
edit.wire([
[click, 'exec', set, 'exec'],
[get, 'value', add, 'a'],
[one, 'value', add, 'b'],
[add, 'result', set, 'value'],
]);
const button = place(f, f.spriteId, { x: 0, y: 0 });
const logic = place(f, behavior.id, { x: 0, y: 0 }, button);
const { world } = startWorld(f, { behaviors: true });
const entity = world.findByObjectId(logic)!;
world.movePointer(500, 500, true); // beside the button
world.setPointerDown(true);
world.tick(0.1);
expect(entity.props.clicks).toBe(0);
world.setPointerDown(false);
world.movePointer(10, 10, true); // on the button
world.setPointerDown(true);
world.tick(0.1);
expect(entity.props.clicks).toBe(1);
world.tick(0.1); // still held – the press was one frame
expect(entity.props.clicks).toBe(1);
});
});
describe('Spawn node', () => {
// On start → Spawn(crate at (5,7)) → Log(the spawned object)
const graph: Graph = {
nodes: [
{ id: 'start', type: 'OnStart', x: 0, y: 0, params: {} },
{ id: 'spawn', type: 'Spawn', x: 0, y: 0, params: { asset: 'crate', x: 5, y: 7 } },
{ id: 'log', type: 'Log', x: 0, y: 0, params: {} },
],
edges: [
{ id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'spawn', port: 'exec' } },
{ id: 'e2', from: { node: 'spawn', port: 'exec' }, to: { node: 'log', port: 'exec' } },
{ id: 'e3', from: { node: 'spawn', port: 'object' }, to: { node: 'log', port: 'message' } },
],
};
it('asks the context for a new instance at the inline position and outputs it', () => {
const calls: unknown[] = [];
const ctx = fakeContext({
spawn: (assetId, position, parent) => {
calls.push({ assetId, position, parent });
return fakeEntity('Crate', position.x);
},
});
new GraphInterpreter(graph).fire('OnStart', ctx);
expect(calls).toEqual([{ assetId: 'crate', position: { x: 5, y: 7, z: 0 }, parent: null }]);
expect(ctx.logs).toEqual(['Crate']); // the spawned object flowed into Log
});
it('logs a hint and continues when no asset is selected or the asset is unknown', () => {
const ctx = fakeContext({ spawn: () => null });
const noAsset: Graph = { ...graph, nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)) };
new GraphInterpreter(noAsset).fire('OnStart', ctx);
expect(ctx.logs).toEqual(['Spawn: no asset selected', '']);
const ctx2 = fakeContext({ spawn: () => null });
new GraphInterpreter(graph).fire('OnStart', ctx2);
expect(ctx2.logs[0]).toBe('Spawn: asset crate not found');
});
});
+7 -17
View File
@@ -1,24 +1,14 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { createEmptyProject } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { deserializeProject } from '../../src/core/store/serialize';
import { makeSpriteScene, place, roundTrip } from '../helpers';
describe('serialize', () => {
it('round-trips a project', () => {
const project = createEmptyProject('p1', 'Round trip');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Box');
stack.execute(scene);
stack.execute(sprite);
const place = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 7, y: 8 });
stack.execute(place);
stack.execute(new SetOverrideCommand(scene.asset.id, place.objectId, 'width', 33));
const copy = deserializeProject(JSON.parse(JSON.stringify(serializeProject(project))));
expect(copy).toEqual(project);
const f = makeSpriteScene();
const box = place(f, f.spriteId, { x: 7, y: 8 });
f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'width', 33));
expect(roundTrip(f.project)).toEqual(f.project);
});
it('repairs broken documents', () => {
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { footprintOf, textSize } from '../../src/core/properties/bounds';
import { fakeHandle, makeSpriteScene, place, runNode, startWorld } from '../helpers';
describe('text objects', () => {
it('measures a text footprint from its content and font size', () => {
expect(textSize({ text: 'ab', fontSize: 100 }).w).toBeCloseTo(110, 5);
expect(textSize({ text: 'ab', fontSize: 100 }).h).toBeCloseTo(125, 5);
expect(footprintOf('text', { text: 'abcd', fontSize: 100 }).w).toBeCloseTo(220, 5);
expect(runNode('SetText', { object: fakeHandle({ text: 'old' }), text: 42 })).toEqual({});
});
});
describe('the mouse', () => {
it('hit-tests an object through its colliders, else through its sprites and texts', () => {
const f = makeSpriteScene('Button');
const { stack, sceneId } = f;
const button = place(f, f.spriteId, { x: 0, y: 0 });
const label = new CreateAssetCommand('text', 'Label');
stack.execute(label);
place(f, label.asset.id, { x: 0, y: 200 }, button);
const { world } = startWorld(f);
const entity = world.findByObjectId(button)!;
expect(world.hitTest(entity, 10, 10)).toBe(true); // inside the 100×100 sprite
expect(world.hitTest(entity, 10, 200)).toBe(true); // on the label parented below it
expect(world.hitTest(entity, 400, 400)).toBe(false);
// A collider takes over the hit test for the whole subtree.
const collider = new AddColliderCommand(sceneId, button);
stack.execute(collider);
stack.execute(new SetOverrideCommand(sceneId, collider.objectId, 'width', 20));
world.syncFromModel();
expect(world.hitTest(entity, 5, 5)).toBe(true);
expect(world.hitTest(entity, 40, 0)).toBe(false); // outside the 20-wide collider
expect(world.hitTest(entity, 10, 200)).toBe(false); // the label no longer answers
// Switched off, nothing of it can be hit.
entity.props.enabled = false;
expect(world.hitTest(entity, 5, 5)).toBe(false);
});
it('reports pointer edges for exactly one tick', () => {
const { world } = startWorld(makeSpriteScene());
world.movePointer(12, 34, true);
world.setPointerDown(true);
expect(world.pointer).toMatchObject({ x: 12, y: 34, inside: true, down: true, pressed: true });
world.tick(0.1);
expect(world.pointer).toMatchObject({ down: true, pressed: false, released: false });
world.setPointerDown(false);
expect(world.pointer).toMatchObject({ down: false, released: true });
});
});
+23 -31
View File
@@ -1,38 +1,28 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { ClearOverrideCommand, PlaceAssetCommand, RemoveSceneObjectCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { createEmptyProject, findScene } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { ClearOverrideCommand, RemoveSceneObjectCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { findScene } from '../../src/core/model/Project';
import { World } from '../../src/runtime/World';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
/** A scene with a 100×100 box asset and two placed boxes. */
/** Two boxes: a at the origin and b at (300,0). */
function setup() {
const project = createEmptyProject('p', 'Sync');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Box');
stack.execute(scene);
stack.execute(sprite);
const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 300, y: 0 });
stack.execute(a);
stack.execute(b);
const sceneOf = () => findScene(project, scene.asset.id)!;
const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!;
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, sceneOf, object };
const f = makeSpriteScene();
const a = place(f, f.spriteId, { x: 0, y: 0 });
const b = place(f, f.spriteId, { x: 300, y: 0 });
return { ...f, a, b };
}
describe('world ↔ model synchronisation (editing while playing)', () => {
it('syncFromModel picks up placed, moved, edited, re-parented and removed objects', () => {
const { project, stack, sceneId, spriteId, a, b, object } = setup();
const world = World.instantiate(project, sceneId);
const f = setup();
const { stack, sceneId, spriteId, a, b, object } = f;
const { world } = startWorld(f);
expect(world.entities.map((e) => e.objectId)).toEqual([a, b]);
// Placed while playing → new entity, existing ones keep their identity.
const entityA = world.findByObjectId(a)!;
const c = new PlaceAssetCommand(sceneId, spriteId, { x: 50, y: 50 });
stack.execute(c);
const c = place(f, spriteId, { x: 50, y: 50 });
world.syncFromModel();
expect(world.entities).toHaveLength(3);
expect(world.findByObjectId(a)).toBe(entityA);
@@ -50,14 +40,15 @@ describe('world ↔ model synchronisation (editing while playing)', () => {
expect(world.findByObjectId(b)!.x).toBe(object(b).position.x);
// Removed while playing → entity gone.
stack.execute(new RemoveSceneObjectCommand(sceneId, c.objectId));
stack.execute(new RemoveSceneObjectCommand(sceneId, c));
world.syncFromModel();
expect(world.entities.map((e) => e.objectId)).toEqual([a, b]);
});
it('syncToModel writes positions, changed values as overrides and destroyed entities back', () => {
const { project, sceneId, a, b, object, sceneOf } = setup();
const world = World.instantiate(project, sceneId);
const f = setup();
const { project, sceneId, a, b, object, sceneOf } = f;
const { world } = startWorld(f);
const entityA = world.findByObjectId(a)!;
entityA.x = 123;
entityA.props.width = 999; // what a Set property node would do
@@ -83,8 +74,9 @@ describe('world ↔ model synchronisation (editing while playing)', () => {
});
it('spawn adds an object to the runtime scene and an entity at once, without resetting others', () => {
const { project, sceneId, spriteId, a, sceneOf } = setup();
const world = World.instantiate(project, sceneId);
const f = setup();
const { sceneId, spriteId, a, sceneOf } = f;
const { world } = startWorld(f);
const entityA = world.findByObjectId(a)!;
entityA.x = 77; // moved earlier in this tick – a spawn must not pull the model value back
@@ -118,14 +110,14 @@ describe('world ↔ model synchronisation (editing while playing)', () => {
});
it('a play-mode copy leaves the original model untouched', () => {
const { project, sceneId, a, object } = setup();
const copy = deserializeProject(serializeProject(project), project.id);
const { project, sceneId, a, object, sceneOf } = setup();
const copy = roundTrip(project);
const world = World.instantiate(copy, sceneId);
world.findByObjectId(a)!.x = 500;
world.remove(world.findByObjectId(a)!);
world.syncToModel();
expect(findScene(copy, sceneId)!.objects).toHaveLength(1);
expect(object(a).position.x).toBe(0);
expect(findScene(project, sceneId)!.objects).toHaveLength(2);
expect(sceneOf().objects).toHaveLength(2);
});
});