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:
1 parent
3234eb480f
commit
1eb28314cf
189 files changed
+6231
-5543
No files matched your search
+28
-40
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user