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 -4
View File
@@ -24,6 +24,21 @@ export type PropertySchema = readonly PropertyDef[];
export const VARIABLE_KINDS = ['number', 'boolean', 'text'] as const;
export type VariableKind = (typeof VARIABLE_KINDS)[number];
/**
* Properties every *placeable* asset has, whatever its type. `enabled` is the master switch:
* a disabled object is not drawn, does not collide and its behavior does not run – and the
* same goes for everything parented below it. Assets default to on, so switching a single
* instance off is an ordinary override. Scenes have no such switch (they are not placed).
*/
export const COMMON_PROPS: PropertySchema = [{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true }];
const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key));
/** Whether a key belongs to the common properties (the inspector groups those separately). */
export function isCommonProp(key: string): boolean {
return COMMON_KEYS.has(key);
}
/** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */
export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
@@ -49,26 +64,37 @@ export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
// A container is pure structure: its content is the template, not a property.
container: [],
text: [
{ key: 'text', label: 'Text', kind: 'text', default: 'Text' },
{ key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1 },
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{ key: 'align', label: 'Align', kind: 'enum', default: 'center', options: [{ value: 'left', label: 'Left' }, { value: 'center', label: 'Center' }, { value: 'right', label: 'Right' }] },
],
};
/** Type schema with the common properties in front – the effective schema of an asset type. */
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [type, type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]]]),
) as Record<AssetType, PropertySchema>;
/** Properties every scene object has regardless of its asset type. */
export const SCENE_OBJECT_SCHEMA: PropertySchema = [
{ key: 'x', label: 'X', kind: 'number', default: 0, step: 1 },
{ key: 'y', label: 'Y', kind: 'number', default: 0, step: 1 },
];
/** Schema shared by every asset of a type. Behaviors add per-asset variables on top. */
/** Schema shared by every asset of a type (common properties first). */
export function schemaFor(type: AssetType): PropertySchema {
return ASSET_SCHEMAS[type];
return FULL_SCHEMAS[type];
}
/** The effective schema of one asset: the type schema, plus declared variables for behaviors. */
export function schemaForAsset(asset: Asset): PropertySchema {
return asset.type === 'behavior' ? asset.variables : ASSET_SCHEMAS[asset.type];
return asset.type === 'behavior' ? [...COMMON_PROPS, ...asset.variables] : FULL_SCHEMAS[asset.type];
}
export function defaultsFor(type: AssetType): PropertyBag {
return defaultsOf(ASSET_SCHEMAS[type]);
return defaultsOf(FULL_SCHEMAS[type]);
}
export function defaultsOf(schema: PropertySchema): PropertyBag {
+61
View File
@@ -0,0 +1,61 @@
import type { AssetType } from '../model/AssetTypes';
import type { PropertyBag, Vec2 } from '../model/Properties';
import { cameraFrame } from './camera';
import { colliderSize } from './collider';
import { defaultsFor } from './PropertySchema';
/**
* Footprints in world units – how much room an object takes up. Shared by the viewport
* (culling, selection gizmos), by pointer hit tests in the running game and by "Add
* collider", so all three agree on how big a thing is.
*/
/** World-unit size of the behavior symbol (a diamond). */
export const BEHAVIOR_SYMBOL = 28;
/** World-unit size of the container symbol (a bracketed square). */
export const CONTAINER_SYMBOL = 24;
/** Rough width of one character relative to the font size (the UI font is a plain sans). */
const CHAR_WIDTH = 0.55;
const LINE_HEIGHT = 1.25;
/** Size a text object covers, estimated from its content and font size. */
export function textSize(props: PropertyBag): { w: number; h: number } {
const d = defaultsFor('text');
const size = Math.max(1, Number(props.fontSize ?? d.fontSize));
const lines = String(props.text ?? d.text).split('\n');
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
return { w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH), h: lines.length * size * LINE_HEIGHT };
}
/** Horizontal anchor of a text object: 0 = left edge on the position, 0.5 = centred, 1 = right. */
export function textAnchor(props: PropertyBag): number {
const align = String(props.align ?? defaultsFor('text').align);
return align === 'left' ? 0 : align === 'right' ? 1 : 0.5;
}
/** Footprint of an object of the given type, centred on its position (text: see `textAnchor`). */
export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h: number } {
switch (type) {
case 'sprite':
return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) };
case 'text':
return textSize(props);
case 'collider':
return colliderSize(props);
case 'behavior':
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
case 'container':
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
default:
return cameraFrame(props);
}
}
/** Whether a world point lies inside the footprint of an object placed at `centre`. */
export function footprintContains(type: AssetType, props: PropertyBag, centre: Vec2, point: Vec2): boolean {
const { w, h } = footprintOf(type, props);
const offset = type === 'text' ? (0.5 - textAnchor(props)) * w : 0;
const x = centre.x + offset;
return Math.abs(point.x - x) <= w / 2 && Math.abs(point.y - centre.y) <= h / 2;
}
+6
View File
@@ -27,6 +27,12 @@ export function colliderSize(props: PropertyBag): { w: number; h: number } {
return shape.kind === 'circle' ? { w: shape.r * 2, h: shape.r * 2 } : { w: shape.w, h: shape.h };
}
/** Whether a point lies inside a shape (used for pointer hit tests). */
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
if (shape.kind === 'circle') return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
return Math.abs(point.x - shape.x) * 2 <= shape.w && Math.abs(point.y - shape.y) * 2 <= shape.h;
}
/** Whether two shapes overlap. Shapes that merely touch along an edge do not overlap. */
export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
if (a.kind === 'box' && b.kind === 'box') {
+26
View File
@@ -0,0 +1,26 @@
import type { PropertyBag } from '../model/Properties';
/**
* The `enabled` switch (see `COMMON_PROPS`). Disabling an object takes it out of the running
* game completely – not drawn, no collisions, no behavior ticks – and the same holds for its
* children, because "enabled" is only true when the whole chain up to the root is on. That is
* what makes a container usable as a screen: switch it on to show it, off to hide it.
*/
/** Whether a resolved property bag says "on". A missing value counts as on. */
export function propsEnabled(props: PropertyBag): boolean {
return props.enabled !== false;
}
/**
* Whether `start` and every ancestor of it are switched on. Generic over the shape so the
* model (scene objects), the viewport (draw items) and the runtime (entities) share one rule.
*/
export function enabledAlongChain<T>(start: T, propsOf: (item: T) => PropertyBag, parentOf: (item: T) => T | null): boolean {
const seen = new Set<T>();
for (let current: T | null = start; current && !seen.has(current); current = parentOf(current)) {
if (!propsEnabled(propsOf(current))) return false;
seen.add(current);
}
return true;
}