diff --git a/README.md b/README.md
index adf49f94..6221da63 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,9 @@ Live: · Widget playground: # write the fixture scene the e2e checks assert against
-node tools/e2e.mjs # headless end-to-end run with screenshots in /tmp/gm-e2e-*.png
-node tools/seed-demo.mjs # put the demo project back (the e2e run mutates it)
+node tools/seed-e2e.mjs && node tools/e2e.mjs # 100+ editor checks, screenshots in /tmp/gm-e2e-*.png
+node tools/seed-demo.mjs \
+ && node tools/smoke-demo.mjs # the shipped demo scene still loads and plays
+node tools/seed-demo.mjs # rewrite the playground with the demo scene
+node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # the example game, played end to end
```
-The e2e run overwrites the project it is pointed at, so it seeds its own fixture first and the
-demo is restored afterwards. The demo scene (`seed-demo.mjs`) is free to evolve independently:
-a "Player" container – green sprite with a collider and the Mover behavior – two "Enemy"
-container instances, a camera at zoom 0.5 and a Spawner that drops another enemy on Space.
+Each script writes to its own project (`tools/projects.mjs`): the **demo** project is the
+playground and is only ever touched by `seed-demo.mjs`, the **fixture** project is rewritten by
+every e2e run, and the **demo-check** project is a throwaway copy for the smoke test. Pointing a
+destructive script at the demo project aborts, so a test run can never eat scene work.
+
+**The example game** (`tools/seed-jumprun.mjs`, project *Jump and Run*) is a complete little
+platformer built only from assets, containers and graphs: a title screen with a clickable Start
+button, a level of boxes with ten coins, gravity and jumping, a two-minute clock in the HUD, and
+a win / lose screen with "Play again". `tools/smoke-jumprun.mjs` plays it in a headless browser
+(start, jump, collect, win, restart, lose) – it is the regression test for everything screens,
+text, mouse and platformer physics need.
+
+The split lets the two sides move independently: `e2e.mjs` asserts editor behaviour against a
+stable fixture with uniquely named objects, while `smoke-demo.mjs` only checks that the shipped
+demo – a "Player" container (green sprite, collider, Mover behavior), two "Enemy" container
+instances, a camera at zoom 0.5 and a Spawner that drops another enemy on Space – still loads,
+plays, collides and spawns. `tools/harness.mjs` holds the shared CDP driver.
The workspace folder itself is what nginx serves, so `npm run build` is the deployment.
@@ -208,4 +253,6 @@ object in the selection (hierarchy rows too).
## Not yet
Textures on sprites, editing a container's template directly (today changes reach it only
-via **Apply to container** from an instance), reordering the hierarchy, auth, touch-optimised layout, graph comments/groups.
+via **Apply to container** from an instance), reordering the hierarchy, several scenes in one
+run (a `Load scene` node – screens are containers today), sound, auth, touch-optimised layout,
+graph comments/groups.
diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts
index 0f71266e..a8a3df81 100644
--- a/src/core/commands/assetCommands.ts
+++ b/src/core/commands/assetCommands.ts
@@ -17,6 +17,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'camera' };
case 'sprite':
return { ...base, type: 'sprite' };
+ case 'text':
+ return { ...base, type: 'text' };
case 'behavior':
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
case 'collider':
diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts
index 56b4b75e..947fd60b 100644
--- a/src/core/commands/variableCommands.ts
+++ b/src/core/commands/variableCommands.ts
@@ -2,7 +2,7 @@ import { nodeDef } from '../graph/NodeRegistry';
import type { BehaviorAsset } from '../model/Asset';
import type { PropertyValue } from '../model/Properties';
import { findAsset, findUsages, type Project } from '../model/Project';
-import { type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
+import { isCommonProp, type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
import { type Command, CommandError } from './Command';
export function requireBehavior(project: Project, assetId: string): BehaviorAsset {
@@ -128,5 +128,6 @@ export class RenameVariableCommand implements Command {
}
export function isValidVariableName(name: string): boolean {
- return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
+ // Common property keys (enabled) are reserved: a variable of that name would shadow the switch.
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !isCommonProp(name);
}
diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts
index d89c42aa..b816d825 100644
--- a/src/core/graph/NodeRegistry.ts
+++ b/src/core/graph/NodeRegistry.ts
@@ -1,4 +1,5 @@
import type { PropertyBag, PropertyValue } from '../model/Properties';
+import { enabledAlongChain } from '../properties/enabled';
import type { PropertyDef } from '../properties/PropertySchema';
import type { PortType } from './Graph';
@@ -11,6 +12,8 @@ export type Value = PropertyValue | EntityHandle | null;
*/
export interface EntityHandle {
id: string;
+ /** Asset this entity was instantiated from (what `Is asset` compares against). */
+ assetId: string;
name: string;
/** Position local to `parent` (world position for top-level entities). */
x: number;
@@ -34,6 +37,23 @@ export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z
return result;
}
+/** Whether a handle and every ancestor of it are switched on (see `properties/enabled.ts`). */
+export function isHandleEnabled(handle: EntityHandle): boolean {
+ return enabledAlongChain(handle, (h) => h.props, (h) => h.parent);
+}
+
+/** Where the mouse is (world units) and what it did this frame. */
+export interface PointerState {
+ x: number;
+ y: number;
+ /** Whether the pointer is over the game view at all. */
+ inside: boolean;
+ down: boolean;
+ /** Went down / up during this frame. */
+ pressed: boolean;
+ released: boolean;
+}
+
/**
* Services a graph may use while running. Implemented by the runtime (BehaviorSystem) and
* by test doubles. Everything the graph does to the world goes through here, so the core
@@ -51,6 +71,14 @@ export interface NodeContext {
dt: number;
findByName(name: string): EntityHandle | null;
isKeyDown(key: string): boolean;
+ /** Mouse state of this frame, in world units (see `PointerState`). */
+ pointer: PointerState;
+ /**
+ * Whether a world point lies on `target` – on its colliders when it has any, otherwise on
+ * its own footprint or that of anything parented below it (so a button made of a container
+ * with a sprite and a label is hit as a whole).
+ */
+ hitTest(target: EntityHandle, x: number, y: number): boolean;
/**
* Whether any collider of `a` overlaps any collider of `b` right now. A collider entity
* stands for itself; any other entity stands for its direct collider children.
@@ -70,6 +98,8 @@ export interface NodeContext {
spawn(assetId: string, position: { x: number; y: number; z: number }, parent: EntityHandle | null): EntityHandle | null;
destroy(target: EntityHandle): void;
stop(): void;
+ /** Ends the run and starts it again from the saved scene (everything spawned is lost). */
+ restart(): void;
}
export interface PortDef {
diff --git a/src/core/graph/nodes.ts b/src/core/graph/nodes.ts
index a8393839..860a574c 100644
--- a/src/core/graph/nodes.ts
+++ b/src/core/graph/nodes.ts
@@ -1,5 +1,5 @@
import type { PropertyDef } from '../properties/PropertySchema';
-import { asBoolean, asEntity, asNumber, asString, type PortDef, registerNode, worldPositionOf } from './NodeRegistry';
+import { asBoolean, asEntity, asNumber, asString, isHandleEnabled, type PortDef, registerNode, worldPositionOf } from './NodeRegistry';
/**
* The built-in node set. Each entry is data + a `run` function; everything else (palette,
@@ -38,6 +38,15 @@ const collisionOutputs = (): PortDef[] => [exec(), obj('other'), obj('otherColli
registerNode({ type: 'OnCollisionEnter', label: 'On collision enter', category: 'event', description: 'Runs once when a collider of the owner starts overlapping another collider.', inputs: [], outputs: collisionOutputs(), params: [] });
registerNode({ type: 'OnCollisionStay', label: 'On collision stay', category: 'event', description: 'Runs every frame while a collider of the owner overlaps another collider.', inputs: [], outputs: collisionOutputs(), params: [] });
registerNode({ type: 'OnCollisionExit', label: 'On collision exit', category: 'event', description: 'Runs once when a collider of the owner stops overlapping another collider.', inputs: [], outputs: collisionOutputs(), params: [] });
+registerNode({
+ type: 'OnClick',
+ label: 'On click',
+ category: 'event',
+ description: 'Runs when the mouse is pressed on the owner (or on anything parented below it) in the Game tab.',
+ inputs: [],
+ outputs: [exec(), num('x', 'world x'), num('y', 'world y')],
+ params: [],
+});
// --- flow ---------------------------------------------------------------------------------
@@ -76,6 +85,30 @@ registerNode({
params: [],
run: (_ctx, inputs) => ({ outputs: { parent: asEntity(inputs.object)?.parent ?? null } }),
});
+registerNode({
+ type: 'NameOf',
+ label: 'Name of',
+ category: 'data',
+ description: 'The name of an object (empty when there is none).',
+ inputs: [obj('object')],
+ outputs: [{ key: 'name', label: 'name', type: 'string' }],
+ params: [],
+ run: (_ctx, inputs) => ({ outputs: { name: asEntity(inputs.object)?.name ?? '' } }),
+});
+registerNode({
+ type: 'IsAsset',
+ label: 'Is asset',
+ category: 'data',
+ description: 'Whether an object is an instance of the chosen asset – the way to tell what you collided with.',
+ inputs: [obj('object')],
+ outputs: [bool('result')],
+ // Placeholder options: the graph inspector lists the project's placeable assets.
+ params: [{ key: 'asset', label: 'Asset', kind: 'enum', default: '', options: [] }],
+ run: (_ctx, inputs, params) => {
+ const assetId = String(params.asset ?? '');
+ return { outputs: { result: Boolean(assetId) && asEntity(inputs.object)?.assetId === assetId } };
+ },
+});
registerNode({
type: 'ObjectByName',
label: 'Object by name',
@@ -198,6 +231,124 @@ registerNode({
run: (_c, i) => ({ outputs: { result: Math.min(Math.max(asNumber(i.value), asNumber(i.min)), asNumber(i.max)) } }),
});
+registerNode({ type: 'Floor', label: 'Floor', category: 'data', description: 'Rounds down to a whole number.', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.floor(asNumber(i.value)) } }) });
+registerNode({ type: 'Ceil', label: 'Ceil', category: 'data', description: 'Rounds up to a whole number.', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.ceil(asNumber(i.value)) } }) });
+registerNode({ type: 'Round', label: 'Round', category: 'data', description: 'Rounds to the nearest whole number.', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.round(asNumber(i.value)) } }) });
+registerNode({
+ type: 'Modulo',
+ label: 'Modulo',
+ category: 'data',
+ description: 'Remainder of a ÷ b (0 when b is 0) – seconds within a minute, for instance.',
+ inputs: [num('a'), { ...num('b'), default: 1 }],
+ outputs: [num('result')],
+ params: [],
+ run: (_c, i) => {
+ const b = asNumber(i.b);
+ return { outputs: { result: b === 0 ? 0 : asNumber(i.a) % b } };
+ },
+});
+registerNode({
+ type: 'Random',
+ label: 'Random',
+ category: 'data',
+ description: 'A random number between min and max (min included, max not).',
+ inputs: [{ ...num('min'), default: 0 }, { ...num('max'), default: 1 }],
+ outputs: [num('result')],
+ params: [],
+ run: (_c, i) => {
+ const min = asNumber(i.min);
+ return { outputs: { result: min + Math.random() * (asNumber(i.max) - min) } };
+ },
+});
+registerNode({
+ type: 'Time',
+ label: 'Time',
+ category: 'data',
+ description: 'Seconds since the game started, and the length of the current frame.',
+ inputs: [],
+ outputs: [num('time'), num('dt')],
+ params: [],
+ run: (ctx) => ({ outputs: { time: ctx.time, dt: ctx.dt } }),
+});
+
+// --- data: text ------------------------------------------------------------------------------
+
+registerNode({
+ type: 'ToText',
+ label: 'To text',
+ category: 'data',
+ description: 'Turns a number into text with a fixed number of decimals.',
+ inputs: [any('value'), num('decimals')],
+ outputs: [{ key: 'text', label: 'text', type: 'string' }],
+ params: [],
+ run: (_c, i) => {
+ const value = i.value;
+ const decimals = Math.max(0, Math.min(10, Math.floor(asNumber(i.decimals))));
+ return { outputs: { text: typeof value === 'string' ? value : asNumber(value).toFixed(decimals) } };
+ },
+});
+registerNode({
+ type: 'Join',
+ label: 'Join text',
+ category: 'data',
+ description: 'Sticks two values together as text ("Coins: " + 3).',
+ inputs: [any('a'), any('b')],
+ outputs: [{ key: 'text', label: 'text', type: 'string' }],
+ params: [],
+ run: (_c, i) => ({ outputs: { text: asString(i.a) + asString(i.b) } }),
+});
+registerNode({
+ type: 'Equals',
+ label: 'Equals',
+ category: 'data',
+ description: 'Whether two values are the same. Objects compare by identity, everything else as text.',
+ inputs: [any('a'), any('b')],
+ outputs: [bool('result')],
+ params: [],
+ run: (_c, i) => {
+ const a = asEntity(i.a);
+ const b = asEntity(i.b);
+ if (a || b) return { outputs: { result: a === b } };
+ return { outputs: { result: asString(i.a) === asString(i.b) } };
+ },
+});
+
+// --- data: mouse ------------------------------------------------------------------------------
+
+registerNode({
+ type: 'MousePosition',
+ label: 'Mouse position',
+ category: 'data',
+ description: 'Where the mouse is in the Game tab, in world units.',
+ inputs: [],
+ outputs: [num('x'), num('y'), bool('inside', 'in view')],
+ params: [],
+ run: (ctx) => ({ outputs: { x: ctx.pointer.x, y: ctx.pointer.y, inside: ctx.pointer.inside } }),
+});
+registerNode({
+ type: 'MouseButton',
+ label: 'Mouse button',
+ category: 'data',
+ description: 'State of the mouse button: held, and whether it went down or up this frame.',
+ inputs: [],
+ outputs: [bool('down'), bool('pressed'), bool('released')],
+ params: [],
+ run: (ctx) => ({ outputs: { down: ctx.pointer.down, pressed: ctx.pointer.pressed, released: ctx.pointer.released } }),
+});
+registerNode({
+ type: 'IsHovered',
+ label: 'Is hovered',
+ category: 'data',
+ description: 'Whether the mouse is over an object (its colliders, else its own or its children\'s footprint).',
+ inputs: [obj('object')],
+ outputs: [bool('result')],
+ params: [],
+ run: (ctx, i) => {
+ const target = asEntity(i.object);
+ return { outputs: { result: Boolean(target && ctx.pointer.inside && ctx.hitTest(target, ctx.pointer.x, ctx.pointer.y)) } };
+ },
+});
+
// --- data: vectors -------------------------------------------------------------------------
registerNode({ type: 'Length', label: 'Length', category: 'data', description: 'Length of the vector (x, y).', inputs: [num('x'), num('y')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.hypot(asNumber(i.x), asNumber(i.y)) } }) });
@@ -332,6 +483,48 @@ registerNode({
return { next: 'exec' };
},
});
+registerNode({
+ type: 'SetEnabled',
+ label: 'Set enabled',
+ category: 'action',
+ description: 'Switches an object on or off. A disabled object is not drawn, does not collide and its behavior stops – and so does everything parented below it. This is how screens (title, win, lose) are shown and hidden.',
+ inputs: [exec(), obj('object'), { ...bool('enabled'), default: true }],
+ outputs: [exec()],
+ params: [],
+ run: (_ctx, i) => {
+ const target = asEntity(i.object);
+ if (target) target.props.enabled = asBoolean(i.enabled);
+ return { next: 'exec' };
+ },
+});
+registerNode({
+ type: 'IsEnabled',
+ label: 'Is enabled',
+ category: 'data',
+ description: 'Whether an object is switched on – "own" is its own switch, "result" also requires every parent to be on.',
+ inputs: [obj('object')],
+ outputs: [bool('result'), bool('own', 'own switch')],
+ params: [],
+ run: (_ctx, i) => {
+ const target = asEntity(i.object);
+ if (!target) return { outputs: { result: false, own: false } };
+ return { outputs: { result: isHandleEnabled(target), own: target.props.enabled !== false } };
+ },
+});
+registerNode({
+ type: 'SetText',
+ label: 'Set text',
+ category: 'action',
+ description: 'Writes the content of a text object (same as Set property "text").',
+ inputs: [exec(), obj('object'), any('text')],
+ outputs: [exec()],
+ params: [],
+ run: (_ctx, i) => {
+ const target = asEntity(i.object);
+ if (target) target.props.text = asString(i.text);
+ return { next: 'exec' };
+ },
+});
registerNode({
type: 'Destroy',
label: 'Destroy',
@@ -376,3 +569,4 @@ registerNode({
},
});
registerNode({ type: 'StopGame', label: 'Stop game', category: 'action', description: 'Ends play mode.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.stop(); return {}; } });
+registerNode({ type: 'RestartGame', label: 'Restart game', category: 'action', description: 'Starts the scene over from its saved state – everything spawned, moved or destroyed is reset.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.restart(); return {}; } });
diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts
index a0676071..bf9fc63d 100644
--- a/src/core/model/Asset.ts
+++ b/src/core/model/Asset.ts
@@ -20,6 +20,14 @@ export interface CameraAsset extends AssetBase {
type: 'camera';
}
+/**
+ * A line of text drawn in the scene (titles, HUD readouts, buttons). Its content is the
+ * ordinary property `text`, so behaviors change it with `Set text` (or `Set property`).
+ */
+export interface TextAsset extends AssetBase {
+ type: 'text';
+}
+
/** A scene is an asset that additionally owns the objects placed into it. */
export interface SceneAsset extends AssetBase {
type: 'scene';
@@ -57,7 +65,7 @@ export interface ContainerAsset extends AssetBase {
objects: SceneObject[];
}
-export type Asset = SpriteAsset | CameraAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
+export type Asset = SpriteAsset | CameraAsset | TextAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
export function isSceneAsset(asset: Asset): asset is SceneAsset {
return asset.type === 'scene';
diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts
index d26936a0..dc6e7c82 100644
--- a/src/core/model/AssetTypes.ts
+++ b/src/core/model/AssetTypes.ts
@@ -1,5 +1,5 @@
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
-export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'behavior', 'collider', 'container'] as const;
+export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'text', 'behavior', 'collider', 'container'] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts
index c4b220be..394369fe 100644
--- a/src/core/properties/PropertySchema.ts
+++ b/src/core/properties/PropertySchema.ts
@@ -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 = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
@@ -49,26 +64,37 @@ export const ASSET_SCHEMAS: Record = {
],
// 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 = Object.fromEntries(
+ (Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [type, type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]]]),
+) as Record;
+
/** 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 {
diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts
new file mode 100644
index 00000000..90e3397a
--- /dev/null
+++ b/src/core/properties/bounds.ts
@@ -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;
+}
diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts
index 6cbcd5bd..0104ffd9 100644
--- a/src/core/properties/collider.ts
+++ b/src/core/properties/collider.ts
@@ -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') {
diff --git a/src/core/properties/enabled.ts b/src/core/properties/enabled.ts
new file mode 100644
index 00000000..6c1a2ad1
--- /dev/null
+++ b/src/core/properties/enabled.ts
@@ -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(start: T, propsOf: (item: T) => PropertyBag, parentOf: (item: T) => T | null): boolean {
+ const seen = new Set();
+ for (let current: T | null = start; current && !seen.has(current); current = parentOf(current)) {
+ if (!propsEnabled(propsOf(current))) return false;
+ seen.add(current);
+ }
+ return true;
+}
diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts
index 3312adbb..d1748519 100644
--- a/src/core/store/serialize.ts
+++ b/src/core/store/serialize.ts
@@ -5,7 +5,7 @@ import { isAssetType } from '../model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
-import { coerceValue, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
+import { coerceValue, COMMON_PROPS, isCommonProp, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
/**
* Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are
@@ -25,7 +25,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
if (a.type === 'behavior') {
const vars = normalizeVariables(a.variables);
variables.set(a.id, vars);
- schemas.set(a.id, vars);
+ schemas.set(a.id, [...COMMON_PROPS, ...vars]);
} else {
schemas.set(a.id, schemaFor(a.type));
}
@@ -113,7 +113,8 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
const v = asRecord(item);
const key = typeof v.key === 'string' ? v.key : '';
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind)) ? (v.kind as VariableKind) : 'number';
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue;
+ // Common property keys are reserved – a variable of that name would shadow the switch.
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key) || isCommonProp(key)) continue;
seen.add(key);
const def = variableDef(key, kind);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue) as never;
diff --git a/src/editor/App.ts b/src/editor/App.ts
index 60e27fb1..27298724 100644
--- a/src/editor/App.ts
+++ b/src/editor/App.ts
@@ -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 }[]; log: string[] } | null {
+ worldSnapshot(): { entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record }[]; 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],
};
diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts
index 5b1eb31c..310ae8f1 100644
--- a/src/editor/panels/AssetPanel.ts
+++ b/src/editor/panels/AssetPanel.ts
@@ -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 = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior', collider: 'Collider', container: 'Container' };
+const TYPE_LABELS: Record = { 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();
diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts
index 3dbb86e9..c586d74d 100644
--- a/src/editor/panels/Inspector.ts
+++ b/src/editor/panels/Inspector.ts
@@ -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, 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: ▾" – 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.
diff --git a/src/runtime/World.ts b/src/runtime/World.ts
index d101e692..2fe23077 100644
--- a/src/runtime/World.ts
+++ b/src/runtime/World.ts
@@ -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 {
diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts
index 093dd2c5..6559da1d 100644
--- a/src/runtime/systems/BehaviorSystem.ts
+++ b/src/runtime/systems/BehaviorSystem.ts
@@ -1,4 +1,4 @@
-import { type EntityHandle, GraphInterpreter, type NodeContext, type TraceListener } from '../../core/graph';
+import { type EntityHandle, GraphInterpreter, isHandleEnabled, type NodeContext, type TraceListener } from '../../core/graph';
import { findAsset, type Project } from '../../core/model/Project';
import type { Entity } from '../Entity';
import type { World } from '../World';
@@ -44,12 +44,14 @@ export class BehaviorSystem implements System {
/**
* One instance per behavior entity: entities that appeared (placed while playing) get an
- * interpreter and their `On start`; instances whose entity is gone are dropped.
+ * interpreter and their `On start`; instances whose entity is gone are dropped. A behavior
+ * that is switched off has no instance yet – it gets one, and its `On start`, the moment it
+ * is switched on. That is what lets a hidden screen wake up when it is shown.
*/
private syncInstances(world: World): void {
this.instances = this.instances.filter((i) => world.entities.includes(i.entity));
for (const entity of world.entities) {
- if (entity.type !== 'behavior' || this.instances.some((i) => i.entity === entity)) continue;
+ if (entity.type !== 'behavior' || !isHandleEnabled(entity) || this.instances.some((i) => i.entity === entity)) continue;
const asset = findAsset(this.project, entity.assetId);
if (!asset || asset.type !== 'behavior') continue;
const trace: Trace = { edges: new Map(), nodes: new Map() };
@@ -66,6 +68,7 @@ export class BehaviorSystem implements System {
const keyOf = (node: { params: Record }) => String(node.params.key ?? '');
for (const instance of this.instances) {
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
+ if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent)
this.run(instance, (i, ctx) => {
for (const key of this.input.justPressed) i.fire('OnKeyDown', ctx, {}, (n) => keyOf(n) === key);
for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key);
@@ -81,6 +84,10 @@ export class BehaviorSystem implements System {
}
}
this.fireCollisions(instance, i, ctx);
+ const pointer = world.pointer;
+ if (pointer.pressed && world.hitTest(instance.entity.parent ?? instance.entity, pointer.x, pointer.y)) {
+ i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
+ }
i.fire('OnUpdate', ctx, { dt });
}, dt);
}
@@ -144,6 +151,11 @@ export class BehaviorSystem implements System {
dt,
findByName: (name) => world.findByName(name) ?? null,
isKeyDown: (key) => this.input.isDown(key),
+ pointer: world.pointer,
+ hitTest: (target, x, y) => {
+ const found = world.entities.find((e) => e === target || e.id === target.id);
+ return Boolean(found && world.hitTest(found, x, y));
+ },
isTouching: (a, b) => {
const ea = world.entities.find((e) => e === a || e.id === a.id);
const eb = world.entities.find((e) => e === b || e.id === b.id);
@@ -172,6 +184,9 @@ export class BehaviorSystem implements System {
stop: () => {
world.stopRequested = true;
},
+ restart: () => {
+ world.restartRequested = true;
+ },
};
}
diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts
index 172072c8..b0ff78a3 100644
--- a/src/runtime/systems/CollisionSystem.ts
+++ b/src/runtime/systems/CollisionSystem.ts
@@ -1,3 +1,4 @@
+import { isHandleEnabled } from '../../core/graph';
import { type Axis, axisCorrection, colliderShape, colliderSize, isTrigger, shapesOverlap } from '../../core/properties/collider';
import type { Entity } from '../Entity';
import type { World } from '../World';
@@ -27,7 +28,8 @@ export class CollisionSystem implements System {
private previous = new Map();
update(world: World): void {
- const colliders = world.entities.filter((e) => e.type === 'collider');
+ // Switched-off colliders (or ones below something switched off) take no part at all.
+ const colliders = world.entities.filter((e) => e.type === 'collider' && isHandleEnabled(e));
const current = new Map();
for (let i = 0; i < colliders.length; i++) {
for (let j = i + 1; j < colliders.length; j++) {
@@ -77,8 +79,8 @@ export class CollisionSystem implements System {
* colliders and colliders that travel with it (descendants) never block.
*/
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } {
- const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props));
- const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && !this.travelsWith(c, entity));
+ const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
+ const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !this.travelsWith(c, entity));
return { blockedX: this.slideAxis(world, entity, own, obstacles, 'x', dx), blockedY: this.slideAxis(world, entity, own, obstacles, 'y', dy) };
}
@@ -155,5 +157,5 @@ export function bodyOf(collider: Entity): Entity {
/** A collider stands for itself; any other entity stands for its direct collider children. */
export function collidersOf(world: World, entity: Entity): Entity[] {
if (entity.type === 'collider') return [entity];
- return world.childrenOf(entity).filter((e) => e.type === 'collider');
+ return world.childrenOf(entity).filter((e) => e.type === 'collider' && isHandleEnabled(e));
}
diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts
index ed729a30..bc7dbca2 100644
--- a/src/viewport/Viewport.ts
+++ b/src/viewport/Viewport.ts
@@ -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();
private readonly labels = new Map();
+ /** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */
+ private readonly texts = new Map();
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));
}
diff --git a/tests/core/commands.test.ts b/tests/core/commands.test.ts
index 2538cf3e..f2672579 100644
--- a/tests/core/commands.test.ts
+++ b/tests/core/commands.test.ts
@@ -32,7 +32,7 @@ describe('asset commands', () => {
const { project, sceneId, spriteId } = setup();
expect(project.assets).toHaveLength(2);
expect(project.activeSceneId).toBe(sceneId);
- expect(project.assets.find((a) => a.id === spriteId)?.props).toEqual({ width: 100, height: 100, color: 0xe0553f });
+ expect(project.assets.find((a) => a.id === spriteId)?.props).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f });
});
it('renames and sets properties with undo/redo', () => {
diff --git a/tests/core/enabled.test.ts b/tests/core/enabled.test.ts
new file mode 100644
index 00000000..5ee7c37e
--- /dev/null
+++ b/tests/core/enabled.test.ts
@@ -0,0 +1,295 @@
+import { describe, expect, it } from 'vitest';
+import { buildAsset, CreateAssetCommand } from '../../src/core/commands/assetCommands';
+import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
+import { CommandStack } from '../../src/core/commands/CommandStack';
+import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
+import { PlaceAssetCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
+import { AddVariableCommand } from '../../src/core/commands/variableCommands';
+import { type EntityHandle, isHandleEnabled, requireNodeDef, type NodeContext, type Value } from '../../src/core/graph';
+import type { BehaviorAsset } from '../../src/core/model/Asset';
+import type { PropertyBag } from '../../src/core/model/Properties';
+import { createEmptyProject, findScene } from '../../src/core/model/Project';
+import { footprintOf, textSize } from '../../src/core/properties/bounds';
+import { schemaFor } from '../../src/core/properties/PropertySchema';
+import { resolveSceneObjectProps } from '../../src/core/properties/resolve';
+import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
+import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem';
+import { CollisionSystem } from '../../src/runtime/systems/CollisionSystem';
+import { Input } from '../../src/runtime/systems/Input';
+import { World } from '../../src/runtime/World';
+
+/** Runs one node definition in isolation – enough for the small data nodes. */
+function run(type: string, inputs: Record, params: PropertyBag = {}, ctx: Partial = {}): Record {
+ return requireNodeDef(type).run!(ctx as NodeContext, inputs, params).outputs ?? {};
+}
+
+function handle(props: PropertyBag, parent: EntityHandle | null = null): EntityHandle {
+ return { id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent };
+}
+
+describe('the enabled switch', () => {
+ it('is a common property of every placeable type, defaults to on and overrides per object', () => {
+ expect(schemaFor('sprite')[0]).toMatchObject({ key: 'enabled', kind: 'boolean', default: true });
+ expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled']);
+ expect(schemaFor('scene').some((d) => d.key === 'enabled')).toBe(false);
+
+ const project = createEmptyProject('p', 'P');
+ const stack = new CommandStack(project);
+ const scene = new CreateAssetCommand('scene', 'Level');
+ const sprite = new CreateAssetCommand('sprite', 'Box');
+ stack.execute(scene);
+ stack.execute(sprite);
+ const placed = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
+ stack.execute(placed);
+ const object = findScene(project, scene.asset.id)!.objects[0]!;
+ expect(resolveSceneObjectProps(sprite.asset, object).enabled).toBe(true);
+
+ // Switching an instance off is an ordinary override – and survives a round trip.
+ stack.execute(new SetOverrideCommand(scene.asset.id, placed.objectId, 'enabled', false));
+ expect(object.overrides).toEqual({ enabled: false });
+ const loaded = deserializeProject(serializeProject(project), 'p');
+ expect(findScene(loaded, scene.asset.id)!.objects[0]!.overrides).toEqual({ enabled: false });
+ stack.undo();
+ expect(object.overrides).toEqual({});
+ });
+
+ it('is inherited: a child of a switched-off parent is off as well', () => {
+ const parent = handle({ enabled: false });
+ const child = handle({ enabled: true }, parent);
+ expect(isHandleEnabled(child)).toBe(false);
+ expect(child.props.enabled).toBe(true); // its own switch is untouched
+ parent.props.enabled = true;
+ expect(isHandleEnabled(child)).toBe(true);
+ expect(run('IsEnabled', { object: child })).toEqual({ result: true, own: true });
+ });
+
+ it('cannot be shadowed by a behavior variable', () => {
+ const project = createEmptyProject('p', 'P');
+ const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
+ project.assets.push(behavior);
+ expect(() => new AddVariableCommand('b', 'enabled', 'number').execute(project)).toThrow();
+ });
+
+ it('takes a collider out of the collision system while it is off', () => {
+ const { world, wall, mover } = collisionSetup();
+ const collisions = new CollisionSystem();
+ world.addSystem(collisions);
+
+ // The wall blocks: moving 200 to the right stops at 100 units of distance (two 100-wide boxes).
+ collisions.moveAndSlide(world, mover, 200, 0);
+ expect(mover.x).toBeCloseTo(100, 5);
+
+ // Switched off, the wall is not there at all.
+ mover.x = 0;
+ wall.props.enabled = false;
+ collisions.moveAndSlide(world, mover, 200, 0);
+ expect(mover.x).toBeCloseTo(200, 5);
+ });
+
+ it('stops a behavior while it is off and runs On start when it is switched on', () => {
+ const project = createEmptyProject('p', 'P');
+ const stack = new CommandStack(project);
+ const scene = new CreateAssetCommand('scene', 'Level');
+ const behavior = new CreateAssetCommand('behavior', 'Counter');
+ stack.execute(scene);
+ stack.execute(behavior);
+ stack.execute(new AddVariableCommand(behavior.asset.id, 'ticks', 'number'));
+ const start = new AddNodeCommand(behavior.asset.id, 'OnStart', 0, 0);
+ const update = new AddNodeCommand(behavior.asset.id, 'OnUpdate', 0, 100);
+ const one = new AddNodeCommand(behavior.asset.id, 'Number', 0, 200, { value: 1 });
+ const add = new AddNodeCommand(behavior.asset.id, 'Add', 100, 200);
+ const get = new AddNodeCommand(behavior.asset.id, 'GetVariable', 0, 300, { variable: 'ticks' });
+ const set = new AddNodeCommand(behavior.asset.id, 'SetVariable', 200, 100, { variable: 'ticks' });
+ for (const c of [start, update, one, add, get, set]) stack.execute(c);
+ for (const [from, fromPort, to, toPort] of [
+ [update, 'exec', set, 'exec'],
+ [get, 'value', add, 'a'],
+ [one, 'value', add, 'b'],
+ [add, 'result', set, 'value'],
+ [start, 'exec', set, 'exec'],
+ ] as const) {
+ stack.execute(new ConnectCommand(behavior.asset.id, { node: from.node.id, port: fromPort }, { node: to.node.id, port: toPort }));
+ }
+ const placed = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 });
+ stack.execute(placed);
+ stack.execute(new SetOverrideCommand(scene.asset.id, placed.objectId, 'enabled', false));
+
+ const world = World.instantiate(project, scene.asset.id);
+ world.addSystem(new BehaviorSystem(project, new Input(), null));
+ const entity = world.findByObjectId(placed.objectId)!;
+ world.tick(0.1);
+ world.tick(0.1);
+ expect(entity.props.ticks).toBe(0); // off: no On start, no ticks
+
+ entity.props.enabled = true; // what "Set enabled" does
+ world.tick(0.1);
+ expect(entity.props.ticks).toBe(2); // On start plus this frame's On update
+ world.tick(0.1);
+ expect(entity.props.ticks).toBe(3);
+
+ entity.props.enabled = false;
+ world.tick(0.1);
+ expect(entity.props.ticks).toBe(3); // frozen again, state kept
+ });
+});
+
+describe('text objects and the mouse', () => {
+ it('measures a text footprint from its content and font size', () => {
+ expect(textSize({ text: 'ab', fontSize: 100 }).w).toBeCloseTo(110, 5);
+ expect(textSize({ text: 'ab', fontSize: 100 }).h).toBeCloseTo(125, 5);
+ expect(footprintOf('text', { text: 'abcd', fontSize: 100 }).w).toBeCloseTo(220, 5);
+ expect(run('SetText', { object: handle({ text: 'old' }), text: 42 })).toEqual({});
+ });
+
+ it('hit-tests an object through its colliders, else through its sprites and texts', () => {
+ const project = createEmptyProject('p', 'P');
+ const stack = new CommandStack(project);
+ const scene = new CreateAssetCommand('scene', 'Level');
+ const sprite = new CreateAssetCommand('sprite', 'Button');
+ stack.execute(scene);
+ stack.execute(sprite);
+ const button = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
+ stack.execute(button);
+ const label = new CreateAssetCommand('text', 'Label');
+ stack.execute(label);
+ const text = new PlaceAssetCommand(scene.asset.id, label.asset.id, { x: 0, y: 200 });
+ stack.execute(text);
+ stack.execute(new SetParentCommand(scene.asset.id, text.objectId, button.objectId));
+
+ const world = World.instantiate(project, scene.asset.id);
+ const entity = world.findByObjectId(button.objectId)!;
+ expect(world.hitTest(entity, 10, 10)).toBe(true); // inside the 100×100 sprite
+ expect(world.hitTest(entity, 10, 200)).toBe(true); // on the label parented below it
+ expect(world.hitTest(entity, 400, 400)).toBe(false);
+
+ // A collider takes over the hit test for the whole subtree.
+ stack.execute(new AddColliderCommand(scene.asset.id, button.objectId));
+ stack.execute(new SetOverrideCommand(scene.asset.id, findScene(project, scene.asset.id)!.objects.at(-1)!.id, 'width', 20));
+ world.syncFromModel();
+ expect(world.hitTest(entity, 5, 5)).toBe(true);
+ expect(world.hitTest(entity, 40, 0)).toBe(false); // outside the 20-wide collider
+ expect(world.hitTest(entity, 10, 200)).toBe(false); // the label no longer answers
+
+ // Switched off, nothing of it can be hit.
+ entity.props.enabled = false;
+ expect(world.hitTest(entity, 5, 5)).toBe(false);
+ });
+
+ it('reports pointer edges for exactly one tick', () => {
+ const project = createEmptyProject('p', 'P');
+ const stack = new CommandStack(project);
+ const scene = new CreateAssetCommand('scene', 'Level');
+ stack.execute(scene);
+ const world = World.instantiate(project, scene.asset.id);
+ world.movePointer(12, 34, true);
+ world.setPointerDown(true);
+ expect(world.pointer).toMatchObject({ x: 12, y: 34, inside: true, down: true, pressed: true });
+ world.tick(0.1);
+ expect(world.pointer).toMatchObject({ down: true, pressed: false, released: false });
+ world.setPointerDown(false);
+ expect(world.pointer).toMatchObject({ down: false, released: true });
+ });
+});
+
+describe('nodes added for game flow', () => {
+ it('formats numbers and joins text', () => {
+ expect(run('ToText', { value: 12.345, decimals: 1 })).toEqual({ text: '12.3' });
+ expect(run('ToText', { value: 'x', decimals: 0 })).toEqual({ text: 'x' });
+ expect(run('Join', { a: 'Coins: ', b: 3 })).toEqual({ text: 'Coins: 3' });
+ expect(run('Modulo', { a: 125, b: 60 })).toEqual({ result: 5 });
+ expect(run('Modulo', { a: 5, b: 0 })).toEqual({ result: 0 });
+ expect(run('Floor', { value: 2.9 })).toEqual({ result: 2 });
+ });
+
+ it('compares values and identifies assets', () => {
+ const coin = handle({});
+ coin.assetId = 'coin';
+ expect(run('IsAsset', { object: coin }, { asset: 'coin' })).toEqual({ result: true });
+ expect(run('IsAsset', { object: coin }, { asset: 'other' })).toEqual({ result: false });
+ expect(run('IsAsset', { object: null }, { asset: 'coin' })).toEqual({ result: false });
+ expect(run('Equals', { a: 'a', b: 'a' })).toEqual({ result: true });
+ expect(run('Equals', { a: coin, b: coin })).toEqual({ result: true });
+ expect(run('Equals', { a: coin, b: null })).toEqual({ result: false });
+ expect(run('NameOf', { object: coin })).toEqual({ name: 'h' });
+ });
+
+ it('switches objects on and off and asks for a restart', () => {
+ const target = handle({ enabled: true });
+ requireNodeDef('SetEnabled').run!({} as NodeContext, { object: target, enabled: false }, {});
+ expect(target.props.enabled).toBe(false);
+
+ let restarted = false;
+ requireNodeDef('RestartGame').run!({ restart: () => { restarted = true; } } as NodeContext, {}, {});
+ expect(restarted).toBe(true);
+ });
+
+ it('fires On click when the mouse is pressed on the owner', () => {
+ const project = createEmptyProject('p', 'P');
+ const stack = new CommandStack(project);
+ const scene = new CreateAssetCommand('scene', 'Level');
+ const sprite = new CreateAssetCommand('sprite', 'Button');
+ const behavior = new CreateAssetCommand('behavior', 'OnPress');
+ for (const c of [scene, sprite, behavior]) stack.execute(c);
+ stack.execute(new AddVariableCommand(behavior.asset.id, 'clicks', 'number'));
+ const click = new AddNodeCommand(behavior.asset.id, 'OnClick', 0, 0);
+ const get = new AddNodeCommand(behavior.asset.id, 'GetVariable', 0, 100, { variable: 'clicks' });
+ const one = new AddNodeCommand(behavior.asset.id, 'Number', 0, 200, { value: 1 });
+ const add = new AddNodeCommand(behavior.asset.id, 'Add', 100, 150);
+ const set = new AddNodeCommand(behavior.asset.id, 'SetVariable', 200, 0, { variable: 'clicks' });
+ for (const c of [click, get, one, add, set]) stack.execute(c);
+ for (const [from, fromPort, to, toPort] of [
+ [click, 'exec', set, 'exec'],
+ [get, 'value', add, 'a'],
+ [one, 'value', add, 'b'],
+ [add, 'result', set, 'value'],
+ ] as const) {
+ stack.execute(new ConnectCommand(behavior.asset.id, { node: from.node.id, port: fromPort }, { node: to.node.id, port: toPort }));
+ }
+ const button = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
+ stack.execute(button);
+ const logic = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 });
+ stack.execute(logic);
+ stack.execute(new SetParentCommand(scene.asset.id, logic.objectId, button.objectId));
+
+ const world = World.instantiate(project, scene.asset.id);
+ world.addSystem(new BehaviorSystem(project, new Input(), null));
+ const entity = world.findByObjectId(logic.objectId)!;
+
+ world.movePointer(500, 500, true); // beside the button
+ world.setPointerDown(true);
+ world.tick(0.1);
+ expect(entity.props.clicks).toBe(0);
+
+ world.setPointerDown(false);
+ world.movePointer(10, 10, true); // on the button
+ world.setPointerDown(true);
+ world.tick(0.1);
+ expect(entity.props.clicks).toBe(1);
+ world.tick(0.1); // still held – the press was one frame
+ expect(entity.props.clicks).toBe(1);
+ });
+});
+
+/** A 100×100 wall at x = 200 and a 100×100 mover at the origin, both with box colliders. */
+function collisionSetup() {
+ const project = createEmptyProject('p', 'P');
+ const stack = new CommandStack(project);
+ const scene = new CreateAssetCommand('scene', 'Level');
+ const sprite = new CreateAssetCommand('sprite', 'Box');
+ stack.execute(scene);
+ stack.execute(sprite);
+ const moverObject = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
+ const wallObject = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 200, y: 0 });
+ stack.execute(moverObject);
+ stack.execute(wallObject);
+ const moverCollider = new AddColliderCommand(scene.asset.id, moverObject.objectId);
+ const wallCollider = new AddColliderCommand(scene.asset.id, wallObject.objectId);
+ stack.execute(moverCollider);
+ stack.execute(wallCollider);
+ const world = World.instantiate(project, scene.asset.id);
+ return {
+ world,
+ mover: world.findByObjectId(moverObject.objectId)!,
+ wall: world.findByObjectId(wallCollider.objectId)!,
+ };
+}
diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts
index c6650c30..778ef401 100644
--- a/tests/core/graph.test.ts
+++ b/tests/core/graph.test.ts
@@ -9,10 +9,12 @@ import type { BehaviorAsset } from '../../src/core/model/Asset';
import { createEmptyProject } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
-function entity(name: string, x = 0): EntityHandle {
- return { id: name, name, x, y: 0, z: 0, props: {}, parent: null };
+function entity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle {
+ return { id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null };
}
+const NO_POINTER = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false };
+
function context(overrides: Partial = {}): NodeContext & { logs: string[]; stopped: boolean } {
const logs: string[] = [];
const ctx = {
@@ -25,6 +27,8 @@ function context(overrides: Partial = {}): NodeContext & { logs: st
stopped: false,
findByName: () => null,
isKeyDown: () => false,
+ pointer: { ...NO_POINTER },
+ hitTest: () => false,
isTouching: () => false,
moveAndSlide: (target: EntityHandle, dx: number, dy: number) => {
target.x += dx;
@@ -35,6 +39,7 @@ function context(overrides: Partial = {}): NodeContext & { logs: st
spawn: () => null,
destroy: () => {},
stop: () => { ctx.stopped = true; },
+ restart: () => {},
...overrides,
};
return ctx;
@@ -326,7 +331,7 @@ describe('variables', () => {
stack.execute(new RenameVariableCommand('mover', 'speed', 'velocity'));
expect(behavior.variables[0]!.key).toBe('velocity');
- expect(behavior.props).toEqual({ velocity: 0 });
+ expect(behavior.props).toEqual({ enabled: true, velocity: 0 });
expect(object.overrides).toEqual({ velocity: 5 });
expect(behavior.graph.nodes.find((n) => n.id === nodes.speed.node.id)!.params.variable).toBe('velocity');
stack.undo();
@@ -373,7 +378,7 @@ describe('behavior serialization', () => {
const loaded = deserializeProject(serializeProject(project)).assets.find((a) => a.id === 'mover') as BehaviorAsset;
expect(loaded.variables).toEqual(behavior.variables);
expect(loaded.graph).toEqual(behavior.graph);
- expect(loaded.props).toEqual({ speed: 0 });
+ expect(loaded.props).toEqual({ enabled: true, speed: 0 });
const raw = serializeProject(project) as { assets: { graph?: { nodes: unknown[]; edges: unknown[] } }[] };
raw.assets[0]!.graph!.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} });
diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts
index d9811f6c..3c700810 100644
--- a/tests/core/hierarchy.test.ts
+++ b/tests/core/hierarchy.test.ts
@@ -133,8 +133,8 @@ describe('runtime hierarchy', () => {
});
it('Get position exposes local and world coordinates, Parent walks up', () => {
- const parent = { id: 'p', name: 'p', x: 100, y: 20, z: 0, props: {}, parent: null };
- const child = { id: 'c', name: 'c', x: 5, y: 5, z: 0, props: {}, parent };
+ const parent = { id: 'p', assetId: 'a-p', name: 'p', x: 100, y: 20, z: 0, props: {}, parent: null };
+ const child = { id: 'c', assetId: 'a-c', name: 'c', x: 5, y: 5, z: 0, props: {}, parent };
const graph = { nodes: [
{ id: 'start', type: 'OnStart', x: 0, y: 0, params: {} },
{ id: 'self', type: 'Self', x: 0, y: 0, params: {} },
@@ -156,7 +156,7 @@ describe('runtime hierarchy', () => {
{ id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } },
] };
const logs: string[] = [];
- const ctx: NodeContext = { self: child, owner: parent, variables: {}, time: 0, dt: 0, findByName: () => null, isKeyDown: () => false, isTouching: () => false, moveAndSlide: () => ({ blockedX: false, blockedY: false }), log: (m) => logs.push(m), spawn: () => null, destroy: () => {}, stop: () => {} };
+ const ctx: NodeContext = { self: child, owner: parent, variables: {}, time: 0, dt: 0, findByName: () => null, isKeyDown: () => false, pointer: { x: 0, y: 0, inside: false, down: false, pressed: false, released: false }, hitTest: () => false, isTouching: () => false, moveAndSlide: () => ({ blockedX: false, blockedY: false }), log: (m) => logs.push(m), spawn: () => null, destroy: () => {}, stop: () => {}, restart: () => {} };
new GraphInterpreter(graph).fire('OnStart', ctx);
expect(logs).toEqual(['105', '5', '100']);
});
diff --git a/tests/core/resolve.test.ts b/tests/core/resolve.test.ts
index f5ec1106..f68f1f12 100644
--- a/tests/core/resolve.test.ts
+++ b/tests/core/resolve.test.ts
@@ -15,7 +15,7 @@ describe('PropertySchema', () => {
});
it('provides defaults for every key', () => {
- expect(defaultsFor('sprite')).toEqual({ width: 100, height: 100, color: 0xe0553f });
+ expect(defaultsFor('sprite')).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f });
});
it('coerces values against the definition', () => {
@@ -33,14 +33,14 @@ describe('resolve', () => {
it('fills asset props with defaults and ignores unknown keys', () => {
const sprite = buildAsset('sprite', 'Box');
sprite.props = { width: 50, bogus: 1 };
- expect(resolveAssetProps(sprite)).toEqual({ width: 50, height: 100, color: 0xe0553f });
+ expect(resolveAssetProps(sprite)).toEqual({ enabled: true, width: 50, height: 100, color: 0xe0553f });
});
it('applies sparse overrides on top of asset values', () => {
const sprite = buildAsset('sprite', 'Box');
sprite.props.color = 0x00ff00;
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
- expect(resolveSceneObjectProps(sprite, object)).toEqual({ width: 100, height: 100, color: 0x00ff00 });
+ expect(resolveSceneObjectProps(sprite, object)).toEqual({ enabled: true, width: 100, height: 100, color: 0x00ff00 });
object.overrides.color = 0x0000ff;
expect(resolveSceneObjectProps(sprite, object).color).toBe(0x0000ff);
diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts
index 3d5703a4..febeb077 100644
--- a/tests/core/serialize.test.ts
+++ b/tests/core/serialize.test.ts
@@ -56,6 +56,6 @@ describe('serialize', () => {
templateId: '',
});
expect(project.assets[1]!.name).toBe('sprite');
- expect(project.assets[1]!.props).toEqual({ width: 10, height: 100, color: 0xe0553f });
+ expect(project.assets[1]!.props).toEqual({ enabled: true, width: 10, height: 100, color: 0xe0553f });
});
});
diff --git a/tools/e2e.mjs b/tools/e2e.mjs
index fee8ce4e..4dcb9999 100644
--- a/tools/e2e.mjs
+++ b/tools/e2e.mjs
@@ -1,134 +1,23 @@
#!/usr/bin/env node
/**
- * Headless end-to-end smoke test driven over the Chrome DevTools Protocol.
- * Usage: PATH=/opt/node22/bin:$PATH node tools/e2e.mjs [baseUrl]
+ * Headless end-to-end test driven over the Chrome DevTools Protocol.
+ * Usage: PATH=/opt/node22/bin:$PATH node tools/e2e.mjs [projectId] [baseUrl]
* Writes screenshots to /tmp/gm-e2e-*.png and prints PASS/FAIL checks. No dependencies.
*
- * Widgets are located through `window.__gm.locate(text)` (installed by the editor), so
- * the test does not depend on pixel coordinates.
- *
- * Expects the fixture scene from tools/seed-e2e.mjs (not the demo scene) and mutates it:
- * node tools/seed-e2e.mjs && node tools/e2e.mjs && node tools/seed-demo.mjs
+ * Runs against the dedicated fixture project (the demo project is refused) and mutates it:
+ * node tools/seed-e2e.mjs && node tools/e2e.mjs
+ * The scene it expects is produced by tools/seed-e2e.mjs.
*/
-import { spawn } from 'node:child_process';
-import { writeFileSync } from 'node:fs';
+import { runChecks } from './harness.mjs';
+import { PROJECTS, cliArgs } from './projects.mjs';
-const projectId = process.argv[2];
-const baseUrl = process.argv[3] ?? 'https://nockal.com/game-maker/';
-if (!projectId) {
- console.error('usage: node tools/e2e.mjs [baseUrl]');
- process.exit(2);
-}
-const PORT = 9333;
-const W = 1280;
-const H = 800;
+const { projectId, baseUrl } = cliArgs('e2e.mjs', PROJECTS.fixture);
-const chrome = spawn('chromium', [
- '--headless', '--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader',
- `--remote-debugging-port=${PORT}`, `--window-size=${W},${H}`, '--hide-scrollbars', 'about:blank',
-], { stdio: 'ignore' });
-
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-
-async function waitForChrome() {
- for (let i = 0; i < 50; i++) {
- try {
- const res = await fetch(`http://127.0.0.1:${PORT}/json/version`);
- if (res.ok) return res.json();
- } catch {}
- await sleep(200);
- }
- throw new Error('chromium did not start');
-}
-
-class CDP {
- constructor(ws) {
- this.ws = ws;
- this.id = 0;
- this.pending = new Map();
- this.listeners = [];
- ws.addEventListener('message', (ev) => {
- const msg = JSON.parse(ev.data);
- if (msg.id && this.pending.has(msg.id)) {
- const { resolve, reject } = this.pending.get(msg.id);
- this.pending.delete(msg.id);
- msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result);
- } else if (msg.method) {
- for (const l of this.listeners) l(msg);
- }
- });
- }
- send(method, params = {}) {
- const id = ++this.id;
- this.ws.send(JSON.stringify({ id, method, params }));
- return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
- }
- on(fn) { this.listeners.push(fn); }
-}
-
-const consoleLog = [];
-let failures = 0;
-function check(name, ok, detail = '') {
- console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' – ' + detail : ''}`);
- if (!ok) failures++;
-}
-
-try {
- await waitForChrome();
- const target = await (await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, { method: 'PUT' })).json();
- const ws = new WebSocket(target.webSocketDebuggerUrl);
- await new Promise((r) => ws.addEventListener('open', r));
- const cdp = new CDP(ws);
- await cdp.send('Page.enable');
- await cdp.send('Runtime.enable');
- await cdp.send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 1, mobile: false });
- cdp.on((msg) => {
- if (msg.method === 'Runtime.consoleAPICalled') consoleLog.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
- if (msg.method === 'Runtime.exceptionThrown') consoleLog.push('EXCEPTION ' + (msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text));
- });
-
- const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value;
- const locate = async (text, last = false) => {
- const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`);
- if (!p) throw new Error(`widget with text "${text}" not found`);
- return p;
- };
- const shot = async (name) => {
- const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' });
- writeFileSync(`/tmp/gm-e2e-${name}.png`, Buffer.from(data, 'base64'));
- };
- const mouse = (type, x, y, button = 'left', extra = {}) =>
- cdp.send('Input.dispatchMouseEvent', { type, x, y, button, clickCount: type === 'mousePressed' || type === 'mouseReleased' ? 1 : 0, ...extra });
- const click = async (x, y, button = 'left') => {
- await mouse('mouseMoved', x, y);
- await mouse('mousePressed', x, y, button);
- await mouse('mouseReleased', x, y, button);
- await sleep(150);
- };
- const clickText = async (text, button = 'left') => {
- const p = await locate(text);
- await click(p.x, p.y, button);
- };
- const drag = async (x1, y1, x2, y2, steps = 12) => {
- await mouse('mouseMoved', x1, y1);
- await mouse('mousePressed', x1, y1);
- for (let i = 1; i <= steps; i++) {
- await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', { buttons: 1 });
- await sleep(16);
- }
- await mouse('mouseReleased', x2, y2);
- await sleep(150);
- };
- const keyPress = async (key, code, opts = {}) => {
- await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, ...opts });
- await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, ...opts });
- };
- const typeText = async (text) => {
- for (const ch of text) await cdp.send('Input.insertText', { text: ch });
- };
- const api = async (route) => (await fetch(`${baseUrl}api/index.php?r=${route}`)).json();
- const sceneOf = (doc) => doc.project.assets.find((a) => a.type === 'scene');
- const stateJson = () => evaluate('JSON.stringify({ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice })');
+await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async (t) => {
+ const {
+ cdp, consoleLog, check, sleep,
+ evaluate, locate, shot, mouse, click, clickText, drag, keyPress, typeText, api, sceneOf, stateJson,
+ } = t;
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
await sleep(4000);
@@ -826,20 +715,53 @@ try {
await sleep(200);
check('Apply to container writes the override into the template and the copy', (await templateA()).overrides.width === 77 && JSON.parse(await objectJson(secondCrate.id)).overrides.width === 77);
- check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
- ws.close();
-} catch (error) {
- console.error(error);
- failures++;
-} finally {
- // Wait for chromium to actually exit (a SwiftShader GPU process kept rendering at full CPU
- // once when only SIGTERM was sent); escalate to SIGKILL if it does not stop in time.
- await new Promise((resolve) => {
- if (chrome.exitCode !== null) return resolve();
- const timer = setTimeout(() => { chrome.kill('SIGKILL'); }, 5000);
- chrome.once('exit', () => { clearTimeout(timer); resolve(); });
- chrome.kill('SIGTERM');
- });
-}
-if (consoleLog.length) console.log('--- console ---\n' + consoleLog.slice(0, 20).join('\n'));
-process.exit(failures ? 1 : 0);
+ // 17. Text assets: created through the + menu, placed, and rendered as real text in the scene.
+ await clickText('+');
+ await sleep(150);
+ await clickText('Text');
+ await sleep(200);
+ const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`);
+ check('+ menu creates a text asset', Boolean(textAssetId));
+ await evaluate(`(() => {
+ const id = ${JSON.stringify(textAssetId)};
+ __gm.state.project.assets.find(a => a.id === id).props.text = 'Hello world';
+ // On the camera so the game view really renders it (outside the frame it would be culled).
+ const cam = __gm.state.activeScene.objects.find(o => o.id === 'o3').position;
+ __gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, overrides: {}, parentId: '', templateId: '' });
+ __gm.state.select({ kind: 'object', sceneId: 'scene1', objectIds: ['otext'], objectId: 'otext' });
+ __gm.state.preview();
+ })()`);
+ await sleep(300);
+ check('a placed text object draws its content in the scene', Boolean(await evaluate(`Boolean(__gm.locate('Hello world'))`)));
+ await shot('29-text-object');
+
+ // 17a. The enabled switch: every object has it, switching it off is a normal override, and a
+ // switched-off object disappears from the scene view too (it stays in the hierarchy).
+ const enabledRow = await evaluate(`__gm.locate('Enabled')`);
+ check('the inspector shows the Enabled switch for a scene object', Boolean(enabledRow));
+ const visibleBefore = (await evaluate('__gm.viewportInfo()')).visibleItems;
+ // The checkbox sits right of the 64 px label column; locate() gives the label's text centre.
+ await click(enabledRow.x + 55, enabledRow.y);
+ await sleep(250);
+ const textObject = JSON.parse(await objectJson('otext'));
+ check('clicking it writes an override instead of changing the asset', textObject.overrides.enabled === false, JSON.stringify(textObject.overrides));
+ check('a switched-off object is not drawn in the scene view', (await evaluate(`JSON.stringify(__gm.drawOrder().includes('otext'))`)) === 'false');
+ const visibleAfter = (await evaluate('__gm.viewportInfo()')).visibleItems;
+ check('the scene view renders one item less', visibleAfter === visibleBefore - 1, `${visibleBefore} → ${visibleAfter}`);
+ check('it stays selectable in the hierarchy, so its switch stays reachable', Boolean(await evaluate(`__gm.locate('Enabled')`)));
+ await shot('30-disabled');
+
+ // 17b. In the running game a switched-off object does not exist: it is not rendered, and
+ // switching it back on brings it back.
+ await clickText('▶ Play');
+ await sleep(500);
+ const offItems = JSON.parse(await evaluate('JSON.stringify(__gm.gameInfo().visibleItems)'));
+ await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'otext').overrides.enabled = true; __gm.state.preview(); })()`);
+ await sleep(400);
+ const onItems = JSON.parse(await evaluate('JSON.stringify(__gm.gameInfo().visibleItems)'));
+ check('the game renders one item more once the object is switched on', onItems === offItems + 1, `${offItems} → ${onItems}`);
+ await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
+ await sleep(300);
+ check('stop leaves the play-mode switch behind', JSON.parse(await objectJson('otext')).overrides.enabled === false);
+
+});
diff --git a/tools/harness.mjs b/tools/harness.mjs
new file mode 100644
index 00000000..8ab0a7cd
--- /dev/null
+++ b/tools/harness.mjs
@@ -0,0 +1,144 @@
+#!/usr/bin/env node
+/**
+ * Shared headless-Chromium harness for the check scripts (e2e.mjs, smoke-demo.mjs).
+ *
+ * Starts chromium with remote debugging, opens a CDP session and hands a set of helpers
+ * to the body function. Widgets are located through `window.__gm.locate(text)` (installed
+ * by the editor), so checks never depend on pixel coordinates.
+ *
+ * await runChecks({ projectId, baseUrl, port, shotPrefix }, async (t) => { ... });
+ *
+ * The harness always waits for chromium to actually exit (a SwiftShader GPU process kept
+ * rendering at full CPU once when only SIGTERM was sent) and exits with code 1 on failures.
+ */
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+class CDP {
+ constructor(ws) {
+ this.ws = ws;
+ this.id = 0;
+ this.pending = new Map();
+ this.listeners = [];
+ ws.addEventListener('message', (ev) => {
+ const msg = JSON.parse(ev.data);
+ if (msg.id && this.pending.has(msg.id)) {
+ const { resolve, reject } = this.pending.get(msg.id);
+ this.pending.delete(msg.id);
+ msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result);
+ } else if (msg.method) {
+ for (const l of this.listeners) l(msg);
+ }
+ });
+ }
+ send(method, params = {}) {
+ const id = ++this.id;
+ this.ws.send(JSON.stringify({ id, method, params }));
+ return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
+ }
+ on(fn) { this.listeners.push(fn); }
+}
+
+async function waitForChrome(port) {
+ for (let i = 0; i < 50; i++) {
+ try {
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`);
+ if (res.ok) return res.json();
+ } catch {}
+ await sleep(200);
+ }
+ throw new Error('chromium did not start');
+}
+
+export async function runChecks(options, body) {
+ const { projectId, baseUrl, port = 9333, shotPrefix = 'gm-e2e', width = 1280, height = 800 } = options;
+ const chrome = spawn('chromium', [
+ '--headless', '--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader',
+ `--remote-debugging-port=${port}`, `--window-size=${width},${height}`, '--hide-scrollbars', 'about:blank',
+ ], { stdio: 'ignore' });
+
+ const consoleLog = [];
+ let failures = 0;
+ function check(name, ok, detail = '') {
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' – ' + detail : ''}`);
+ if (!ok) failures++;
+ }
+
+ try {
+ await waitForChrome(port);
+ const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json();
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
+ await new Promise((r) => ws.addEventListener('open', r));
+ const cdp = new CDP(ws);
+ await cdp.send('Page.enable');
+ await cdp.send('Runtime.enable');
+ await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
+ cdp.on((msg) => {
+ if (msg.method === 'Runtime.consoleAPICalled') consoleLog.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
+ if (msg.method === 'Runtime.exceptionThrown') consoleLog.push('EXCEPTION ' + (msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text));
+ });
+
+ const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value;
+ const locate = async (text, last = false) => {
+ const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`);
+ if (!p) throw new Error(`widget with text "${text}" not found`);
+ return p;
+ };
+ const shot = async (name) => {
+ const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' });
+ writeFileSync(`/tmp/${shotPrefix}-${name}.png`, Buffer.from(data, 'base64'));
+ };
+ const mouse = (type, x, y, button = 'left', extra = {}) =>
+ cdp.send('Input.dispatchMouseEvent', { type, x, y, button, clickCount: type === 'mousePressed' || type === 'mouseReleased' ? 1 : 0, ...extra });
+ const click = async (x, y, button = 'left') => {
+ await mouse('mouseMoved', x, y);
+ await mouse('mousePressed', x, y, button);
+ await mouse('mouseReleased', x, y, button);
+ await sleep(150);
+ };
+ const clickText = async (text, button = 'left') => {
+ const p = await locate(text);
+ await click(p.x, p.y, button);
+ };
+ const drag = async (x1, y1, x2, y2, steps = 12) => {
+ await mouse('mouseMoved', x1, y1);
+ await mouse('mousePressed', x1, y1);
+ for (let i = 1; i <= steps; i++) {
+ await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', { buttons: 1 });
+ await sleep(16);
+ }
+ await mouse('mouseReleased', x2, y2);
+ await sleep(150);
+ };
+ const keyPress = async (key, code, opts = {}) => {
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, ...opts });
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, ...opts });
+ };
+ const typeText = async (text) => {
+ for (const ch of text) await cdp.send('Input.insertText', { text: ch });
+ };
+ const api = async (route) => (await fetch(`${baseUrl}api/index.php?r=${route}`)).json();
+ const sceneOf = (doc) => doc.project.assets.find((a) => a.type === 'scene');
+ const stateJson = () => evaluate('JSON.stringify({ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice })');
+
+ await body({
+ cdp, ws, projectId, baseUrl, consoleLog, check, sleep,
+ evaluate, locate, shot, mouse, click, clickText, drag, keyPress, typeText, api, sceneOf, stateJson,
+ });
+ ws.close();
+ } catch (error) {
+ console.error(error);
+ failures++;
+ } finally {
+ await new Promise((resolve) => {
+ if (chrome.exitCode !== null) return resolve();
+ const timer = setTimeout(() => { chrome.kill('SIGKILL'); }, 5000);
+ chrome.once('exit', () => { clearTimeout(timer); resolve(); });
+ chrome.kill('SIGTERM');
+ });
+ }
+ if (consoleLog.length) console.log('--- console ---\n' + consoleLog.slice(0, 20).join('\n'));
+ process.exit(failures ? 1 : 0);
+}
diff --git a/tools/projects.mjs b/tools/projects.mjs
new file mode 100644
index 00000000..cb153911
--- /dev/null
+++ b/tools/projects.mjs
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+/**
+ * Which project each tool writes to, and the guard that keeps the checks away from the
+ * playground.
+ *
+ * demo the project served at ?project=… in the browser. It is the user's playground;
+ * only tools/seed-demo.mjs may overwrite it, and only when asked.
+ * fixture rewritten by every e2e run. tools/e2e.mjs mutates it heavily.
+ * demoCheck a throwaway copy of the demo scene for tools/smoke-demo.mjs.
+ * jumpRun the example game (tools/seed-jumprun.mjs, checked by tools/smoke-jumprun.mjs).
+ *
+ * All ids exist on the server (created via POST ?r=projects); PUT does not create projects.
+ */
+export const PROJECTS = {
+ demo: '649188c15414',
+ fixture: '3caa9b6a0c92',
+ demoCheck: 'aaf5e962ef9c',
+ jumpRun: '3d271f3bb58b',
+};
+
+export const DEFAULT_BASE_URL = 'https://nockal.com/game-maker/';
+
+/**
+ * Reads `[projectId] [baseUrl]` from argv, falling back to `defaultId`. Unless `allowDemo` is
+ * set, targeting the demo project aborts: a destructive run must never hit the playground.
+ */
+export function cliArgs(script, defaultId, { allowDemo = false } = {}) {
+ const projectId = process.argv[2] ?? defaultId;
+ const baseUrl = process.argv[3] ?? DEFAULT_BASE_URL;
+ if (!projectId) {
+ console.error(`usage: node tools/${script} [baseUrl]`);
+ process.exit(2);
+ }
+ if (!allowDemo && projectId === PROJECTS.demo) {
+ console.error(`refusing to run ${script} against the demo project (${PROJECTS.demo}) – it is the playground.`);
+ process.exit(2);
+ }
+ return { projectId, baseUrl };
+}
+
+/** Overwrites a project, keeping the name it already has on the server. */
+export async function writeProject(projectId, baseUrl, project) {
+ const url = `${baseUrl}api/index.php?r=projects/${projectId}`;
+ const current = await fetch(url);
+ if (!current.ok) {
+ console.error(`GET failed: ${current.status} – create the project first (POST ?r=projects)`);
+ process.exit(1);
+ }
+ const doc = await current.json();
+ const body = { revision: doc.revision, project: { ...project, id: projectId, name: doc.name ?? project.name } };
+ const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ const result = await res.json();
+ if (!res.ok) {
+ console.error('PUT failed', res.status, result);
+ process.exit(1);
+ }
+ console.log(`seeded ${projectId} (${body.project.name}) → revision ${result.revision}`);
+}
diff --git a/tools/seed-demo.mjs b/tools/seed-demo.mjs
index 8df662f9..b334bae6 100644
--- a/tools/seed-demo.mjs
+++ b/tools/seed-demo.mjs
@@ -2,14 +2,13 @@
/**
* Writes the demo project: a "Player" container (green character with the Mover behavior and a
* collider) facing two "Enemy" container instances, plus a Spawner that drops more enemies.
- * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-demo.mjs [baseUrl]
+ * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-demo.mjs [projectId] [baseUrl]
+ * Defaults to the demo project – the only script allowed to overwrite the playground. Pass
+ * PROJECTS.demoCheck to seed the throwaway copy that tools/smoke-demo.mjs runs against.
*/
-const projectId = process.argv[2];
-const baseUrl = process.argv[3] ?? 'https://nockal.com/game-maker/';
-if (!projectId) {
- console.error('usage: node tools/seed-demo.mjs [baseUrl]');
- process.exit(2);
-}
+import { PROJECTS, cliArgs, writeProject } from './projects.mjs';
+
+const { projectId, baseUrl } = cliArgs('seed-demo.mjs', PROJECTS.demo, { allowDemo: true });
/**
* "Mover" behavior in five nodes: every frame, move the owner along the normalized arrow-key
@@ -123,17 +122,4 @@ const project = {
],
};
-const url = `${baseUrl}api/index.php?r=projects/${projectId}`;
-const current = await fetch(url);
-if (!current.ok) {
- console.error(`GET failed: ${current.status}`);
- process.exit(1);
-}
-const { revision } = await current.json();
-const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ revision, project }) });
-const body = await res.json();
-if (!res.ok) {
- console.error('PUT failed', res.status, body);
- process.exit(1);
-}
-console.log(`seeded ${projectId} → revision ${body.revision}`);
+await writeProject(projectId, baseUrl, project);
diff --git a/tools/seed-e2e.mjs b/tools/seed-e2e.mjs
index 1c89706e..9a0af08c 100644
--- a/tools/seed-e2e.mjs
+++ b/tools/seed-e2e.mjs
@@ -5,15 +5,12 @@
* from tools/seed-demo.mjs so the shipped demo can change without rewriting 100+ checks; the
* fixture's objects are uniquely named and positioned exactly where the checks expect them.
*
- * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-e2e.mjs [baseUrl]
- * Run tools/seed-demo.mjs afterwards to put the demo project back.
+ * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-e2e.mjs [projectId] [baseUrl]
+ * Defaults to the dedicated fixture project; the demo project is refused.
*/
-const projectId = process.argv[2];
-const baseUrl = process.argv[3] ?? 'https://nockal.com/game-maker/';
-if (!projectId) {
- console.error('usage: node tools/seed-e2e.mjs [baseUrl]');
- process.exit(2);
-}
+import { PROJECTS, cliArgs, writeProject } from './projects.mjs';
+
+const { projectId, baseUrl } = cliArgs('seed-e2e.mjs', PROJECTS.fixture);
/**
* "Mover" behavior in five nodes: every frame, move the owner along the normalized arrow-key
@@ -93,17 +90,4 @@ const project = {
],
};
-const url = `${baseUrl}api/index.php?r=projects/${projectId}`;
-const current = await fetch(url);
-if (!current.ok) {
- console.error(`GET failed: ${current.status}`);
- process.exit(1);
-}
-const { revision } = await current.json();
-const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ revision, project }) });
-const body = await res.json();
-if (!res.ok) {
- console.error('PUT failed', res.status, body);
- process.exit(1);
-}
-console.log(`seeded ${projectId} → revision ${body.revision}`);
+await writeProject(projectId, baseUrl, project);
diff --git a/tools/seed-jumprun.mjs b/tools/seed-jumprun.mjs
new file mode 100644
index 00000000..2ab58ff6
--- /dev/null
+++ b/tools/seed-jumprun.mjs
@@ -0,0 +1,408 @@
+#!/usr/bin/env node
+/**
+ * Writes the example game: a small jump and run built entirely from assets, behaviors and the
+ * node graph – no engine code is involved.
+ *
+ * Title screen (a container with a panel, texts and a clickable Start button)
+ * Level (ground, platforms, walls, ten coins, the player and the HUD labels)
+ * Win / Lose (containers shown when the coins are collected or the two minutes are up)
+ *
+ * Screens are containers that are switched on and off with the `enabled` property – the same
+ * switch the inspector shows for every object. Everything else is nodes: gravity and jumping
+ * on the player, timer, coin counter and screen flow on the "Game" behavior.
+ *
+ * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs [projectId] [baseUrl]
+ */
+import { PROJECTS, cliArgs, writeProject } from './projects.mjs';
+
+const { projectId, baseUrl } = cliArgs('seed-jumprun.mjs', PROJECTS.jumpRun);
+
+// --- colours ---------------------------------------------------------------------------------
+
+const SKY = 0x151821;
+const STONE = 0x3c4454;
+const GRASS = 0x4a7d3a;
+const PLAYER = 0x63c74d;
+const COIN = 0xf5c542;
+const PANEL = 0x11141c;
+const BUTTON = 0x63c74d;
+const WHITE = 0xf2f5f7;
+
+// --- level geometry (the camera shows x −320…320, y −180…180; +y is down) ---------------------
+
+/** A solid box: the sprite plus a collider child of the same size. */
+const solid = (id, x, y, w, h, color, name = '') => [
+ { id, assetId: 'box', name, position: { x, y, z: 0 }, overrides: { width: w, height: h, color }, parentId: '', templateId: '' },
+ { id: `${id}-c`, assetId: 'solid', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: w, height: h }, parentId: id, templateId: '' },
+];
+
+/** A coin: sprite plus a trigger collider, so it reports the touch without blocking. */
+const coin = (id, x, y) => [
+ { id, assetId: 'coin-sprite', name: 'Coin', position: { x, y, z: 1 }, overrides: {}, parentId: '', templateId: '' },
+ { id: `${id}-c`, assetId: 'trigger', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: id, templateId: '' },
+];
+
+const text = (id, x, y, content, { size = 28, color = WHITE, align = 'center', name = '', parentId = '', z = 1 } = {}) => ({
+ id,
+ assetId: 'label',
+ name,
+ position: { x, y, z },
+ overrides: { text: content, fontSize: size, color, align },
+ parentId,
+ templateId: '',
+});
+
+const levelTemplate = [
+ // Ground, side walls and six platforms.
+ ...solid('ground', 0, 165, 660, 30, GRASS),
+ ...solid('wall-l', -315, 0, 10, 360, STONE),
+ ...solid('wall-r', 315, 0, 10, 360, STONE),
+ ...solid('p1', -170, 90, 120, 16, STONE),
+ ...solid('p2', -30, 30, 140, 16, STONE),
+ ...solid('p3', 170, 60, 140, 16, STONE),
+ ...solid('p4', 60, -50, 120, 16, STONE),
+ ...solid('p5', -230, -30, 120, 16, STONE),
+ ...solid('p6', 255, -60, 110, 16, STONE),
+ // Ten coins: three on the ground, one above each platform, one in the far corner.
+ ...coin('c1', -150, 128),
+ ...coin('c2', 0, 128),
+ ...coin('c3', 150, 128),
+ ...coin('c4', -170, 60),
+ ...coin('c5', -30, 0),
+ ...coin('c6', 170, 30),
+ ...coin('c7', 60, -80),
+ ...coin('c8', -230, -60),
+ ...coin('c9', 255, -90),
+ ...coin('c10', 290, 128),
+ // The player: sprite, solid collider and the behavior that owns it.
+ { id: 'player', assetId: 'player-sprite', name: 'Player', position: { x: -270, y: 125, z: 2 }, overrides: {}, parentId: '', templateId: '' },
+ { id: 'player-c', assetId: 'solid', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 30, height: 40 }, parentId: 'player', templateId: '' },
+ { id: 'player-logic', assetId: 'player', name: '', position: { x: 0, y: -60, z: 0 }, overrides: {}, parentId: 'player', templateId: '' },
+ // HUD, top left and top right of the camera frame.
+ text('time-label', -300, -160, 'Time 120', { size: 22, align: 'left', name: 'TimeLabel', z: 3 }),
+ text('coin-label', 300, -160, 'Coins 0/10', { size: 22, align: 'right', name: 'CoinLabel', z: 3 }),
+];
+
+/** A full-frame panel with a headline, a line of help text and a button carrying a behavior. */
+const screenTemplate = (prefix, headline, hint, buttonLabel, behaviorId, headlineSize = 52) => [
+ { id: `${prefix}-panel`, assetId: 'box', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 640, height: 360, color: PANEL }, parentId: '', templateId: '' },
+ text(`${prefix}-title`, 0, -70, headline, { size: headlineSize }),
+ text(`${prefix}-hint`, 0, -10, hint, { size: 20, color: 0x9aa3b2 }),
+ { id: `${prefix}-button`, assetId: 'box', name: '', position: { x: 0, y: 80, z: 1 }, overrides: { width: 240, height: 60, color: BUTTON }, parentId: '', templateId: '' },
+ text(`${prefix}-button-label`, 0, 0, buttonLabel, { size: 26, color: 0x10240a, parentId: `${prefix}-button`, z: 1 }),
+ { id: `${prefix}-button-logic`, assetId: behaviorId, name: '', position: { x: 0, y: -60, z: 0 }, overrides: {}, parentId: `${prefix}-button`, templateId: '' },
+];
+
+const titleTemplate = screenTemplate('title', 'JUMP & RUN', 'Arrow keys to move, Space to jump.', 'START', 'start-button');
+const winTemplate = screenTemplate('win', 'YOU WIN!', 'All ten coins collected in time.', 'PLAY AGAIN', 'restart-button');
+const loseTemplate = screenTemplate('lose', 'TIME UP', 'The two minutes are over – try again.', 'PLAY AGAIN', 'restart-button');
+
+/** One placed container: the container object plus a copy of every template object. */
+const instantiate = (containerId, assetId, name, position, template, overrides = {}) => [
+ { id: containerId, assetId, name, position, overrides, parentId: '', templateId: '' },
+ ...template.map((t) => ({ ...t, id: `${containerId}-${t.id}`, parentId: t.parentId ? `${containerId}-${t.parentId}` : containerId, templateId: t.id })),
+];
+
+// --- graphs ----------------------------------------------------------------------------------
+
+const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
+
+/**
+ * "Player": gravity, walking, jumping and picking coins up – 33 nodes in three strands.
+ *
+ * On update vy += gravity·dt, then Move(owner) by (axis·speed, vy)·dt with collisions on.
+ * Blocked vertically while falling means standing: vy back to 0, onGround true.
+ * On key down Space while onGround shoots vy up (negative is up).
+ * On collision Touching a coin adds one to the "Game" behavior's counter and destroys it.
+ */
+const playerGraph = {
+ nodes: [
+ { id: 'p-upd', type: 'OnUpdate', x: 0, y: 0, params: {} },
+ { id: 'p-owner', type: 'Owner', x: 0, y: 140, params: {} },
+ { id: 'p-axis', type: 'AxisInput', x: 0, y: 220, params: { left: 'ArrowLeft', right: 'ArrowRight', up: '', down: '' } },
+ { id: 'p-speed', type: 'GetVariable', x: 0, y: 360, params: { variable: 'speed' } },
+ { id: 'p-mulx', type: 'Multiply', x: 220, y: 300, params: {} },
+ { id: 'p-vy', type: 'GetVariable', x: 0, y: 440, params: { variable: 'vy' } },
+ { id: 'p-grav', type: 'GetVariable', x: 0, y: 520, params: { variable: 'gravity' } },
+ { id: 'p-mulg', type: 'Multiply', x: 220, y: 500, params: {} },
+ { id: 'p-addvy', type: 'Add', x: 420, y: 460, params: {} },
+ { id: 'p-setvy', type: 'SetVariable', x: 620, y: 0, params: { variable: 'vy' } },
+ { id: 'p-vy2', type: 'GetVariable', x: 620, y: 340, params: { variable: 'vy' } },
+ { id: 'p-move', type: 'Move', x: 840, y: 0, params: { collide: true } },
+ { id: 'p-br', type: 'Branch', x: 1080, y: 0, params: {} },
+ { id: 'p-seq', type: 'Sequence', x: 1260, y: 0, params: {} },
+ { id: 'p-vy3', type: 'GetVariable', x: 1080, y: 260, params: { variable: 'vy' } },
+ { id: 'p-cmp', type: 'Compare', x: 1260, y: 260, params: { op: '>=', b: 0 } },
+ { id: 'p-ground', type: 'SetVariable', x: 1460, y: 0, params: { variable: 'onGround' } },
+ { id: 'p-zero', type: 'SetVariable', x: 1460, y: 140, params: { variable: 'vy', value: 0 } },
+ { id: 'p-air', type: 'SetVariable', x: 1260, y: 400, params: { variable: 'onGround', value: false } },
+
+ { id: 'p-key', type: 'OnKeyDown', x: 0, y: 700, params: { key: ' ' } },
+ { id: 'p-og', type: 'GetVariable', x: 0, y: 820, params: { variable: 'onGround' } },
+ { id: 'p-brj', type: 'Branch', x: 240, y: 700, params: {} },
+ { id: 'p-jump', type: 'GetVariable', x: 240, y: 880, params: { variable: 'jumpSpeed' } },
+ { id: 'p-neg', type: 'Multiply', x: 440, y: 880, params: { b: -1 } },
+ { id: 'p-setjump', type: 'SetVariable', x: 660, y: 700, params: { variable: 'vy' } },
+
+ { id: 'p-col', type: 'OnCollisionEnter', x: 0, y: 1060, params: {} },
+ { id: 'p-iscoin', type: 'IsAsset', x: 260, y: 1180, params: { asset: 'coin-sprite' } },
+ { id: 'p-brc', type: 'Branch', x: 480, y: 1060, params: {} },
+ { id: 'p-seqc', type: 'Sequence', x: 680, y: 1060, params: {} },
+ { id: 'p-game', type: 'ObjectByName', x: 680, y: 1280, params: { name: 'Game' } },
+ { id: 'p-coins', type: 'GetProperty', x: 880, y: 1280, params: { property: 'coins' } },
+ { id: 'p-add1', type: 'Add', x: 1080, y: 1280, params: { b: 1 } },
+ { id: 'p-setcoins', type: 'SetProperty', x: 1280, y: 1060, params: { property: 'coins' } },
+ { id: 'p-destroy', type: 'Destroy', x: 1280, y: 1200, params: {} },
+ ],
+ edges: [
+ edge('pe1', 'p-upd', 'exec', 'p-setvy', 'exec'),
+ edge('pe2', 'p-vy', 'value', 'p-addvy', 'a'),
+ edge('pe3', 'p-grav', 'value', 'p-mulg', 'a'),
+ edge('pe4', 'p-upd', 'dt', 'p-mulg', 'b'),
+ edge('pe5', 'p-mulg', 'result', 'p-addvy', 'b'),
+ edge('pe6', 'p-addvy', 'result', 'p-setvy', 'value'),
+ edge('pe7', 'p-setvy', 'exec', 'p-move', 'exec'),
+ edge('pe8', 'p-owner', 'object', 'p-move', 'object'),
+ edge('pe9', 'p-axis', 'x', 'p-mulx', 'a'),
+ edge('pe10', 'p-speed', 'value', 'p-mulx', 'b'),
+ edge('pe11', 'p-mulx', 'result', 'p-move', 'dx'),
+ edge('pe12', 'p-vy2', 'value', 'p-move', 'dy'),
+ edge('pe13', 'p-upd', 'dt', 'p-move', 'dt'),
+ edge('pe14', 'p-move', 'exec', 'p-br', 'exec'),
+ edge('pe15', 'p-move', 'blockedY', 'p-br', 'condition'),
+ edge('pe16', 'p-br', 'then', 'p-seq', 'exec'),
+ edge('pe17', 'p-seq', 'a', 'p-ground', 'exec'),
+ edge('pe18', 'p-vy3', 'value', 'p-cmp', 'a'),
+ edge('pe19', 'p-cmp', 'result', 'p-ground', 'value'),
+ edge('pe20', 'p-seq', 'b', 'p-zero', 'exec'),
+ edge('pe21', 'p-br', 'else', 'p-air', 'exec'),
+
+ edge('pe22', 'p-key', 'exec', 'p-brj', 'exec'),
+ edge('pe23', 'p-og', 'value', 'p-brj', 'condition'),
+ edge('pe24', 'p-brj', 'then', 'p-setjump', 'exec'),
+ edge('pe25', 'p-jump', 'value', 'p-neg', 'a'),
+ edge('pe26', 'p-neg', 'result', 'p-setjump', 'value'),
+
+ edge('pe27', 'p-col', 'exec', 'p-brc', 'exec'),
+ edge('pe28', 'p-col', 'other', 'p-iscoin', 'object'),
+ edge('pe29', 'p-iscoin', 'result', 'p-brc', 'condition'),
+ edge('pe30', 'p-brc', 'then', 'p-seqc', 'exec'),
+ edge('pe31', 'p-seqc', 'a', 'p-setcoins', 'exec'),
+ edge('pe32', 'p-game', 'object', 'p-coins', 'object'),
+ edge('pe33', 'p-coins', 'value', 'p-add1', 'a'),
+ edge('pe34', 'p-add1', 'result', 'p-setcoins', 'value'),
+ edge('pe35', 'p-game', 'object', 'p-setcoins', 'object'),
+ edge('pe36', 'p-seqc', 'b', 'p-destroy', 'exec'),
+ edge('pe37', 'p-col', 'other', 'p-destroy', 'object'),
+ ],
+};
+
+/**
+ * "Game": owns the run. On start it shows the title and hides everything else; while
+ * `running` it counts the clock down, writes both HUD labels and decides win (coins reached)
+ * or loss (time gone) by switching the screens.
+ */
+const gameGraph = {
+ nodes: [
+ { id: 'g-start', type: 'OnStart', x: 0, y: 0, params: {} },
+ { id: 'g-title', type: 'ObjectByName', x: 0, y: 140, params: { name: 'Title' } },
+ { id: 'g-level', type: 'ObjectByName', x: 0, y: 220, params: { name: 'Level' } },
+ { id: 'g-win', type: 'ObjectByName', x: 0, y: 300, params: { name: 'Win' } },
+ { id: 'g-lose', type: 'ObjectByName', x: 0, y: 380, params: { name: 'Lose' } },
+ { id: 'g-on1', type: 'SetEnabled', x: 260, y: 0, params: { enabled: true } },
+ { id: 'g-off1', type: 'SetEnabled', x: 480, y: 0, params: { enabled: false } },
+ { id: 'g-off2', type: 'SetEnabled', x: 700, y: 0, params: { enabled: false } },
+ { id: 'g-off3', type: 'SetEnabled', x: 920, y: 0, params: { enabled: false } },
+
+ { id: 'g-upd', type: 'OnUpdate', x: 0, y: 520, params: {} },
+ { id: 'g-running', type: 'GetVariable', x: 0, y: 640, params: { variable: 'running' } },
+ { id: 'g-brrun', type: 'Branch', x: 220, y: 520, params: {} },
+ { id: 'g-time', type: 'GetVariable', x: 220, y: 700, params: { variable: 'timeLeft' } },
+ { id: 'g-sub', type: 'Subtract', x: 420, y: 700, params: {} },
+ { id: 'g-max', type: 'Max', x: 600, y: 700, params: { b: 0 } },
+ { id: 'g-settime', type: 'SetVariable', x: 440, y: 520, params: { variable: 'timeLeft' } },
+
+ { id: 'g-timelabel', type: 'ObjectByName', x: 440, y: 860, params: { name: 'TimeLabel' } },
+ { id: 'g-time2', type: 'GetVariable', x: 440, y: 940, params: { variable: 'timeLeft' } },
+ { id: 'g-totext', type: 'ToText', x: 640, y: 940, params: { decimals: 0 } },
+ { id: 'g-join', type: 'Join', x: 840, y: 940, params: { a: 'Time ' } },
+ { id: 'g-settext', type: 'SetText', x: 700, y: 520, params: {} },
+
+ { id: 'g-coinlabel', type: 'ObjectByName', x: 700, y: 1080, params: { name: 'CoinLabel' } },
+ { id: 'g-coins', type: 'GetVariable', x: 700, y: 1160, params: { variable: 'coins' } },
+ { id: 'g-totext2', type: 'ToText', x: 900, y: 1160, params: { decimals: 0 } },
+ { id: 'g-join2', type: 'Join', x: 1080, y: 1160, params: { a: 'Coins ' } },
+ { id: 'g-join3', type: 'Join', x: 1260, y: 1160, params: { b: '/10' } },
+ { id: 'g-settext2', type: 'SetText', x: 960, y: 520, params: {} },
+
+ { id: 'g-needed', type: 'GetVariable', x: 960, y: 700, params: { variable: 'needed' } },
+ { id: 'g-cmpwin', type: 'Compare', x: 1160, y: 700, params: { op: '>=' } },
+ { id: 'g-brwin', type: 'Branch', x: 1220, y: 520, params: {} },
+ { id: 'g-stop1', type: 'SetVariable', x: 1420, y: 440, params: { variable: 'running', value: false } },
+ { id: 'g-leveloff1', type: 'SetEnabled', x: 1620, y: 440, params: { enabled: false } },
+ { id: 'g-winon', type: 'SetEnabled', x: 1820, y: 440, params: { enabled: true } },
+
+ { id: 'g-cmplose', type: 'Compare', x: 1160, y: 860, params: { op: '<=', b: 0 } },
+ { id: 'g-brlose', type: 'Branch', x: 1420, y: 640, params: {} },
+ { id: 'g-stop2', type: 'SetVariable', x: 1620, y: 640, params: { variable: 'running', value: false } },
+ { id: 'g-leveloff2', type: 'SetEnabled', x: 1820, y: 640, params: { enabled: false } },
+ { id: 'g-loseon', type: 'SetEnabled', x: 2020, y: 640, params: { enabled: true } },
+ ],
+ edges: [
+ edge('ge1', 'g-start', 'exec', 'g-on1', 'exec'),
+ edge('ge2', 'g-title', 'object', 'g-on1', 'object'),
+ edge('ge3', 'g-on1', 'exec', 'g-off1', 'exec'),
+ edge('ge4', 'g-level', 'object', 'g-off1', 'object'),
+ edge('ge5', 'g-off1', 'exec', 'g-off2', 'exec'),
+ edge('ge6', 'g-win', 'object', 'g-off2', 'object'),
+ edge('ge7', 'g-off2', 'exec', 'g-off3', 'exec'),
+ edge('ge8', 'g-lose', 'object', 'g-off3', 'object'),
+
+ edge('ge9', 'g-upd', 'exec', 'g-brrun', 'exec'),
+ edge('ge10', 'g-running', 'value', 'g-brrun', 'condition'),
+ edge('ge11', 'g-brrun', 'then', 'g-settime', 'exec'),
+ edge('ge12', 'g-time', 'value', 'g-sub', 'a'),
+ edge('ge13', 'g-upd', 'dt', 'g-sub', 'b'),
+ edge('ge14', 'g-sub', 'result', 'g-max', 'a'),
+ edge('ge15', 'g-max', 'result', 'g-settime', 'value'),
+
+ edge('ge16', 'g-settime', 'exec', 'g-settext', 'exec'),
+ edge('ge17', 'g-timelabel', 'object', 'g-settext', 'object'),
+ edge('ge18', 'g-time2', 'value', 'g-totext', 'value'),
+ edge('ge19', 'g-totext', 'text', 'g-join', 'b'),
+ edge('ge20', 'g-join', 'text', 'g-settext', 'text'),
+
+ edge('ge21', 'g-settext', 'exec', 'g-settext2', 'exec'),
+ edge('ge22', 'g-coinlabel', 'object', 'g-settext2', 'object'),
+ edge('ge23', 'g-coins', 'value', 'g-totext2', 'value'),
+ edge('ge24', 'g-totext2', 'text', 'g-join2', 'b'),
+ edge('ge25', 'g-join2', 'text', 'g-join3', 'a'),
+ edge('ge26', 'g-join3', 'text', 'g-settext2', 'text'),
+
+ edge('ge27', 'g-settext2', 'exec', 'g-brwin', 'exec'),
+ edge('ge28', 'g-coins', 'value', 'g-cmpwin', 'a'),
+ edge('ge29', 'g-needed', 'value', 'g-cmpwin', 'b'),
+ edge('ge30', 'g-cmpwin', 'result', 'g-brwin', 'condition'),
+ edge('ge31', 'g-brwin', 'then', 'g-stop1', 'exec'),
+ edge('ge32', 'g-stop1', 'exec', 'g-leveloff1', 'exec'),
+ edge('ge33', 'g-level', 'object', 'g-leveloff1', 'object'),
+ edge('ge34', 'g-leveloff1', 'exec', 'g-winon', 'exec'),
+ edge('ge35', 'g-win', 'object', 'g-winon', 'object'),
+
+ edge('ge36', 'g-brwin', 'else', 'g-brlose', 'exec'),
+ edge('ge37', 'g-time2', 'value', 'g-cmplose', 'a'),
+ edge('ge38', 'g-cmplose', 'result', 'g-brlose', 'condition'),
+ edge('ge39', 'g-brlose', 'then', 'g-stop2', 'exec'),
+ edge('ge40', 'g-stop2', 'exec', 'g-leveloff2', 'exec'),
+ edge('ge41', 'g-level', 'object', 'g-leveloff2', 'object'),
+ edge('ge42', 'g-leveloff2', 'exec', 'g-loseon', 'exec'),
+ edge('ge43', 'g-lose', 'object', 'g-loseon', 'object'),
+ ],
+};
+
+/** "Start button": a click resets the counters on the Game behavior and swaps title for level. */
+const startButtonGraph = {
+ nodes: [
+ { id: 's-click', type: 'OnClick', x: 0, y: 0, params: {} },
+ { id: 's-game', type: 'ObjectByName', x: 0, y: 160, params: { name: 'Game' } },
+ { id: 's-title', type: 'ObjectByName', x: 0, y: 240, params: { name: 'Title' } },
+ { id: 's-level', type: 'ObjectByName', x: 0, y: 320, params: { name: 'Level' } },
+ { id: 's-coins', type: 'SetProperty', x: 240, y: 0, params: { property: 'coins', value: 0 } },
+ { id: 's-time', type: 'SetProperty', x: 460, y: 0, params: { property: 'timeLeft', value: 120 } },
+ { id: 's-run', type: 'SetProperty', x: 680, y: 0, params: { property: 'running', value: true } },
+ { id: 's-hide', type: 'SetEnabled', x: 900, y: 0, params: { enabled: false } },
+ { id: 's-show', type: 'SetEnabled', x: 1120, y: 0, params: { enabled: true } },
+ ],
+ edges: [
+ edge('se1', 's-click', 'exec', 's-coins', 'exec'),
+ edge('se2', 's-game', 'object', 's-coins', 'object'),
+ edge('se3', 's-coins', 'exec', 's-time', 'exec'),
+ edge('se4', 's-game', 'object', 's-time', 'object'),
+ edge('se5', 's-time', 'exec', 's-run', 'exec'),
+ edge('se6', 's-game', 'object', 's-run', 'object'),
+ edge('se7', 's-run', 'exec', 's-hide', 'exec'),
+ edge('se8', 's-title', 'object', 's-hide', 'object'),
+ edge('se9', 's-hide', 'exec', 's-show', 'exec'),
+ edge('se10', 's-level', 'object', 's-show', 'object'),
+ ],
+};
+
+/** "Restart button": one click, one node – the scene starts over from its saved state. */
+const restartButtonGraph = {
+ nodes: [
+ { id: 'r-click', type: 'OnClick', x: 0, y: 0, params: {} },
+ { id: 'r-restart', type: 'RestartGame', x: 260, y: 0, params: {} },
+ ],
+ edges: [edge('re1', 'r-click', 'exec', 'r-restart', 'exec')],
+};
+
+const variable = (key, kind, value) => ({ key, label: key, kind, default: value });
+
+// --- project ---------------------------------------------------------------------------------
+
+const project = {
+ formatVersion: 1,
+ id: projectId,
+ name: 'Jump and Run',
+ activeSceneId: 'scene1',
+ assets: [
+ {
+ id: 'scene1',
+ type: 'scene',
+ name: 'Level 1',
+ props: { backgroundColor: SKY },
+ objects: [
+ ...instantiate('L', 'level', 'Level', { x: 0, y: 0, z: 0 }, levelTemplate),
+ // All three screens sit on the camera frame and start switched off, so the level stays
+ // visible in the editor; the Game behavior decides on `On start` which one is up.
+ ...instantiate('T', 'title', 'Title', { x: 0, y: 0, z: 10 }, titleTemplate, { enabled: false }),
+ ...instantiate('W', 'win', 'Win', { x: 0, y: 0, z: 10 }, winTemplate, { enabled: false }),
+ ...instantiate('B', 'lose', 'Lose', { x: 0, y: 0, z: 10 }, loseTemplate, { enabled: false }),
+ { id: 'ocam', assetId: 'cam', name: 'Main Camera', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: '', templateId: '' },
+ { id: 'ogame', assetId: 'game', name: 'Game', position: { x: -200, y: -220, z: 0 }, overrides: {}, parentId: '', templateId: '' },
+ ],
+ },
+ { id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } },
+ { id: 'box', type: 'sprite', name: 'Box', props: { width: 100, height: 100, color: STONE } },
+ { id: 'player-sprite', type: 'sprite', name: 'Player Sprite', props: { width: 30, height: 40, color: PLAYER } },
+ { id: 'coin-sprite', type: 'sprite', name: 'Coin Sprite', props: { width: 24, height: 24, color: COIN } },
+ { id: 'label', type: 'text', name: 'Label', props: { text: 'Text', fontSize: 28, color: WHITE, align: 'center' } },
+ { id: 'solid', type: 'collider', name: 'Solid Collider', props: { shape: 'box', width: 100, height: 100, radius: 50, isTrigger: false } },
+ { id: 'trigger', type: 'collider', name: 'Coin Trigger', props: { shape: 'box', width: 24, height: 24, radius: 12, isTrigger: true } },
+ {
+ id: 'player',
+ type: 'behavior',
+ name: 'Player',
+ props: { speed: 170, jumpSpeed: 480, gravity: 1200, vy: 0, onGround: false },
+ variables: [
+ variable('speed', 'number', 0),
+ variable('jumpSpeed', 'number', 0),
+ variable('gravity', 'number', 0),
+ variable('vy', 'number', 0),
+ variable('onGround', 'boolean', false),
+ ],
+ graph: playerGraph,
+ },
+ {
+ id: 'game',
+ type: 'behavior',
+ name: 'Game',
+ props: { timeLeft: 120, coins: 0, needed: 10, running: false },
+ variables: [
+ variable('timeLeft', 'number', 120),
+ variable('coins', 'number', 0),
+ variable('needed', 'number', 10),
+ variable('running', 'boolean', false),
+ ],
+ graph: gameGraph,
+ },
+ { id: 'start-button', type: 'behavior', name: 'Start Button', props: {}, variables: [], graph: startButtonGraph },
+ { id: 'restart-button', type: 'behavior', name: 'Restart Button', props: {}, variables: [], graph: restartButtonGraph },
+ { id: 'level', type: 'container', name: 'Level', props: {}, objects: levelTemplate },
+ { id: 'title', type: 'container', name: 'Title screen', props: {}, objects: titleTemplate },
+ { id: 'win', type: 'container', name: 'Win screen', props: {}, objects: winTemplate },
+ { id: 'lose', type: 'container', name: 'Lose screen', props: {}, objects: loseTemplate },
+ ],
+};
+
+await writeProject(projectId, baseUrl, project);
diff --git a/tools/smoke-demo.mjs b/tools/smoke-demo.mjs
new file mode 100644
index 00000000..f0554f3f
--- /dev/null
+++ b/tools/smoke-demo.mjs
@@ -0,0 +1,101 @@
+#!/usr/bin/env node
+/**
+ * Smoke test for the *shipped demo scene* (containers, template instances, spawning, collision).
+ * tools/e2e.mjs covers the editor in depth against a stable fixture; this one guards the other
+ * side: that the scene we actually hand out still loads and plays after a change.
+ *
+ * Usage: PATH=/opt/node22/bin:$PATH node tools/smoke-demo.mjs [projectId] [baseUrl]
+ * Defaults to the throwaway demo-check project (the playground itself is refused):
+ * node tools/seed-demo.mjs aaf5e962ef9c && node tools/smoke-demo.mjs
+ * Screenshots land in /tmp/gm-smoke-*.png.
+ */
+import { runChecks } from './harness.mjs';
+import { PROJECTS, cliArgs } from './projects.mjs';
+
+const { projectId, baseUrl } = cliArgs('smoke-demo.mjs', PROJECTS.demoCheck);
+
+/** Seeded ids: the three container instances and everything the demo places. */
+const ROOTS = ['op', 'o3', 'o5', 'oe1', 'oe2'];
+const OBJECT_COUNT = 12;
+
+await runChecks({ projectId, baseUrl, port: 9335, shotPrefix: 'gm-smoke' }, async (t) => {
+ const { cdp, consoleLog, check, sleep, evaluate, shot, clickText, keyPress } = t;
+
+ const objects = async () => JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects)'));
+ const world = async () => JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
+ const holdKey = async (key, code, keyCode, ms) => {
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: keyCode });
+ await sleep(ms);
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: keyCode });
+ };
+
+ await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
+ await sleep(4000);
+ await shot('01-loaded');
+ check('demo scene loaded without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | '));
+ check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)')));
+
+ // 1. Structure: three container instances (Player + two Enemies), a camera and the spawner.
+ const objs = await objects();
+ const roots = objs.filter((o) => o.parentId === '').map((o) => o.id);
+ check('demo scene has the seeded objects and roots', objs.length === OBJECT_COUNT && ROOTS.every((id) => roots.includes(id)), `${objs.length} objects, roots=${JSON.stringify(roots)}`);
+ check('hierarchy lists every object', (await evaluate('__gm.hierarchyOrder().length')) === OBJECT_COUNT);
+
+ // 2. Every child of a container instance points at an object of that container's template.
+ const templateLinks = JSON.parse(await evaluate(`JSON.stringify((() => {
+ const scene = __gm.state.activeScene, assets = __gm.state.project.assets;
+ const containerOf = (id) => { const o = scene.objects.find((x) => x.id === id); return o ? assets.find((a) => a.id === o.assetId && a.type === 'container') ?? containerOf(o.parentId) : null; };
+ return scene.objects.filter((o) => o.templateId).map((o) => {
+ const c = containerOf(o.parentId);
+ return { id: o.id, container: c ? c.id : '', ok: Boolean(c && c.objects.some((t) => t.id === o.templateId)) };
+ });
+ })())`));
+ check('container instances are linked to their template', templateLinks.length === 7 && templateLinks.every((l) => l.ok), JSON.stringify(templateLinks));
+
+ // 3. Play: the demo runs, the Mover moves the player and colliders keep it out of an enemy.
+ await clickText('▶ Play');
+ await sleep(400);
+ check('play opens the Game tab with the demo running', (await evaluate('__gm.tab()')) === 'game' && (await world()).entities.length === OBJECT_COUNT);
+ const startX = (await world()).entities.find((e) => e.objectId === 'o1').worldX;
+ await holdKey('ArrowRight', 'ArrowRight', 39, 400);
+ const movedX = (await world()).entities.find((e) => e.objectId === 'o1').worldX;
+ check('the Mover behavior moves the player while ArrowRight is held', movedX > startX + 30, `world x ${startX} → ${movedX}`);
+ await shot('02-playing');
+
+ // 3b. Line the player up with the upper enemy (100, −100) – editing while playing only touches
+ // the runtime copy – and walk right into it. Collider halves are 50 and 40, so a blocked
+ // player comes to rest with its centre exactly 90 away; without collisions it would run past.
+ await evaluate(`(() => { const o = __gm.state.activeScene.objects.find((x) => x.id === 'op'); o.position.x = -100; o.position.y = -100; __gm.state.preview(); })()`);
+ await sleep(150);
+ await holdKey('ArrowRight', 'ArrowRight', 39, 1500);
+ const w = await world();
+ const player = w.entities.find((e) => e.objectId === 'o1');
+ const enemy = w.entities.find((e) => e.objectId === 'o2');
+ const dx = player.worldX - enemy.worldX;
+ const dy = Math.abs(player.worldY - enemy.worldY);
+ check('solid colliders stop the player at the enemy instead of letting it pass', dy < 1 && dx > -91 && dx < -89, `dx=${dx.toFixed(2)} dy=${dy.toFixed(2)}`);
+ await shot('03-collided');
+
+ // 4. Spawning a container: the Spawner drops a whole Enemy instance (container + two children).
+ await keyPress(' ', 'Space', { windowsVirtualKeyCode: 32 });
+ await sleep(250);
+ const after = await objects();
+ const fresh = after.filter((o) => !objs.some((old) => old.id === o.id));
+ const container = fresh.find((o) => o.parentId === '');
+ const linked = fresh.filter((o) => o.templateId && o.id !== container?.id);
+ check('Space spawns a full Enemy container instance', fresh.length === 3 && container?.assetId === 'enemy' && container.position.y === 150 && linked.length === 2, JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId, templateId: o.templateId }))));
+ const spawnWorld = await world();
+ check('the spawned instance lives in the world and reached the Log node', spawnWorld.entities.length === OBJECT_COUNT + 3 && spawnWorld.log.includes('Enemy'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) }));
+ await shot('04-spawned');
+
+ // 5. Stop discards the runtime copy: the saved demo is exactly as it was.
+ await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
+ await sleep(400);
+ const restored = await objects();
+ check('stop restores the demo scene untouched', restored.length === OBJECT_COUNT && (await evaluate('__gm.tab()')) === 'scene', `${restored.length} objects`);
+ const op = restored.find((o) => o.id === 'op');
+ const o1 = restored.find((o) => o.id === 'o1');
+ check('the player container is back at its seeded position (the play-mode move is gone)', op.position.x === -100 && op.position.y === 0 && o1.position.x === 0 && o1.position.y === 0, JSON.stringify({ op: op.position, o1: o1.position }));
+
+ check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
+});
diff --git a/tools/smoke-jumprun.mjs b/tools/smoke-jumprun.mjs
new file mode 100644
index 00000000..d13b36d5
--- /dev/null
+++ b/tools/smoke-jumprun.mjs
@@ -0,0 +1,125 @@
+#!/usr/bin/env node
+/**
+ * Plays the example game (tools/seed-jumprun.mjs) in a headless browser: title screen, start
+ * button, running, jumping, collecting a coin, the win screen, restart and the lose screen.
+ * It is the proof that the engine features this game needs actually work together.
+ *
+ * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
+ * Screenshots land in /tmp/gm-jr-*.png.
+ */
+import { runChecks } from './harness.mjs';
+import { PROJECTS, cliArgs } from './projects.mjs';
+
+const { projectId, baseUrl } = cliArgs('smoke-jumprun.mjs', PROJECTS.jumpRun);
+
+await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (t) => {
+ const { cdp, consoleLog, check, sleep, evaluate, shot, click, clickText, keyPress } = t;
+
+ const world = async () => JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
+ const entity = async (objectId) => (await world()).entities.find((e) => e.objectId === objectId);
+ /** Variables of the "Game" behavior instance, live from the running world. */
+ const gameVars = async () => (await world()).entities.find((e) => e.objectId === 'ogame').props;
+ const holdKey = async (key, code, keyCode, ms) => {
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: keyCode });
+ await sleep(ms);
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: keyCode });
+ };
+ /** Clicks an object where the *player* sees it (through the camera), not in the scene view. */
+ const clickObject = async (objectId) => {
+ const p = JSON.parse(await evaluate(`JSON.stringify(__gm.gameScreen(${JSON.stringify(objectId)}))`));
+ if (!p) throw new Error(`object ${objectId} is not on screen in the game view`);
+ await click(p.x, p.y);
+ };
+ /** Edits the play copy (never the saved project) and pushes it into the running world. */
+ const setVariable = async (objectId, key, value) => {
+ await evaluate(`(() => { const o = __gm.state.activeScene.objects.find((x) => x.id === ${JSON.stringify(objectId)}); o.overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}; __gm.state.preview(); })()`);
+ await sleep(150);
+ };
+
+ await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
+ await sleep(4000);
+ check('the game project loads without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | '));
+
+ // 1. Nothing was dropped by the loader: every node and edge of the four behaviors survived.
+ const graphs = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.filter((a) => a.type === 'behavior').map((a) => ({ id: a.id, n: a.graph.nodes.length, e: a.graph.edges.length })))`));
+ const expected = { player: [34, 37], game: [38, 43], 'start-button': [9, 10], 'restart-button': [2, 1] };
+ check('all behavior graphs load complete (no unknown node types, no dropped edges)',
+ graphs.length === 4 && graphs.every((g) => expected[g.id] && g.n === expected[g.id][0] && g.e === expected[g.id][1]), JSON.stringify(graphs));
+ const objectCount = JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects.length)'));
+ check('the scene holds the seeded objects', objectCount === 67, `${objectCount} objects`);
+ await shot('01-editor');
+
+ // 2. Play: the Game behavior shows the title screen and switches the level off.
+ await clickText('▶ Play');
+ await sleep(600);
+ check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game');
+ check('the title screen is up', Boolean(await evaluate('Boolean(__gm.locate("JUMP & RUN"))')));
+ const level = await entity('L');
+ const title = await entity('T');
+ check('the Game behavior switched the level off and the title on', level.props.enabled === false && title.props.enabled === true, JSON.stringify({ level: level.props.enabled, title: title.props.enabled }));
+ await shot('02-title');
+
+ // 3. The Start button is a click target in the running game (On click + hit test).
+ await clickObject('T-title-button');
+ await sleep(400);
+ check('clicking Start hides the title and shows the level', (await entity('L')).props.enabled === true && (await entity('T')).props.enabled === false);
+ const vars = await gameVars();
+ check('the run starts with a full clock and no coins', vars.running === true && vars.coins === 0 && vars.timeLeft > 118, JSON.stringify(vars));
+ await shot('03-running');
+
+ // 4. Gravity: the player rests on the ground (its 40-high box on the ground's top edge, y = 150).
+ const grounded = await entity('L-player');
+ check('gravity dropped the player onto the ground (top edge at y = 150)', Math.abs(grounded.worldY - 130) < 1.5, `world y ${grounded.worldY.toFixed(2)}`);
+
+ // 5. Jumping: Space lifts the player (y gets smaller) and gravity brings it back down.
+ await keyPress(' ', 'Space', { windowsVirtualKeyCode: 32 });
+ await sleep(200);
+ const airborne = await entity('L-player');
+ check('Space makes the player jump', airborne.worldY < grounded.worldY - 25, `y ${grounded.worldY.toFixed(1)} → ${airborne.worldY.toFixed(1)}`);
+ await sleep(1200);
+ const landed = await entity('L-player');
+ check('the player falls back onto the ground', Math.abs(landed.worldY - 130) < 1.5, `y ${landed.worldY.toFixed(2)}`);
+ await shot('04-jumped');
+
+ // 6. Walking and collecting: running into a coin removes it and counts up.
+ const coins = async () => (await world()).entities.filter((e) => e.name === 'Coin').length;
+ const coinsBefore = await coins();
+ await holdKey('ArrowRight', 'ArrowRight', 39, 1500);
+ const walked = await entity('L-player');
+ check('arrow keys walk the player', walked.worldX > landed.worldX + 40, `x ${landed.worldX.toFixed(1)} → ${walked.worldX.toFixed(1)}`);
+ const afterCoin = await gameVars();
+ const coinsAfter = await coins();
+ check('running into a coin collects it', afterCoin.coins >= 1 && coinsAfter < coinsBefore, JSON.stringify({ coins: afterCoin.coins, before: coinsBefore, after: coinsAfter }));
+ const hud = await evaluate('Boolean(__gm.locate("Coins 1/10"))');
+ check('the HUD shows the counter', Boolean(hud));
+ check('the clock is counting down', afterCoin.timeLeft < 118, `timeLeft ${afterCoin.timeLeft}`);
+ await shot('05-collected');
+
+ // 7. Winning: with all ten coins the Game behavior swaps the level for the win screen.
+ await setVariable('ogame', 'coins', 10);
+ await sleep(300);
+ check('collecting everything wins the game', Boolean(await evaluate('Boolean(__gm.locate("YOU WIN!"))')) && (await entity('W')).props.enabled === true && (await entity('L')).props.enabled === false);
+ await shot('06-won');
+
+ // 8. Restart game: back to a fresh run at the title screen, coins and clock reset.
+ await clickObject('W-win-button');
+ await sleep(1200);
+ check('Play again restarts the scene at the title screen', Boolean(await evaluate('Boolean(__gm.locate("JUMP & RUN"))')) && (await evaluate('__gm.state.mode')) === 'play');
+ const restartedCoins = (await world()).entities.filter((e) => e.name === 'Coin').length;
+ check('the restarted run has all ten coins back', restartedCoins === 10, `${restartedCoins} coins`);
+
+ // 9. Losing: start again, run the clock out.
+ await clickObject('T-title-button');
+ await sleep(300);
+ await setVariable('ogame', 'timeLeft', 0.2);
+ await sleep(600);
+ check('the clock running out loses the game', Boolean(await evaluate('Boolean(__gm.locate("TIME UP"))')) && (await entity('B')).props.enabled === true);
+ await shot('07-lost');
+
+ // 10. Stop throws the run away – the saved project is untouched.
+ await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
+ await sleep(500);
+ const saved = JSON.parse(await evaluate('JSON.stringify({ objects: __gm.state.activeScene.objects.length, mode: __gm.state.mode, tab: __gm.tab() })'));
+ check('stop restores the edited scene', saved.objects === 67 && saved.mode === 'edit' && saved.tab === 'scene', JSON.stringify(saved));
+ check('no exceptions during the run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
+});