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

+54 -1
View File
@@ -1,4 +1,6 @@
import { worldPositionOf } from '../core/graph';
import { isHandleEnabled, type PointerState, worldPositionOf } from '../core/graph';
import { footprintContains } from '../core/properties/bounds';
import { colliderShape, shapeContains } from '../core/properties/collider';
import { instantiateContainer } from '../core/model/container';
import { newId } from '../core/ids';
import type { Asset } from '../core/model/Asset';
@@ -23,6 +25,13 @@ export class World {
backgroundColor = 0x000000;
/** Set by a behavior (Stop game node); the editor leaves play mode on the next tick. */
stopRequested = false;
/** Set by a behavior (Restart game node); the editor restarts the scene on the next tick. */
restartRequested = false;
/**
* Mouse state in world units, fed by the game view (the only place with the camera
* transform). `pressed`/`released` last for one tick, like the keyboard edges.
*/
readonly pointer: PointerState = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false };
/** Messages logged by behaviors (Log node), newest last. */
readonly log: string[] = [];
private readonly systems: System[] = [];
@@ -160,6 +169,50 @@ export class World {
tick(dt: number): void {
this.elapsed += dt;
for (const system of this.systems) system.update(this, dt);
// The pointer edges belong to exactly one tick, whatever the systems did with them.
this.pointer.pressed = false;
this.pointer.released = false;
}
/** Game view: where the mouse is (world units) and whether it is over the view at all. */
movePointer(x: number, y: number, inside: boolean): void {
this.pointer.x = x;
this.pointer.y = y;
this.pointer.inside = inside;
}
/** Game view: the mouse button changed. Sets the edge flag the current tick reports. */
setPointerDown(down: boolean): void {
if (down === this.pointer.down) return;
this.pointer.down = down;
if (down) this.pointer.pressed = true;
else this.pointer.released = true;
}
/**
* Whether a world point lies on an entity: on the colliders of its subtree when it has
* any, otherwise on the box of a sprite or text in it. Disabled parts never answer, so a
* hidden screen cannot swallow clicks meant for what is drawn behind it.
*/
hitTest(entity: Entity, x: number, y: number): boolean {
const point = { x, y };
const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e));
const colliders = parts.filter((e) => e.type === 'collider');
if (colliders.length > 0) return colliders.some((c) => shapeContains(colliderShape(c.props, this.worldPosition(c)), point));
return parts.some((e) => (e.type === 'sprite' || e.type === 'text') && footprintContains(e.type, e.props, this.worldPosition(e), point));
}
/** Everything parented below an entity, at any depth. */
descendantsOf(entity: Entity): Entity[] {
const result: Entity[] = [];
const visit = (parent: Entity) => {
for (const child of this.childrenOf(parent)) {
result.push(child);
visit(child);
}
};
visit(entity);
return result;
}
get time(): number {