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:
1 parent
4900d7bbf6
commit
76aa441dce
33 files changed
+1905
-301
No files matched your search
+10
-2
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user