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

+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)!,
};
}