Enabled switch, text assets, mouse input and the jump-and-run example game

Engine additions that the example game needed:
- `enabled` as a shared property of every placeable asset (default on, an
  override on the scene object). A switched-off object is not drawn, does not
  collide and its behavior does not run - and neither does anything parented
  below it. Disabled objects are now hidden in the scene view as well, so the
  editor shows what the game shows; they stay in the hierarchy panel, which is
  where they are selected and switched back on.
- Asset type `text` (text, fontSize, color, align), rendered in scene and game.
- Pointer input in the game view: `On click`, `Mouse position`, `Mouse button`,
  `Is hovered`, hit-tested against colliders, else sprite/text footprints.
- New nodes: Set/Is enabled, Set text, Is asset, Name of, Equals, To text,
  Join text, Floor/Ceil/Round, Modulo, Random, Time, Restart game.

Tooling: shared CDP harness (tools/harness.mjs), central project registry with
a guard that keeps destructive scripts off the playground (tools/projects.mjs),
plus smoke suites for the demo scene and the jump-and-run game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-07 20:52:10 +02:00
1 parent 4900d7bbf6
commit 76aa441dce
33 files changed
+1905 -301

No files matched your search

+63 -16
View File
@@ -10,7 +10,9 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
| Term | Meaning |
|---|---|
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `behavior`, `collider`, `container`) and typed properties. Assets have **no position**. |
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `text`, `behavior`, `collider`, `container`) and typed properties. Assets have **no position**. |
| **Text** | An asset that draws a line of text (`text`, `fontSize`, `color`, `align`) – titles, HUD readouts, button labels. Its content is an ordinary property, so `Set text` (or `Set property`) changes it while playing. |
| **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run – **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. `Set enabled` / `Is enabled` do it from a graph. |
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. |
| **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container creates a container object plus fresh copies of the template below it – ordinary objects, each remembering its template id. **Create container** turns the current selection into such an asset and replaces the selected objects by one placed container at their centre (world positions unchanged). The template is edited from its instances (see *Instance*). |
| **Selection** | One or more scene objects. Click selects, `Ctrl`-click adds/removes, dragging on empty scene space draws a rubber band (objects whose position lies inside are selected). Dragging a selected object moves the whole selection; `Delete` removes it; the inspector offers **Create container** and **Remove from scene** for several objects. |
@@ -55,16 +57,30 @@ shown on the node and editable in the inspector.
viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up
from any object.
- **Node set** (`src/core/graph/nodes.ts`): events `On start`, `On update`, `On key down/up/held`,
`On timer`, `On collision enter/stay/exit` · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Parent`, `Object by name`, `Get/Set
variable`, `Get/Set property`, `Get position` (local and world), literals, `Add/Subtract/Multiply/Divide`,
`Min/Max/Abs/Sqrt/Clamp`, `Length`, `Distance`, `Normalize`, `Axis input` (four keys → a
normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down`, `Is touching` ·
actions `Move` (dx, dy, scale, dt – connect an event's dt for per-second movement), `Set position`, `Spawn`
(asset setting + x/y/z, optional parent → outputs the new object), `Destroy`, `Log`, `Stop game`. A node type is one `registerNode()` call; palette, ports, validation and
`On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner) · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`,
`Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set
variable`, `Get/Set property`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`,
`Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `Join text`, `Equals`,
`Length`, `Distance`, `Normalize`, `Axis input` (four keys → a
normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down`,
`Mouse position`, `Mouse button`, `Is hovered`, `Is touching` ·
actions `Move` (dx, dy, scale, dt – connect an event's dt for per-second movement), `Set position`, `Set enabled`, `Set text`, `Spawn`
(asset setting + x/y/z, optional parent → outputs the new object), `Destroy`, `Log`, `Stop game`, `Restart game`. A node type is one `registerNode()` call; palette, ports, validation and
inspector fields are generated from it. A port may declare a `default` for its inline value
(e.g. `Move.scale = 1`), shown on the node and in the inspector.
- **Cross-object state**: a behavior's variables *are* the properties of its object, so another
graph reads and writes them with `Object by name` → `Get property` / `Set property`. That is
how the example game keeps one coin counter for the whole run.
- **Mouse**: the Game tab feeds the pointer to the world in world units. `On click` fires for
the behavior whose owner was hit – colliders decide when there are any, otherwise the sprites
and texts of the owner and everything below it, so a button made of a box plus a label works
as one. A switched-off screen cannot swallow clicks.
- **Idiomatic movement** (the demo's "Mover", five nodes): `On update → Move(Owner, dx = Axis
input.x, dy = Axis input.y, scale = Get variable speed, dt = On update.dt)`.
- **Idiomatic platformer** (the example game's "Player"): `On update` adds `gravity · dt` to a
`vy` variable and moves the owner by `(axis.x · speed, vy)` with *Collide with solids* on;
`blocked y` while falling means standing, so `vy` goes back to 0 and `onGround` becomes true;
`On key down (Space)` sets `vy = −jumpSpeed` when `onGround`.
- **Editing**: right-click the canvas for the searchable palette, drag port → port to connect
(types are checked; a data input holds one link and is replaced), drag nodes, right-click a
node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging
@@ -128,6 +144,20 @@ fast object cannot jump across a thin wall and end up on the other side. Collide
moving object and of anything parented under it travel with it and cannot
block it. `Move` without the setting and `Set position` ignore colliders entirely.
## Screens without scene switching
A **screen** (title, win, lose, a pause overlay) is just a container whose `enabled` is
switched on and off. It keeps working while it is off in one important way: `Object by name`
still finds it, so a graph can switch it back on. A behavior inside a disabled screen has no
interpreter yet – it gets one, and its `On start`, the moment the screen is switched on, which
makes "the screen initialises itself when shown" the default. Its variables survive being
switched off and on again. A switched-off object is hidden in the Scene tab as well, so the
editor shows what the game shows; it stays listed in the hierarchy panel, which is where it is
selected and switched back on.
`Restart game` throws the run away and starts the scene again from the saved model – what the
"Play again" buttons of the example game do.
## Stack
- **PixiJS v8** – rendering and pointer events. No game engine; runtime systems live in `src/runtime/`.
@@ -150,7 +180,7 @@ src/viewport/ scene renderer: Scene tab (model, editable) and Game tab (wo
src/editor/ App composition, state, panels, inspector field factory
src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette
api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ e2e.mjs (CDP smoke test), seed-demo/seed-e2e.mjs
tests/ Vitest unit tests tools/ harness/projects + e2e, smoke-demo, seed-*
```
The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated
@@ -171,15 +201,30 @@ npm run dev # http://localhost:5175/game-maker/dist/ – API calls are
npm test # unit tests (core)
npm run typecheck
npm run build # writes dist/ and regenerates index.html
node tools/seed-e2e.mjs <projectId> # write the fixture scene the e2e checks assert against
node tools/e2e.mjs <projectId> # headless end-to-end run with screenshots in /tmp/gm-e2e-*.png
node tools/seed-demo.mjs <projectId> # 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 <demoCheckId> \
&& 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.
+2
View File
@@ -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':
+3 -2
View File
@@ -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);
}
+30
View File
@@ -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 {
+195 -1
View File
@@ -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 {}; } });
+9 -1
View File
@@ -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';
+1 -1
View File
@@ -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];
+30 -4
View File
@@ -24,6 +24,21 @@ export type PropertySchema = readonly PropertyDef[];
export const VARIABLE_KINDS = ['number', 'boolean', 'text'] as const;
export type VariableKind = (typeof VARIABLE_KINDS)[number];
/**
* Properties every *placeable* asset has, whatever its type. `enabled` is the master switch:
* a disabled object is not drawn, does not collide and its behavior does not run – and the
* same goes for everything parented below it. Assets default to on, so switching a single
* instance off is an ordinary override. Scenes have no such switch (they are not placed).
*/
export const COMMON_PROPS: PropertySchema = [{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true }];
const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key));
/** Whether a key belongs to the common properties (the inspector groups those separately). */
export function isCommonProp(key: string): boolean {
return COMMON_KEYS.has(key);
}
/** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */
export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
@@ -49,26 +64,37 @@ export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
// A container is pure structure: its content is the template, not a property.
container: [],
text: [
{ key: 'text', label: 'Text', kind: 'text', default: 'Text' },
{ key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1 },
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{ key: 'align', label: 'Align', kind: 'enum', default: 'center', options: [{ value: 'left', label: 'Left' }, { value: 'center', label: 'Center' }, { value: 'right', label: 'Right' }] },
],
};
/** Type schema with the common properties in front – the effective schema of an asset type. */
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [type, type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]]]),
) as Record<AssetType, PropertySchema>;
/** Properties every scene object has regardless of its asset type. */
export const SCENE_OBJECT_SCHEMA: PropertySchema = [
{ key: 'x', label: 'X', kind: 'number', default: 0, step: 1 },
{ key: 'y', label: 'Y', kind: 'number', default: 0, step: 1 },
];
/** Schema shared by every asset of a type. Behaviors add per-asset variables on top. */
/** Schema shared by every asset of a type (common properties first). */
export function schemaFor(type: AssetType): PropertySchema {
return ASSET_SCHEMAS[type];
return FULL_SCHEMAS[type];
}
/** The effective schema of one asset: the type schema, plus declared variables for behaviors. */
export function schemaForAsset(asset: Asset): PropertySchema {
return asset.type === 'behavior' ? asset.variables : ASSET_SCHEMAS[asset.type];
return asset.type === 'behavior' ? [...COMMON_PROPS, ...asset.variables] : FULL_SCHEMAS[asset.type];
}
export function defaultsFor(type: AssetType): PropertyBag {
return defaultsOf(ASSET_SCHEMAS[type]);
return defaultsOf(FULL_SCHEMAS[type]);
}
export function defaultsOf(schema: PropertySchema): PropertyBag {
+61
View File
@@ -0,0 +1,61 @@
import type { AssetType } from '../model/AssetTypes';
import type { PropertyBag, Vec2 } from '../model/Properties';
import { cameraFrame } from './camera';
import { colliderSize } from './collider';
import { defaultsFor } from './PropertySchema';
/**
* Footprints in world units – how much room an object takes up. Shared by the viewport
* (culling, selection gizmos), by pointer hit tests in the running game and by "Add
* collider", so all three agree on how big a thing is.
*/
/** World-unit size of the behavior symbol (a diamond). */
export const BEHAVIOR_SYMBOL = 28;
/** World-unit size of the container symbol (a bracketed square). */
export const CONTAINER_SYMBOL = 24;
/** Rough width of one character relative to the font size (the UI font is a plain sans). */
const CHAR_WIDTH = 0.55;
const LINE_HEIGHT = 1.25;
/** Size a text object covers, estimated from its content and font size. */
export function textSize(props: PropertyBag): { w: number; h: number } {
const d = defaultsFor('text');
const size = Math.max(1, Number(props.fontSize ?? d.fontSize));
const lines = String(props.text ?? d.text).split('\n');
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
return { w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH), h: lines.length * size * LINE_HEIGHT };
}
/** Horizontal anchor of a text object: 0 = left edge on the position, 0.5 = centred, 1 = right. */
export function textAnchor(props: PropertyBag): number {
const align = String(props.align ?? defaultsFor('text').align);
return align === 'left' ? 0 : align === 'right' ? 1 : 0.5;
}
/** Footprint of an object of the given type, centred on its position (text: see `textAnchor`). */
export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h: number } {
switch (type) {
case 'sprite':
return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) };
case 'text':
return textSize(props);
case 'collider':
return colliderSize(props);
case 'behavior':
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
case 'container':
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
default:
return cameraFrame(props);
}
}
/** Whether a world point lies inside the footprint of an object placed at `centre`. */
export function footprintContains(type: AssetType, props: PropertyBag, centre: Vec2, point: Vec2): boolean {
const { w, h } = footprintOf(type, props);
const offset = type === 'text' ? (0.5 - textAnchor(props)) * w : 0;
const x = centre.x + offset;
return Math.abs(point.x - x) <= w / 2 && Math.abs(point.y - centre.y) <= h / 2;
}
+6
View File
@@ -27,6 +27,12 @@ export function colliderSize(props: PropertyBag): { w: number; h: number } {
return shape.kind === 'circle' ? { w: shape.r * 2, h: shape.r * 2 } : { w: shape.w, h: shape.h };
}
/** Whether a point lies inside a shape (used for pointer hit tests). */
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
if (shape.kind === 'circle') return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
return Math.abs(point.x - shape.x) * 2 <= shape.w && Math.abs(point.y - shape.y) * 2 <= shape.h;
}
/** Whether two shapes overlap. Shapes that merely touch along an edge do not overlap. */
export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
if (a.kind === 'box' && b.kind === 'box') {
+26
View File
@@ -0,0 +1,26 @@
import type { PropertyBag } from '../model/Properties';
/**
* The `enabled` switch (see `COMMON_PROPS`). Disabling an object takes it out of the running
* game completely – not drawn, no collisions, no behavior ticks – and the same holds for its
* children, because "enabled" is only true when the whole chain up to the root is on. That is
* what makes a container usable as a screen: switch it on to show it, off to hide it.
*/
/** Whether a resolved property bag says "on". A missing value counts as on. */
export function propsEnabled(props: PropertyBag): boolean {
return props.enabled !== false;
}
/**
* Whether `start` and every ancestor of it are switched on. Generic over the shape so the
* model (scene objects), the viewport (draw items) and the runtime (entities) share one rule.
*/
export function enabledAlongChain<T>(start: T, propsOf: (item: T) => PropertyBag, parentOf: (item: T) => T | null): boolean {
const seen = new Set<T>();
for (let current: T | null = start; current && !seen.has(current); current = parentOf(current)) {
if (!propsEnabled(propsOf(current))) return false;
seen.add(current);
}
return true;
}
+4 -3
View File
@@ -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;
+10 -2
View File
@@ -69,6 +69,8 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
drawOrder: () => viewport.drawOrder(),
/** Screen position of an object in the Scene tab (its world position through the view). */
objectScreen: (id: string) => viewport.objectScreen(id),
/** The same for the Game tab (through the camera), so tests can click what the player sees. */
gameScreen: (id: string) => gameView.objectScreen(id),
graph: {
current: () => graphEditor.currentAssetId,
portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput),
@@ -320,6 +322,12 @@ class Editor {
this.stop();
return;
}
// Restart game: throw the run away and start the scene again from the saved model.
if (world.restartRequested) {
this.stop();
this.play();
return;
}
// Mirror what the systems did into the runtime copy so the panels show live values.
// A destroyed entity removes its object – that is a structural change the lists must see.
if (world.syncToModel()) this.state.preview();
@@ -339,13 +347,13 @@ class Editor {
}
/** Read-only view of the running world for tests. */
worldSnapshot(): { entities: { objectId: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[]; log: string[] } | null {
worldSnapshot(): { entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[]; log: string[] } | null {
const world = this.world;
if (!world) return null;
return {
entities: world.entities.map((e) => {
const w = world.worldPosition(e);
return { objectId: e.objectId, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
}),
log: [...world.log],
};
+2 -2
View File
@@ -7,7 +7,7 @@ import { findAsset, findUsages } from '../../core/model/Project';
import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import { type EditorState, objectSelection } from '../state';
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior', collider: 'Collider', container: 'Container' };
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', text: 'Text', behavior: 'Behavior', collider: 'Collider', container: 'Container' };
/** Lists all assets of the project; creates, places and deletes them. */
export class AssetPanel extends Panel {
@@ -45,7 +45,7 @@ export class AssetPanel extends Panel {
id: asset.id,
label: asset.name,
hint: asset.type === 'scene' && asset.id === active ? 'scene ●' : asset.type,
swatch: asset.type === 'sprite' ? Number(asset.props.color ?? 0) : undefined,
swatch: asset.type === 'sprite' || asset.type === 'text' ? Number(asset.props.color ?? 0) : undefined,
})),
);
this.syncSelection();
+64 -43
View File
@@ -25,7 +25,7 @@ import { isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import type { Position, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import { type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, ui, VStack } from '../../ui';
import { createContainer, removeObjects } from '../actions';
@@ -135,13 +135,19 @@ export class Inspector extends Panel {
this.body.add(this.labelled('Name', this.nameField));
if (asset.type === 'behavior') {
this.buildAssetProps(asset, COMMON_PROPS);
this.buildVariables(asset);
return;
}
// Asset properties: values that differ from the schema default are marked and resettable.
this.buildAssetProps(asset, schemaForAsset(asset));
}
/** Asset properties: values that differ from the schema default are marked and resettable. */
private buildAssetProps(asset: Asset, schema: readonly PropertyDef[]): void {
const assetId = asset.id;
const values = resolveAssetProps(asset);
for (const def of schemaForAsset(asset)) {
for (const def of schema) {
const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)));
row.setModified(isModified(asset, def.key));
this.fields.set(def.key, row);
@@ -269,6 +275,11 @@ export class Inspector extends Panel {
this.body.add(linkRow);
}
// The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS) {
this.body.add(this.objectPropertyRow(sceneId, objectId, def, resolveSceneObjectProps(asset, object), templateLinkOf(this.state.project, scene, object) ?? null).row);
}
// Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top).
const parent = object.parentId ? findSceneObject(scene, object.parentId) : undefined;
this.body.add(new Label(parent ? 'POSITION (LOCAL TO PARENT)' : 'POSITION', { style: 'title' }));
@@ -302,48 +313,15 @@ export class Inspector extends Panel {
this.body.add(row.row);
}
// Asset properties (or behavior variables) with overrides.
// Asset properties (or behavior variables) with overrides. The common switch (Enabled)
// is shown on its own, above the position – it exists for every type and is not a
// "property of a sprite" in the same sense.
const schema = schemaForAsset(asset);
if (schema.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' }));
if (schema.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' }));
const values = resolveSceneObjectProps(asset, object);
for (const def of schema) {
let original: PropertyValue | undefined;
let hadOverride = false;
const row = createFieldRow(
def,
values[def.key] ?? def.default,
{
onInput: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original === undefined) {
hadOverride = isOverridden(target, def.key);
original = target.overrides[def.key] ?? values[def.key] ?? def.default;
}
target.overrides[def.key] = value;
this.state.preview();
},
onChange: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original !== undefined) {
if (hadOverride) target.overrides[def.key] = original;
else delete target.overrides[def.key];
}
original = undefined;
this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
},
},
() => this.state.execute(link ? new RevertOverrideToTemplateCommand(sceneId, objectId, def.key) : new ClearOverrideCommand(sceneId, objectId, def.key)),
[
...(link ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
{ label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) },
],
);
this.fields.set(def.key, row);
this.body.add(row.row);
}
const own = schema.filter((def) => !isCommonProp(def.key));
if (own.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' }));
if (own.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' }));
for (const def of own) this.body.add(this.objectPropertyRow(sceneId, objectId, def, values, link ?? null).row);
this.markObjectRows(object, asset, link);
if (asset.type === 'container') {
@@ -368,6 +346,49 @@ export class Inspector extends Panel {
}
}
/**
* One editable property of a scene object: writes an override, offers ↺ (back to the asset
* or, inside a container, to the template) and ↑ (apply upwards). Registered in `fields`
* so live refreshes and `markObjectRows` find it.
*/
private objectPropertyRow(sceneId: string, objectId: string, def: PropertyDef, values: Record<string, PropertyValue>, link: TemplateLink | null): FieldRow {
let original: PropertyValue | undefined;
let hadOverride = false;
const row = createFieldRow(
def,
values[def.key] ?? def.default,
{
onInput: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original === undefined) {
hadOverride = isOverridden(target, def.key);
original = target.overrides[def.key] ?? values[def.key] ?? def.default;
}
target.overrides[def.key] = value;
this.state.preview();
},
onChange: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original !== undefined) {
if (hadOverride) target.overrides[def.key] = original;
else delete target.overrides[def.key];
}
original = undefined;
this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
},
},
() => this.state.execute(link ? new RevertOverrideToTemplateCommand(sceneId, objectId, def.key) : new ClearOverrideCommand(sceneId, objectId, def.key)),
[
...(link ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
{ label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) },
],
);
this.fields.set(def.key, row);
return row;
}
/**
* "Parent: <name> ▾" – picks another object of the same scene, or none. The object's own
* subtree is excluded (that would be a cycle). For behaviors the parent is the owner.
+54 -1
View File
@@ -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 {
+18 -3
View File
@@ -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, unknown> }) => 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;
},
};
}
+6 -4
View File
@@ -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<string, CollisionPair>();
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<string, CollisionPair>();
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));
}
+97 -22
View File
@@ -1,12 +1,15 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Rectangle, Text, TextStyle } from 'pixi.js';
import { CompoundCommand } from '../core/commands/CompoundCommand';
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { isHandleEnabled } from '../core/graph';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties';
import { subtreeRoots, treeOrder, worldPosition } from '../core/model/hierarchy';
import { findAsset } from '../core/model/Project';
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, footprintOf, textAnchor } from '../core/properties/bounds';
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
import { type ColliderShape, colliderShape, colliderSize, isTrigger } from '../core/properties/collider';
import { enabledAlongChain } from '../core/properties/enabled';
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { World } from '../runtime/World';
import { Theme, textStyle, Widget, type Size } from '../ui';
@@ -24,12 +27,10 @@ interface DrawItem {
props: PropertyBag;
/** Id of the parent item, empty when top-level. */
parentId: string;
/** Effective switch: this object and all its parents are on (see `properties/enabled.ts`). */
enabled: boolean;
}
/** World-unit size of the behavior symbol (a diamond). */
const BEHAVIOR_SYMBOL = 28;
/** World-unit size of the container symbol (a bracketed square). */
const CONTAINER_SYMBOL = 24;
/** Pixels the pointer must travel before a press on empty space becomes a rubber band. */
const MARQUEE_THRESHOLD = 3;
/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */
@@ -119,6 +120,8 @@ export class Viewport extends Widget {
private readonly gizmos = new Graphics();
private readonly nodes = new Map<string, Graphics>();
private readonly labels = new Map<string, Text>();
/** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */
private readonly texts = new Map<string, Text>();
private playWorld: World | null = null;
/** Edit-mode view: world point at the viewport centre and zoom. */
private view = { x: 0, y: 0, zoom: 1 };
@@ -143,7 +146,13 @@ export class Viewport extends Widget {
this.cursor = 'default';
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (this.game) return;
if (this.game) {
if (e.button === 0) {
this.reportPointer(e);
this.playWorld?.setPointerDown(true);
}
return;
}
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
e.stopPropagation();
@@ -157,6 +166,10 @@ export class Viewport extends Widget {
}
});
this.on('globalpointermove', (e: FederatedPointerEvent) => {
if (this.game) {
this.reportPointer(e);
return;
}
if (this.panning) {
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom;
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom;
@@ -184,6 +197,10 @@ export class Viewport extends Widget {
}
});
const finish = () => {
if (this.game) {
this.playWorld?.setPointerDown(false);
return;
}
this.panning = null;
const marquee = this.marquee;
this.marquee = null;
@@ -231,6 +248,21 @@ export class Viewport extends Widget {
state.on('runtime', () => { if (this.visible) this.redraw(); });
}
/**
* Game view: hands the mouse position to the running world in world units, so graphs can
* use it (Mouse position, Is hovered, On click). Outside the camera frame counts as "not
* in view" – that region is letterbox, not game.
*/
private reportPointer(e: FederatedPointerEvent): void {
const world = this.playWorld;
const view = this.currentView();
if (!world || !view) return;
const local = this.toLocal(e.global);
const point = this.screenToWorld(local.x, local.y, view);
const inside = local.x >= view.rect.x && local.x <= view.rect.x + view.rect.w && local.y >= view.rect.y && local.y <= view.rect.y + view.rect.h;
world.movePointer(point.x, point.y, inside);
}
/** Game view: the world to draw (null = not playing). Ignored by the scene view. */
setWorld(world: World | null): void {
this.playWorld = world;
@@ -261,9 +293,10 @@ export class Viewport extends Widget {
/** Global screen position of an object's world position (test hook), null when not drawn. */
objectScreen(objectId: string): Vec2 | null {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!item) return null;
const local = this.worldToScreen(item.x, item.y);
if (!item || !view) return null;
const local = this.worldToScreen(item.x, item.y, view);
const origin = this.getGlobalPosition();
return { x: origin.x + local.x, y: origin.y + local.y };
}
@@ -316,7 +349,10 @@ export class Viewport extends Widget {
if (!world) return { items: [], background: LETTERBOX_COLOR };
return {
background: world.backgroundColor,
items: world.entities.filter((e) => !isSymbolType(e.type)).map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...world.worldPosition(e), props: e.props, parentId: e.parent?.objectId ?? '' })),
// Symbols (camera, behaviors) are editor-only, switched-off objects do not exist for the game.
items: world.entities
.filter((e) => !isSymbolType(e.type) && isHandleEnabled(e))
.map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...world.worldPosition(e), props: e.props, parentId: e.parent?.objectId ?? '', enabled: true })),
};
}
const scene = this.state.activeScene;
@@ -326,18 +362,17 @@ export class Viewport extends Widget {
for (const { object } of treeOrder(scene)) {
const asset = findAsset(this.state.project, object.assetId);
if (!asset) continue;
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, ...worldPosition(scene, object), props: resolveSceneObjectProps(asset, object), parentId: object.parentId });
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, ...worldPosition(scene, object), props: resolveSceneObjectProps(asset, object), parentId: object.parentId, enabled: true });
}
// Effective switch of every item: off when it or any parent is off (it is then not drawn).
const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items) item.enabled = enabledAlongChain(item, (i) => i.props, (i) => byId.get(i.parentId) ?? null);
return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) };
}
/** Size of an item's footprint in world units (sprite box or camera frame). */
/** Size of an item's footprint in world units (sprite box, text box, camera frame …). */
private itemSize(item: DrawItem): { w: number; h: number } {
if (item.type === 'sprite') return { w: Number(item.props.width ?? 1), h: Number(item.props.height ?? 1) };
if (item.type === 'behavior') return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
if (item.type === 'container') return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
if (item.type === 'collider') return colliderSize(item.props);
return cameraFrame(item.props);
return footprintOf(item.type, item.props);
}
override redraw(): void {
@@ -370,7 +405,9 @@ export class Viewport extends Widget {
for (const item of items) {
seen.add(item.id);
const size = this.itemSize(item);
const onScreen = rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
// A switched-off object is not drawn at all – in the scene view as in the game. It stays
// in the hierarchy panel, which is where it is selected and switched back on.
const onScreen = item.enabled && rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
let node = this.nodes.get(item.id);
if (!node) {
node = new Graphics();
@@ -383,6 +420,7 @@ export class Viewport extends Widget {
node.visible = onScreen;
node.eventMode = editing && onScreen ? 'static' : 'none';
this.syncLabel(item, editing && onScreen, view.scale);
this.syncText(item, onScreen, view.scale);
if (!onScreen) continue;
this.visibleCount++;
node.position.set(item.x, item.y);
@@ -406,6 +444,8 @@ export class Viewport extends Widget {
const r = CONTAINER_SYMBOL / 2;
node.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3).fill({ color: Theme.colors.container, alpha: 0.25 }).stroke({ width: 2, color: Theme.colors.container });
for (const [bx, by] of [[-7, -7], [1, -7], [-7, 1], [1, 1]] as const) node.rect(bx, by, 6, 6).fill(Theme.colors.container);
} else if (item.type === 'text') {
node.hitArea = new Rectangle(-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w, -size.h / 2, size.w, size.h);
} else if (item.type === 'collider') {
// Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on
// top of their body, so only the outline band is hit-testable: clicks inside still reach
@@ -421,6 +461,8 @@ export class Viewport extends Widget {
this.drawParentLinks(items, editing, view.scale);
for (const [id, node] of this.nodes) {
if (!seen.has(id)) {
this.texts.get(id)?.destroy();
this.texts.delete(id);
node.destroy();
this.nodes.delete(id);
this.labels.get(id)?.destroy();
@@ -443,6 +485,9 @@ export class Viewport extends Widget {
for (const item of ordered) {
const node = this.nodes.get(item.id);
if (node) this.objects.addChild(node);
// The rendered text is a sibling (Graphics may not have children), kept right above its box.
const text = this.texts.get(item.id);
if (text) this.objects.addChild(text);
}
}
@@ -461,9 +506,9 @@ export class Viewport extends Widget {
if (!editing) return;
const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items) {
if (!this.isSymbol(item) || !item.parentId || item.type === 'collider') continue;
if (!this.isSymbol(item) || !item.parentId || item.type === 'collider' || !item.enabled) continue;
const parent = byId.get(item.parentId);
if (!parent) continue;
if (!parent || !parent.enabled) continue;
const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted;
dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale);
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
@@ -471,6 +516,35 @@ export class Viewport extends Widget {
}
}
/**
* Content of a text object: a Pixi Text parented to the item's node, so it moves, scales and
* fades with it. Its resolution follows the zoom in coarse steps – re-rendering the glyph
* texture on every wheel notch would be wasteful, blurry text is worse.
*/
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
let text = this.texts.get(item.id);
if (item.type !== 'text' || !onScreen) {
if (text) text.visible = false;
return;
}
if (!text) {
text = new Text({ text: '', style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }) });
text.eventMode = 'none';
this.texts.set(item.id, text);
this.objects.addChild(text);
}
text.visible = true;
text.position.set(item.x, item.y);
text.text = String(item.props.text ?? '');
const size = Math.max(1, Number(item.props.fontSize ?? 32));
if (text.style.fontSize !== size) text.style.fontSize = size;
const color = Number(item.props.color ?? 0xffffff);
if (text.style.fill !== color) text.style.fill = color;
text.anchor.set(textAnchor(item.props), 0.5);
const resolution = Math.min(4, Math.max(1, Math.round(scale * 2) / 2 * 2));
if (text.resolution !== resolution) text.resolution = resolution;
}
private syncLabel(item: DrawItem, show: boolean, scale: number): void {
let label = this.labels.get(item.id);
if (!show || !this.isSymbol(item)) {
@@ -511,8 +585,9 @@ export class Viewport extends Widget {
const s = this.state.selection;
if (s?.kind === 'object') {
for (const id of s.objectIds) {
// Nothing is outlined for a switched-off object – it is not drawn either.
const item = items.find((i) => i.id === id);
if (!item) continue;
if (!item || !item.enabled) continue;
const { w, h } = this.itemSize(item);
const tl = this.worldToScreen(item.x - w / 2, item.y - h / 2, view);
const sw = w * view.scale;
@@ -544,7 +619,7 @@ export class Viewport extends Widget {
const maxX = Math.max(a.x, b.x);
const minY = Math.min(a.y, b.y);
const maxY = Math.max(a.y, b.y);
const ids = this.lastItems.filter((i) => i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
const ids = this.lastItems.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
this.state.select(objectSelection(scene.id, ids));
}
+1 -1
View File
@@ -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', () => {
+295
View File
@@ -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<string, Value>, params: PropertyBag = {}, ctx: Partial<NodeContext> = {}): Record<string, Value> {
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)!,
};
}
+9 -4
View File
@@ -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> = {}): NodeContext & { logs: string[]; stopped: boolean } {
const logs: string[] = [];
const ctx = {
@@ -25,6 +27,8 @@ function context(overrides: Partial<NodeContext> = {}): 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> = {}): 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: {} });
+3 -3
View File
@@ -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']);
});
+3 -3
View File
@@ -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);
+1 -1
View File
@@ -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 });
});
});
+63 -141
View File
@@ -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 <projectId> [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 <id> && node tools/e2e.mjs <id> && node tools/seed-demo.mjs <id>
* 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 <projectId> [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);
});
+144
View File
@@ -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);
}
+58
View File
@@ -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} <projectId> [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}`);
}
+7 -21
View File
@@ -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 <projectId> [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 <projectId> [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);
+6 -22
View File
@@ -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 <projectId> [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 <projectId> [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);
+408
View File
@@ -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);
+101
View File
@@ -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(' | '));
});
+125
View File
@@ -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(' | '));
});