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

+30
View File
@@ -1,4 +1,5 @@
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { enabledAlongChain } from '../properties/enabled';
import type { PropertyDef } from '../properties/PropertySchema';
import type { PortType } from './Graph';
@@ -11,6 +12,8 @@ export type Value = PropertyValue | EntityHandle | null;
*/
export interface EntityHandle {
id: string;
/** Asset this entity was instantiated from (what `Is asset` compares against). */
assetId: string;
name: string;
/** Position local to `parent` (world position for top-level entities). */
x: number;
@@ -34,6 +37,23 @@ export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z
return result;
}
/** Whether a handle and every ancestor of it are switched on (see `properties/enabled.ts`). */
export function isHandleEnabled(handle: EntityHandle): boolean {
return enabledAlongChain(handle, (h) => h.props, (h) => h.parent);
}
/** Where the mouse is (world units) and what it did this frame. */
export interface PointerState {
x: number;
y: number;
/** Whether the pointer is over the game view at all. */
inside: boolean;
down: boolean;
/** Went down / up during this frame. */
pressed: boolean;
released: boolean;
}
/**
* Services a graph may use while running. Implemented by the runtime (BehaviorSystem) and
* by test doubles. Everything the graph does to the world goes through here, so the core
@@ -51,6 +71,14 @@ export interface NodeContext {
dt: number;
findByName(name: string): EntityHandle | null;
isKeyDown(key: string): boolean;
/** Mouse state of this frame, in world units (see `PointerState`). */
pointer: PointerState;
/**
* Whether a world point lies on `target` – on its colliders when it has any, otherwise on
* its own footprint or that of anything parented below it (so a button made of a container
* with a sprite and a label is hit as a whole).
*/
hitTest(target: EntityHandle, x: number, y: number): boolean;
/**
* Whether any collider of `a` overlaps any collider of `b` right now. A collider entity
* stands for itself; any other entity stands for its direct collider children.
@@ -70,6 +98,8 @@ export interface NodeContext {
spawn(assetId: string, position: { x: number; y: number; z: number }, parent: EntityHandle | null): EntityHandle | null;
destroy(target: EntityHandle): void;
stop(): void;
/** Ends the run and starts it again from the saved scene (everything spawned is lost). */
restart(): void;
}
export interface PortDef {
+195 -1
View File
@@ -1,5 +1,5 @@
import type { PropertyDef } from '../properties/PropertySchema';
import { asBoolean, asEntity, asNumber, asString, type PortDef, registerNode, worldPositionOf } from './NodeRegistry';
import { asBoolean, asEntity, asNumber, asString, isHandleEnabled, type PortDef, registerNode, worldPositionOf } from './NodeRegistry';
/**
* The built-in node set. Each entry is data + a `run` function; everything else (palette,
@@ -38,6 +38,15 @@ const collisionOutputs = (): PortDef[] => [exec(), obj('other'), obj('otherColli
registerNode({ type: 'OnCollisionEnter', label: 'On collision enter', category: 'event', description: 'Runs once when a collider of the owner starts overlapping another collider.', inputs: [], outputs: collisionOutputs(), params: [] });
registerNode({ type: 'OnCollisionStay', label: 'On collision stay', category: 'event', description: 'Runs every frame while a collider of the owner overlaps another collider.', inputs: [], outputs: collisionOutputs(), params: [] });
registerNode({ type: 'OnCollisionExit', label: 'On collision exit', category: 'event', description: 'Runs once when a collider of the owner stops overlapping another collider.', inputs: [], outputs: collisionOutputs(), params: [] });
registerNode({
type: 'OnClick',
label: 'On click',
category: 'event',
description: 'Runs when the mouse is pressed on the owner (or on anything parented below it) in the Game tab.',
inputs: [],
outputs: [exec(), num('x', 'world x'), num('y', 'world y')],
params: [],
});
// --- flow ---------------------------------------------------------------------------------
@@ -76,6 +85,30 @@ registerNode({
params: [],
run: (_ctx, inputs) => ({ outputs: { parent: asEntity(inputs.object)?.parent ?? null } }),
});
registerNode({
type: 'NameOf',
label: 'Name of',
category: 'data',
description: 'The name of an object (empty when there is none).',
inputs: [obj('object')],
outputs: [{ key: 'name', label: 'name', type: 'string' }],
params: [],
run: (_ctx, inputs) => ({ outputs: { name: asEntity(inputs.object)?.name ?? '' } }),
});
registerNode({
type: 'IsAsset',
label: 'Is asset',
category: 'data',
description: 'Whether an object is an instance of the chosen asset – the way to tell what you collided with.',
inputs: [obj('object')],
outputs: [bool('result')],
// Placeholder options: the graph inspector lists the project's placeable assets.
params: [{ key: 'asset', label: 'Asset', kind: 'enum', default: '', options: [] }],
run: (_ctx, inputs, params) => {
const assetId = String(params.asset ?? '');
return { outputs: { result: Boolean(assetId) && asEntity(inputs.object)?.assetId === assetId } };
},
});
registerNode({
type: 'ObjectByName',
label: 'Object by name',
@@ -198,6 +231,124 @@ registerNode({
run: (_c, i) => ({ outputs: { result: Math.min(Math.max(asNumber(i.value), asNumber(i.min)), asNumber(i.max)) } }),
});
registerNode({ type: 'Floor', label: 'Floor', category: 'data', description: 'Rounds down to a whole number.', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.floor(asNumber(i.value)) } }) });
registerNode({ type: 'Ceil', label: 'Ceil', category: 'data', description: 'Rounds up to a whole number.', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.ceil(asNumber(i.value)) } }) });
registerNode({ type: 'Round', label: 'Round', category: 'data', description: 'Rounds to the nearest whole number.', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.round(asNumber(i.value)) } }) });
registerNode({
type: 'Modulo',
label: 'Modulo',
category: 'data',
description: 'Remainder of a ÷ b (0 when b is 0) – seconds within a minute, for instance.',
inputs: [num('a'), { ...num('b'), default: 1 }],
outputs: [num('result')],
params: [],
run: (_c, i) => {
const b = asNumber(i.b);
return { outputs: { result: b === 0 ? 0 : asNumber(i.a) % b } };
},
});
registerNode({
type: 'Random',
label: 'Random',
category: 'data',
description: 'A random number between min and max (min included, max not).',
inputs: [{ ...num('min'), default: 0 }, { ...num('max'), default: 1 }],
outputs: [num('result')],
params: [],
run: (_c, i) => {
const min = asNumber(i.min);
return { outputs: { result: min + Math.random() * (asNumber(i.max) - min) } };
},
});
registerNode({
type: 'Time',
label: 'Time',
category: 'data',
description: 'Seconds since the game started, and the length of the current frame.',
inputs: [],
outputs: [num('time'), num('dt')],
params: [],
run: (ctx) => ({ outputs: { time: ctx.time, dt: ctx.dt } }),
});
// --- data: text ------------------------------------------------------------------------------
registerNode({
type: 'ToText',
label: 'To text',
category: 'data',
description: 'Turns a number into text with a fixed number of decimals.',
inputs: [any('value'), num('decimals')],
outputs: [{ key: 'text', label: 'text', type: 'string' }],
params: [],
run: (_c, i) => {
const value = i.value;
const decimals = Math.max(0, Math.min(10, Math.floor(asNumber(i.decimals))));
return { outputs: { text: typeof value === 'string' ? value : asNumber(value).toFixed(decimals) } };
},
});
registerNode({
type: 'Join',
label: 'Join text',
category: 'data',
description: 'Sticks two values together as text ("Coins: " + 3).',
inputs: [any('a'), any('b')],
outputs: [{ key: 'text', label: 'text', type: 'string' }],
params: [],
run: (_c, i) => ({ outputs: { text: asString(i.a) + asString(i.b) } }),
});
registerNode({
type: 'Equals',
label: 'Equals',
category: 'data',
description: 'Whether two values are the same. Objects compare by identity, everything else as text.',
inputs: [any('a'), any('b')],
outputs: [bool('result')],
params: [],
run: (_c, i) => {
const a = asEntity(i.a);
const b = asEntity(i.b);
if (a || b) return { outputs: { result: a === b } };
return { outputs: { result: asString(i.a) === asString(i.b) } };
},
});
// --- data: mouse ------------------------------------------------------------------------------
registerNode({
type: 'MousePosition',
label: 'Mouse position',
category: 'data',
description: 'Where the mouse is in the Game tab, in world units.',
inputs: [],
outputs: [num('x'), num('y'), bool('inside', 'in view')],
params: [],
run: (ctx) => ({ outputs: { x: ctx.pointer.x, y: ctx.pointer.y, inside: ctx.pointer.inside } }),
});
registerNode({
type: 'MouseButton',
label: 'Mouse button',
category: 'data',
description: 'State of the mouse button: held, and whether it went down or up this frame.',
inputs: [],
outputs: [bool('down'), bool('pressed'), bool('released')],
params: [],
run: (ctx) => ({ outputs: { down: ctx.pointer.down, pressed: ctx.pointer.pressed, released: ctx.pointer.released } }),
});
registerNode({
type: 'IsHovered',
label: 'Is hovered',
category: 'data',
description: 'Whether the mouse is over an object (its colliders, else its own or its children\'s footprint).',
inputs: [obj('object')],
outputs: [bool('result')],
params: [],
run: (ctx, i) => {
const target = asEntity(i.object);
return { outputs: { result: Boolean(target && ctx.pointer.inside && ctx.hitTest(target, ctx.pointer.x, ctx.pointer.y)) } };
},
});
// --- data: vectors -------------------------------------------------------------------------
registerNode({ type: 'Length', label: 'Length', category: 'data', description: 'Length of the vector (x, y).', inputs: [num('x'), num('y')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.hypot(asNumber(i.x), asNumber(i.y)) } }) });
@@ -332,6 +483,48 @@ registerNode({
return { next: 'exec' };
},
});
registerNode({
type: 'SetEnabled',
label: 'Set enabled',
category: 'action',
description: 'Switches an object on or off. A disabled object is not drawn, does not collide and its behavior stops – and so does everything parented below it. This is how screens (title, win, lose) are shown and hidden.',
inputs: [exec(), obj('object'), { ...bool('enabled'), default: true }],
outputs: [exec()],
params: [],
run: (_ctx, i) => {
const target = asEntity(i.object);
if (target) target.props.enabled = asBoolean(i.enabled);
return { next: 'exec' };
},
});
registerNode({
type: 'IsEnabled',
label: 'Is enabled',
category: 'data',
description: 'Whether an object is switched on – "own" is its own switch, "result" also requires every parent to be on.',
inputs: [obj('object')],
outputs: [bool('result'), bool('own', 'own switch')],
params: [],
run: (_ctx, i) => {
const target = asEntity(i.object);
if (!target) return { outputs: { result: false, own: false } };
return { outputs: { result: isHandleEnabled(target), own: target.props.enabled !== false } };
},
});
registerNode({
type: 'SetText',
label: 'Set text',
category: 'action',
description: 'Writes the content of a text object (same as Set property "text").',
inputs: [exec(), obj('object'), any('text')],
outputs: [exec()],
params: [],
run: (_ctx, i) => {
const target = asEntity(i.object);
if (target) target.props.text = asString(i.text);
return { next: 'exec' };
},
});
registerNode({
type: 'Destroy',
label: 'Destroy',
@@ -376,3 +569,4 @@ registerNode({
},
});
registerNode({ type: 'StopGame', label: 'Stop game', category: 'action', description: 'Ends play mode.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.stop(); return {}; } });
registerNode({ type: 'RestartGame', label: 'Restart game', category: 'action', description: 'Starts the scene over from its saved state – everything spawned, moved or destroyed is reset.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.restart(); return {}; } });