Files
feuerwerk/tests/core/enabled.test.ts
T
rootandClaude Opus 5 76aa441dce 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>
2026-09-07 20:52:10 +02:00

296 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)!,
};
}