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

+97 -22
View File
@@ -1,12 +1,15 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Rectangle, Text, TextStyle } from 'pixi.js';
import { CompoundCommand } from '../core/commands/CompoundCommand';
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { isHandleEnabled } from '../core/graph';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties';
import { subtreeRoots, treeOrder, worldPosition } from '../core/model/hierarchy';
import { findAsset } from '../core/model/Project';
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, footprintOf, textAnchor } from '../core/properties/bounds';
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
import { type ColliderShape, colliderShape, colliderSize, isTrigger } from '../core/properties/collider';
import { enabledAlongChain } from '../core/properties/enabled';
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { World } from '../runtime/World';
import { Theme, textStyle, Widget, type Size } from '../ui';
@@ -24,12 +27,10 @@ interface DrawItem {
props: PropertyBag;
/** Id of the parent item, empty when top-level. */
parentId: string;
/** Effective switch: this object and all its parents are on (see `properties/enabled.ts`). */
enabled: boolean;
}
/** World-unit size of the behavior symbol (a diamond). */
const BEHAVIOR_SYMBOL = 28;
/** World-unit size of the container symbol (a bracketed square). */
const CONTAINER_SYMBOL = 24;
/** Pixels the pointer must travel before a press on empty space becomes a rubber band. */
const MARQUEE_THRESHOLD = 3;
/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */
@@ -119,6 +120,8 @@ export class Viewport extends Widget {
private readonly gizmos = new Graphics();
private readonly nodes = new Map<string, Graphics>();
private readonly labels = new Map<string, Text>();
/** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */
private readonly texts = new Map<string, Text>();
private playWorld: World | null = null;
/** Edit-mode view: world point at the viewport centre and zoom. */
private view = { x: 0, y: 0, zoom: 1 };
@@ -143,7 +146,13 @@ export class Viewport extends Widget {
this.cursor = 'default';
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (this.game) return;
if (this.game) {
if (e.button === 0) {
this.reportPointer(e);
this.playWorld?.setPointerDown(true);
}
return;
}
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
e.stopPropagation();
@@ -157,6 +166,10 @@ export class Viewport extends Widget {
}
});
this.on('globalpointermove', (e: FederatedPointerEvent) => {
if (this.game) {
this.reportPointer(e);
return;
}
if (this.panning) {
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom;
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom;
@@ -184,6 +197,10 @@ export class Viewport extends Widget {
}
});
const finish = () => {
if (this.game) {
this.playWorld?.setPointerDown(false);
return;
}
this.panning = null;
const marquee = this.marquee;
this.marquee = null;
@@ -231,6 +248,21 @@ export class Viewport extends Widget {
state.on('runtime', () => { if (this.visible) this.redraw(); });
}
/**
* Game view: hands the mouse position to the running world in world units, so graphs can
* use it (Mouse position, Is hovered, On click). Outside the camera frame counts as "not
* in view" – that region is letterbox, not game.
*/
private reportPointer(e: FederatedPointerEvent): void {
const world = this.playWorld;
const view = this.currentView();
if (!world || !view) return;
const local = this.toLocal(e.global);
const point = this.screenToWorld(local.x, local.y, view);
const inside = local.x >= view.rect.x && local.x <= view.rect.x + view.rect.w && local.y >= view.rect.y && local.y <= view.rect.y + view.rect.h;
world.movePointer(point.x, point.y, inside);
}
/** Game view: the world to draw (null = not playing). Ignored by the scene view. */
setWorld(world: World | null): void {
this.playWorld = world;
@@ -261,9 +293,10 @@ export class Viewport extends Widget {
/** Global screen position of an object's world position (test hook), null when not drawn. */
objectScreen(objectId: string): Vec2 | null {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!item) return null;
const local = this.worldToScreen(item.x, item.y);
if (!item || !view) return null;
const local = this.worldToScreen(item.x, item.y, view);
const origin = this.getGlobalPosition();
return { x: origin.x + local.x, y: origin.y + local.y };
}
@@ -316,7 +349,10 @@ export class Viewport extends Widget {
if (!world) return { items: [], background: LETTERBOX_COLOR };
return {
background: world.backgroundColor,
items: world.entities.filter((e) => !isSymbolType(e.type)).map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...world.worldPosition(e), props: e.props, parentId: e.parent?.objectId ?? '' })),
// Symbols (camera, behaviors) are editor-only, switched-off objects do not exist for the game.
items: world.entities
.filter((e) => !isSymbolType(e.type) && isHandleEnabled(e))
.map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...world.worldPosition(e), props: e.props, parentId: e.parent?.objectId ?? '', enabled: true })),
};
}
const scene = this.state.activeScene;
@@ -326,18 +362,17 @@ export class Viewport extends Widget {
for (const { object } of treeOrder(scene)) {
const asset = findAsset(this.state.project, object.assetId);
if (!asset) continue;
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, ...worldPosition(scene, object), props: resolveSceneObjectProps(asset, object), parentId: object.parentId });
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, ...worldPosition(scene, object), props: resolveSceneObjectProps(asset, object), parentId: object.parentId, enabled: true });
}
// Effective switch of every item: off when it or any parent is off (it is then not drawn).
const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items) item.enabled = enabledAlongChain(item, (i) => i.props, (i) => byId.get(i.parentId) ?? null);
return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) };
}
/** Size of an item's footprint in world units (sprite box or camera frame). */
/** Size of an item's footprint in world units (sprite box, text box, camera frame …). */
private itemSize(item: DrawItem): { w: number; h: number } {
if (item.type === 'sprite') return { w: Number(item.props.width ?? 1), h: Number(item.props.height ?? 1) };
if (item.type === 'behavior') return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
if (item.type === 'container') return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
if (item.type === 'collider') return colliderSize(item.props);
return cameraFrame(item.props);
return footprintOf(item.type, item.props);
}
override redraw(): void {
@@ -370,7 +405,9 @@ export class Viewport extends Widget {
for (const item of items) {
seen.add(item.id);
const size = this.itemSize(item);
const onScreen = rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
// A switched-off object is not drawn at all – in the scene view as in the game. It stays
// in the hierarchy panel, which is where it is selected and switched back on.
const onScreen = item.enabled && rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
let node = this.nodes.get(item.id);
if (!node) {
node = new Graphics();
@@ -383,6 +420,7 @@ export class Viewport extends Widget {
node.visible = onScreen;
node.eventMode = editing && onScreen ? 'static' : 'none';
this.syncLabel(item, editing && onScreen, view.scale);
this.syncText(item, onScreen, view.scale);
if (!onScreen) continue;
this.visibleCount++;
node.position.set(item.x, item.y);
@@ -406,6 +444,8 @@ export class Viewport extends Widget {
const r = CONTAINER_SYMBOL / 2;
node.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3).fill({ color: Theme.colors.container, alpha: 0.25 }).stroke({ width: 2, color: Theme.colors.container });
for (const [bx, by] of [[-7, -7], [1, -7], [-7, 1], [1, 1]] as const) node.rect(bx, by, 6, 6).fill(Theme.colors.container);
} else if (item.type === 'text') {
node.hitArea = new Rectangle(-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w, -size.h / 2, size.w, size.h);
} else if (item.type === 'collider') {
// Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on
// top of their body, so only the outline band is hit-testable: clicks inside still reach
@@ -421,6 +461,8 @@ export class Viewport extends Widget {
this.drawParentLinks(items, editing, view.scale);
for (const [id, node] of this.nodes) {
if (!seen.has(id)) {
this.texts.get(id)?.destroy();
this.texts.delete(id);
node.destroy();
this.nodes.delete(id);
this.labels.get(id)?.destroy();
@@ -443,6 +485,9 @@ export class Viewport extends Widget {
for (const item of ordered) {
const node = this.nodes.get(item.id);
if (node) this.objects.addChild(node);
// The rendered text is a sibling (Graphics may not have children), kept right above its box.
const text = this.texts.get(item.id);
if (text) this.objects.addChild(text);
}
}
@@ -461,9 +506,9 @@ export class Viewport extends Widget {
if (!editing) return;
const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items) {
if (!this.isSymbol(item) || !item.parentId || item.type === 'collider') continue;
if (!this.isSymbol(item) || !item.parentId || item.type === 'collider' || !item.enabled) continue;
const parent = byId.get(item.parentId);
if (!parent) continue;
if (!parent || !parent.enabled) continue;
const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted;
dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale);
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
@@ -471,6 +516,35 @@ export class Viewport extends Widget {
}
}
/**
* Content of a text object: a Pixi Text parented to the item's node, so it moves, scales and
* fades with it. Its resolution follows the zoom in coarse steps – re-rendering the glyph
* texture on every wheel notch would be wasteful, blurry text is worse.
*/
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
let text = this.texts.get(item.id);
if (item.type !== 'text' || !onScreen) {
if (text) text.visible = false;
return;
}
if (!text) {
text = new Text({ text: '', style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }) });
text.eventMode = 'none';
this.texts.set(item.id, text);
this.objects.addChild(text);
}
text.visible = true;
text.position.set(item.x, item.y);
text.text = String(item.props.text ?? '');
const size = Math.max(1, Number(item.props.fontSize ?? 32));
if (text.style.fontSize !== size) text.style.fontSize = size;
const color = Number(item.props.color ?? 0xffffff);
if (text.style.fill !== color) text.style.fill = color;
text.anchor.set(textAnchor(item.props), 0.5);
const resolution = Math.min(4, Math.max(1, Math.round(scale * 2) / 2 * 2));
if (text.resolution !== resolution) text.resolution = resolution;
}
private syncLabel(item: DrawItem, show: boolean, scale: number): void {
let label = this.labels.get(item.id);
if (!show || !this.isSymbol(item)) {
@@ -511,8 +585,9 @@ export class Viewport extends Widget {
const s = this.state.selection;
if (s?.kind === 'object') {
for (const id of s.objectIds) {
// Nothing is outlined for a switched-off object – it is not drawn either.
const item = items.find((i) => i.id === id);
if (!item) continue;
if (!item || !item.enabled) continue;
const { w, h } = this.itemSize(item);
const tl = this.worldToScreen(item.x - w / 2, item.y - h / 2, view);
const sw = w * view.scale;
@@ -544,7 +619,7 @@ export class Viewport extends Widget {
const maxX = Math.max(a.x, b.x);
const minY = Math.min(a.y, b.y);
const maxY = Math.max(a.y, b.y);
const ids = this.lastItems.filter((i) => i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
const ids = this.lastItems.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
this.state.select(objectSelection(scene.id, ids));
}