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:
1 parent
4900d7bbf6
commit
76aa441dce
33 files changed
+1905
-301
No files matched your search
@@ -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)!,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user