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

+10 -2
View File
@@ -69,6 +69,8 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
drawOrder: () => viewport.drawOrder(),
/** Screen position of an object in the Scene tab (its world position through the view). */
objectScreen: (id: string) => viewport.objectScreen(id),
/** The same for the Game tab (through the camera), so tests can click what the player sees. */
gameScreen: (id: string) => gameView.objectScreen(id),
graph: {
current: () => graphEditor.currentAssetId,
portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput),
@@ -320,6 +322,12 @@ class Editor {
this.stop();
return;
}
// Restart game: throw the run away and start the scene again from the saved model.
if (world.restartRequested) {
this.stop();
this.play();
return;
}
// Mirror what the systems did into the runtime copy so the panels show live values.
// A destroyed entity removes its object – that is a structural change the lists must see.
if (world.syncToModel()) this.state.preview();
@@ -339,13 +347,13 @@ class Editor {
}
/** Read-only view of the running world for tests. */
worldSnapshot(): { entities: { objectId: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[]; log: string[] } | null {
worldSnapshot(): { entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[]; log: string[] } | null {
const world = this.world;
if (!world) return null;
return {
entities: world.entities.map((e) => {
const w = world.worldPosition(e);
return { objectId: e.objectId, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
}),
log: [...world.log],
};
+2 -2
View File
@@ -7,7 +7,7 @@ import { findAsset, findUsages } from '../../core/model/Project';
import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import { type EditorState, objectSelection } from '../state';
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior', collider: 'Collider', container: 'Container' };
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', text: 'Text', behavior: 'Behavior', collider: 'Collider', container: 'Container' };
/** Lists all assets of the project; creates, places and deletes them. */
export class AssetPanel extends Panel {
@@ -45,7 +45,7 @@ export class AssetPanel extends Panel {
id: asset.id,
label: asset.name,
hint: asset.type === 'scene' && asset.id === active ? 'scene ●' : asset.type,
swatch: asset.type === 'sprite' ? Number(asset.props.color ?? 0) : undefined,
swatch: asset.type === 'sprite' || asset.type === 'text' ? Number(asset.props.color ?? 0) : undefined,
})),
);
this.syncSelection();
+64 -43
View File
@@ -25,7 +25,7 @@ import { isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import type { Position, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import { type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, ui, VStack } from '../../ui';
import { createContainer, removeObjects } from '../actions';
@@ -135,13 +135,19 @@ export class Inspector extends Panel {
this.body.add(this.labelled('Name', this.nameField));
if (asset.type === 'behavior') {
this.buildAssetProps(asset, COMMON_PROPS);
this.buildVariables(asset);
return;
}
// Asset properties: values that differ from the schema default are marked and resettable.
this.buildAssetProps(asset, schemaForAsset(asset));
}
/** Asset properties: values that differ from the schema default are marked and resettable. */
private buildAssetProps(asset: Asset, schema: readonly PropertyDef[]): void {
const assetId = asset.id;
const values = resolveAssetProps(asset);
for (const def of schemaForAsset(asset)) {
for (const def of schema) {
const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)));
row.setModified(isModified(asset, def.key));
this.fields.set(def.key, row);
@@ -269,6 +275,11 @@ export class Inspector extends Panel {
this.body.add(linkRow);
}
// The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS) {
this.body.add(this.objectPropertyRow(sceneId, objectId, def, resolveSceneObjectProps(asset, object), templateLinkOf(this.state.project, scene, object) ?? null).row);
}
// Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top).
const parent = object.parentId ? findSceneObject(scene, object.parentId) : undefined;
this.body.add(new Label(parent ? 'POSITION (LOCAL TO PARENT)' : 'POSITION', { style: 'title' }));
@@ -302,48 +313,15 @@ export class Inspector extends Panel {
this.body.add(row.row);
}
// Asset properties (or behavior variables) with overrides.
// Asset properties (or behavior variables) with overrides. The common switch (Enabled)
// is shown on its own, above the position – it exists for every type and is not a
// "property of a sprite" in the same sense.
const schema = schemaForAsset(asset);
if (schema.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' }));
if (schema.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' }));
const values = resolveSceneObjectProps(asset, object);
for (const def of schema) {
let original: PropertyValue | undefined;
let hadOverride = false;
const row = createFieldRow(
def,
values[def.key] ?? def.default,
{
onInput: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original === undefined) {
hadOverride = isOverridden(target, def.key);
original = target.overrides[def.key] ?? values[def.key] ?? def.default;
}
target.overrides[def.key] = value;
this.state.preview();
},
onChange: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original !== undefined) {
if (hadOverride) target.overrides[def.key] = original;
else delete target.overrides[def.key];
}
original = undefined;
this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
},
},
() => this.state.execute(link ? new RevertOverrideToTemplateCommand(sceneId, objectId, def.key) : new ClearOverrideCommand(sceneId, objectId, def.key)),
[
...(link ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
{ label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) },
],
);
this.fields.set(def.key, row);
this.body.add(row.row);
}
const own = schema.filter((def) => !isCommonProp(def.key));
if (own.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' }));
if (own.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' }));
for (const def of own) this.body.add(this.objectPropertyRow(sceneId, objectId, def, values, link ?? null).row);
this.markObjectRows(object, asset, link);
if (asset.type === 'container') {
@@ -368,6 +346,49 @@ export class Inspector extends Panel {
}
}
/**
* One editable property of a scene object: writes an override, offers ↺ (back to the asset
* or, inside a container, to the template) and ↑ (apply upwards). Registered in `fields`
* so live refreshes and `markObjectRows` find it.
*/
private objectPropertyRow(sceneId: string, objectId: string, def: PropertyDef, values: Record<string, PropertyValue>, link: TemplateLink | null): FieldRow {
let original: PropertyValue | undefined;
let hadOverride = false;
const row = createFieldRow(
def,
values[def.key] ?? def.default,
{
onInput: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original === undefined) {
hadOverride = isOverridden(target, def.key);
original = target.overrides[def.key] ?? values[def.key] ?? def.default;
}
target.overrides[def.key] = value;
this.state.preview();
},
onChange: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original !== undefined) {
if (hadOverride) target.overrides[def.key] = original;
else delete target.overrides[def.key];
}
original = undefined;
this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
},
},
() => this.state.execute(link ? new RevertOverrideToTemplateCommand(sceneId, objectId, def.key) : new ClearOverrideCommand(sceneId, objectId, def.key)),
[
...(link ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
{ label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) },
],
);
this.fields.set(def.key, row);
return row;
}
/**
* "Parent: <name> ▾" – picks another object of the same scene, or none. The object's own
* subtree is excluded (that would be a cycle). For behaviors the parent is the owner.