Enabled switch, text assets, mouse input and the jump-and-run example game

Engine additions that the example game needed:
- `enabled` as a shared property of every placeable asset (default on, an
  override on the scene object). A switched-off object is not drawn, does not
  collide and its behavior does not run - and neither does anything parented
  below it. Disabled objects are now hidden in the scene view as well, so the
  editor shows what the game shows; they stay in the hierarchy panel, which is
  where they are selected and switched back on.
- Asset type `text` (text, fontSize, color, align), rendered in scene and game.
- Pointer input in the game view: `On click`, `Mouse position`, `Mouse button`,
  `Is hovered`, hit-tested against colliders, else sprite/text footprints.
- New nodes: Set/Is enabled, Set text, Is asset, Name of, Equals, To text,
  Join text, Floor/Ceil/Round, Modulo, Random, Time, Restart game.

Tooling: shared CDP harness (tools/harness.mjs), central project registry with
a guard that keeps destructive scripts off the playground (tools/projects.mjs),
plus smoke suites for the demo scene and the jump-and-run game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-07 20:52:10 +02:00
1 parent 4900d7bbf6
commit 76aa441dce
33 files changed
+1905 -301

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ describe('asset commands', () => {
const { project, sceneId, spriteId } = setup();
expect(project.assets).toHaveLength(2);
expect(project.activeSceneId).toBe(sceneId);
expect(project.assets.find((a) => a.id === spriteId)?.props).toEqual({ width: 100, height: 100, color: 0xe0553f });
expect(project.assets.find((a) => a.id === spriteId)?.props).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f });
});
it('renames and sets properties with undo/redo', () => {
+295
View File
@@ -0,0 +1,295 @@
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 { 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 { 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';
/** 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 };
}
describe('the enabled switch', () => {
it('is a common property of every placeable type, defaults to on and overrides per object', () => {
expect(schemaFor('sprite')[0]).toMatchObject({ key: 'enabled', kind: 'boolean', default: true });
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);
// 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.undo();
expect(object.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);
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 });
});
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();
});
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);
// The wall blocks: moving 200 to the right stops at 100 units of distance (two 100-wide boxes).
collisions.moveAndSlide(world, mover, 200, 0);
expect(mover.x).toBeCloseTo(100, 5);
// Switched off, the wall is not there at all.
mover.x = 0;
wall.props.enabled = false;
collisions.moveAndSlide(world, mover, 200, 0);
expect(mover.x).toBeCloseTo(200, 5);
});
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 [
[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 world = World.instantiate(project, scene.asset.id);
world.addSystem(new BehaviorSystem(project, new Input(), null));
const entity = world.findByObjectId(placed.objectId)!;
world.tick(0.1);
world.tick(0.1);
expect(entity.props.ticks).toBe(0); // off: no On start, no ticks
entity.props.enabled = true; // what "Set enabled" does
world.tick(0.1);
expect(entity.props.ticks).toBe(2); // On start plus this frame's On update
world.tick(0.1);
expect(entity.props.ticks).toBe(3);
entity.props.enabled = false;
world.tick(0.1);
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)!,
};
}
+9 -4
View File
@@ -9,10 +9,12 @@ import type { BehaviorAsset } from '../../src/core/model/Asset';
import { createEmptyProject } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
function entity(name: string, x = 0): EntityHandle {
return { id: name, name, x, y: 0, z: 0, props: {}, parent: null };
function entity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle {
return { id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null };
}
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 = {
@@ -25,6 +27,8 @@ function context(overrides: Partial<NodeContext> = {}): NodeContext & { logs: st
stopped: false,
findByName: () => null,
isKeyDown: () => false,
pointer: { ...NO_POINTER },
hitTest: () => false,
isTouching: () => false,
moveAndSlide: (target: EntityHandle, dx: number, dy: number) => {
target.x += dx;
@@ -35,6 +39,7 @@ function context(overrides: Partial<NodeContext> = {}): NodeContext & { logs: st
spawn: () => null,
destroy: () => {},
stop: () => { ctx.stopped = true; },
restart: () => {},
...overrides,
};
return ctx;
@@ -326,7 +331,7 @@ describe('variables', () => {
stack.execute(new RenameVariableCommand('mover', 'speed', 'velocity'));
expect(behavior.variables[0]!.key).toBe('velocity');
expect(behavior.props).toEqual({ velocity: 0 });
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');
stack.undo();
@@ -373,7 +378,7 @@ describe('behavior serialization', () => {
const loaded = deserializeProject(serializeProject(project)).assets.find((a) => a.id === 'mover') as BehaviorAsset;
expect(loaded.variables).toEqual(behavior.variables);
expect(loaded.graph).toEqual(behavior.graph);
expect(loaded.props).toEqual({ speed: 0 });
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: {} });
+3 -3
View File
@@ -133,8 +133,8 @@ describe('runtime hierarchy', () => {
});
it('Get position exposes local and world coordinates, Parent walks up', () => {
const parent = { id: 'p', name: 'p', x: 100, y: 20, z: 0, props: {}, parent: null };
const child = { id: 'c', name: 'c', x: 5, y: 5, z: 0, props: {}, parent };
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: [
{ id: 'start', type: 'OnStart', x: 0, y: 0, params: {} },
{ id: 'self', type: 'Self', x: 0, y: 0, params: {} },
@@ -156,7 +156,7 @@ describe('runtime hierarchy', () => {
{ 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, isTouching: () => false, moveAndSlide: () => ({ blockedX: false, blockedY: false }), log: (m) => logs.push(m), spawn: () => null, destroy: () => {}, stop: () => {} };
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: () => {} };
new GraphInterpreter(graph).fire('OnStart', ctx);
expect(logs).toEqual(['105', '5', '100']);
});
+3 -3
View File
@@ -15,7 +15,7 @@ describe('PropertySchema', () => {
});
it('provides defaults for every key', () => {
expect(defaultsFor('sprite')).toEqual({ width: 100, height: 100, color: 0xe0553f });
expect(defaultsFor('sprite')).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f });
});
it('coerces values against the definition', () => {
@@ -33,14 +33,14 @@ describe('resolve', () => {
it('fills asset props with defaults and ignores unknown keys', () => {
const sprite = buildAsset('sprite', 'Box');
sprite.props = { width: 50, bogus: 1 };
expect(resolveAssetProps(sprite)).toEqual({ width: 50, height: 100, color: 0xe0553f });
expect(resolveAssetProps(sprite)).toEqual({ enabled: true, width: 50, height: 100, color: 0xe0553f });
});
it('applies sparse overrides on top of asset values', () => {
const sprite = buildAsset('sprite', 'Box');
sprite.props.color = 0x00ff00;
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
expect(resolveSceneObjectProps(sprite, object)).toEqual({ width: 100, height: 100, color: 0x00ff00 });
expect(resolveSceneObjectProps(sprite, object)).toEqual({ enabled: true, width: 100, height: 100, color: 0x00ff00 });
object.overrides.color = 0x0000ff;
expect(resolveSceneObjectProps(sprite, object).color).toBe(0x0000ff);
+1 -1
View File
@@ -56,6 +56,6 @@ describe('serialize', () => {
templateId: '',
});
expect(project.assets[1]!.name).toBe('sprite');
expect(project.assets[1]!.props).toEqual({ width: 10, height: 100, color: 0xe0553f });
expect(project.assets[1]!.props).toEqual({ enabled: true, width: 10, height: 100, color: 0xe0553f });
});
});