Editor offset, global On click, Coin container in the jump-and-run seed

- SceneObject.editorOffset (x/y): shifts only where an object is drawn in
  the Scene tab, cascades to children, undo-able with a reset action.
  Never written into container templates.
- On click without an owner now fires for any click inside the camera
  frame instead of hit-testing the (editor-only) behavior symbol.
- seed-jumprun: coins are a "Coin" container (sprite + trigger), nested
  containers are expanded recursively, Destroy targets the coin's parent,
  and the clock uses a Ceil node instead of ToText.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 07:34:00 +02:00
1 parent 2511483a67
commit 3234eb480f
14 files changed
+229 -53

No files matched your search

+15 -3
View File
@@ -57,7 +57,7 @@ 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`, `On click` (the mouse pressed on the owner) · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`,
`On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none) · 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`,
@@ -74,7 +74,9 @@ shown on the node and editable in the inspector.
- **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.
as one. A switched-off screen cannot swallow clicks. A behavior **without an owner** has no
shape that could be hit, so it takes every click inside the camera frame instead (the
`world x`/`world y` outputs say where) – that is the "click anywhere" listener.
- **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
@@ -117,6 +119,14 @@ unfolds the way down to it and scrolls it into view, so a selection is never hid
play mode entities carry the same `parent` link: moving a parent entity moves its subtree,
`Destroy` removes it. `Get position` exposes both the local and the world coordinates.
**Editor offset (scene view only).** Every scene object additionally carries an
`editorOffset` – an x/y shift of where it is *drawn, selected and dragged* in the Scene tab,
and nothing else. The game, collisions and the graph never see it; it is there to pull apart
objects that sit on top of each other, such as the stacked title/win/lose screens of a game.
It cascades like a position (offsetting a container offsets its content) and is edited in the
inspector below X/Y/Z, undoable, with ↺ back to 0. It belongs to the placed object, so
`Create container` and placing a container never carry it into the template.
## Colliders and collisions
A **collider** is an asset (`shape` box/circle, `width`/`height` or `radius`) placed like any
@@ -221,7 +231,9 @@ destructive script at the demo project aborts, so a test run can never eat scene
**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
a win / lose screen with "Play again". A coin is a container of its own (sprite plus trigger
collider) placed ten times inside the level container – nested containers expand on placement,
and picking one up destroys the container, not just the sprite the collider reported. `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.
+2
View File
@@ -65,6 +65,8 @@ export class CreateContainerCommand implements Command {
assetId: object.assetId,
name: object.name,
position: isRoot ? { x: world.x - centre.x, y: world.y - centre.y, z: world.z } : { ...object.position },
// A view-only offset belongs to the placed object, not to the template (see container.ts).
editorOffset: { x: 0, y: 0 },
overrides: { ...object.overrides },
parentId: isRoot ? '' : templateIds.get(object.parentId) ?? '',
templateId: '',
+29
View File
@@ -147,6 +147,35 @@ export class MoveSceneObjectCommand implements Command {
}
}
/**
* Sets the editor-only offset an object is drawn at in the scene view (see
* `SceneObject.editorOffset`). Undoable like any other edit, but it changes nothing the game
* can see – it only pulls overlapping objects apart while working on them.
*/
export class SetEditorOffsetCommand implements Command {
readonly label = 'Editor offset';
private previous: Vec2 = { x: 0, y: 0 };
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly offset: Vec2,
previous?: Vec2,
) {
if (previous) this.previous = { ...previous };
}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
this.previous = { ...object.editorOffset };
object.editorOffset = { x: this.offset.x, y: this.offset.y };
}
undo(project: Project): void {
requireObject(project, this.sceneId, this.objectId).editorOffset = { ...this.previous };
}
}
export class RenameSceneObjectCommand implements Command {
readonly label = 'Rename object';
private previous = '';
+1 -1
View File
@@ -42,7 +42,7 @@ 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.',
description: 'Runs when the mouse is pressed on the owner (or on anything parented below it) in the Game tab. Without an owner it runs on a click anywhere in the game view.',
inputs: [],
outputs: [exec(), num('x', 'world x'), num('y', 'world y')],
params: [],
+7
View File
@@ -15,6 +15,12 @@ export interface SceneObject {
* draw order `z`, which is relative as well. See `hierarchy.ts` for world coordinates.
*/
position: Position;
/**
* Editor-only shift of where this object (and everything below it) is *drawn* in the scene
* view – never part of the game. It exists to pull objects apart that sit on top of each
* other, such as the title/win/lose screens of a game, without moving them for real.
*/
editorOffset: Vec2;
overrides: PropertyBag;
/**
* Parent object of the same scene, empty string = top level. Children move with their
@@ -37,6 +43,7 @@ export function createSceneObject(id: string, assetId: string, position: Vec2 |
assetId,
name: '',
position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 },
editorOffset: { x: 0, y: 0 },
overrides: {},
parentId,
templateId: '',
+3 -1
View File
@@ -58,7 +58,9 @@ export function instantiateContainer(project: Project, asset: ContainerAsset, co
for (const template of asset.objects) {
const id = ids.get(template.id)!;
const parentId = template.parentId ? ids.get(template.parentId) ?? containerObjectId : containerObjectId;
result.push({ id, assetId: template.assetId, name: template.name, position: { ...template.position }, overrides: { ...template.overrides }, parentId, templateId: template.id });
// The editor offset stays with the instance, never with the template: it is a view tweak
// on one placed object, not something every copy of the container should inherit.
result.push({ id, assetId: template.assetId, name: template.name, position: { ...template.position }, editorOffset: { x: 0, y: 0 }, overrides: { ...template.overrides }, parentId, templateId: template.id });
const nested = findAsset(project, template.assetId);
const hasExplicitContent = asset.objects.some((o) => o.parentId === template.id);
if (nested?.type === 'container' && !hasExplicitContent && depth < MAX_NESTING) result.push(...instantiateContainer(project, nested, id, depth + 1));
+31 -7
View File
@@ -80,18 +80,42 @@ export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[]
});
}
/** Position of an object in world units (all local positions along the chain summed up). */
export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
const result = { ...object.position };
let parent = object.parentId ? findObject(tree, object.parentId) : undefined;
/** An object and every parent above it, nearest first. Stops at a cycle (defensive). */
function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] {
const chain = [object];
const seen = new Set<string>([object.id]);
let parent = object.parentId ? findObject(tree, object.parentId) : undefined;
while (parent && !seen.has(parent.id)) {
result.x += parent.position.x;
result.y += parent.position.y;
result.z += parent.position.z;
chain.push(parent);
seen.add(parent.id);
parent = parent.parentId ? findObject(tree, parent.parentId) : undefined;
}
return chain;
}
/** Position of an object in world units (all local positions along the chain summed up). */
export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
const result: Position = { x: 0, y: 0, z: 0 };
for (const o of chainOf(tree, object)) {
result.x += o.position.x;
result.y += o.position.y;
result.z += o.position.z;
}
return result;
}
/**
* Where the scene view draws an object: its world position plus the editor offsets along the
* chain, so shifting a parent shifts everything below it. This is a view aid only – nothing
* in the game, in collisions or in the graph ever sees it (they all use `worldPosition`).
*/
export function editorPosition(tree: ObjectTree, object: SceneObject): Position {
const result: Position = { x: 0, y: 0, z: 0 };
for (const o of chainOf(tree, object)) {
result.x += o.position.x + (o.editorOffset?.x ?? 0);
result.y += o.position.y + (o.editorOffset?.y ?? 0);
result.z += o.position.z;
}
return result;
}
+3
View File
@@ -77,11 +77,14 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
const schema = schemas.get(o.assetId);
if (!schema || schema === schemaFor('scene')) continue; // dangling reference or illegal scene-in-scene
const p = asRecord(o.position);
const offset = asRecord(o.editorOffset);
objects.push({
id: o.id,
assetId: o.assetId,
name: typeof o.name === 'string' ? o.name : '',
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
// Editor-only view shift; absent in documents written before it existed.
editorOffset: { x: toNumber(offset.x), y: toNumber(offset.y) },
overrides: normalizeBag(o.overrides, schema, true),
// `ownerId` is the pre-hierarchy name of the same link (documents saved before parenting).
parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '',
+48 -1
View File
@@ -13,6 +13,7 @@ import {
ClearOverrideCommand,
MoveSceneObjectCommand,
RenameSceneObjectCommand,
SetEditorOffsetCommand,
SetOverrideCommand,
SetParentCommand,
} from '../../core/commands/sceneObjectCommands';
@@ -22,7 +23,7 @@ import type { Asset, BehaviorAsset, SceneAsset } from '../../core/model/Asset';
import { isPlaceableType } from '../../core/model/AssetTypes';
import { type TemplateLink, templateLinkOf } from '../../core/model/container';
import { isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import type { Position, PropertyValue } from '../../core/model/Properties';
import type { Position, PropertyValue, Vec2 } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
@@ -323,6 +324,41 @@ export class Inspector extends Panel {
this.body.add(row.row);
}
// Editor offset – where the object is *drawn* in the scene view, nothing else. Handy for
// objects that sit on top of each other (stacked screens): pull them apart to work on them.
this.body.add(new Label('EDITOR OFFSET (SCENE VIEW ONLY)', { style: 'title' }));
for (const axis of ['x', 'y'] as const) {
let original: Vec2 | undefined;
const row = createFieldRow(
{ key: `editorOffset${axis.toUpperCase()}`, label: `Offset ${axis.toUpperCase()}`, kind: 'number', default: 0, step: 1 },
object.editorOffset[axis],
{
onInput: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (!original) original = { ...target.editorOffset };
target.editorOffset[axis] = Number(value);
this.state.preview();
},
onChange: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
const previous = original ?? { ...target.editorOffset };
if (original) target.editorOffset = { ...original };
original = undefined;
this.state.execute(new SetEditorOffsetCommand(sceneId, objectId, { ...previous, [axis]: Number(value) }, previous));
},
},
// Back to "drawn where it really is". No apply: the offset never leaves the scene object.
() =>
this.currentObject(sceneId, objectId)?.editorOffset[axis]
? [{ label: 'Reset to 0', onSelect: () => this.resetEditorOffset(sceneId, objectId, axis) }]
: [],
);
this.fields.set(`editorOffset${axis.toUpperCase()}`, row);
this.body.add(row.row);
}
// 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.
@@ -557,6 +593,8 @@ export class Inspector extends Panel {
this.fields.get('x')?.setValue(object.position.x);
this.fields.get('y')?.setValue(object.position.y);
this.fields.get('z')?.setValue(object.position.z);
this.fields.get('editorOffsetX')?.setValue(object.editorOffset.x);
this.fields.get('editorOffsetY')?.setValue(object.editorOffset.y);
const values = resolveSceneObjectProps(asset, object);
for (const def of schemaForAsset(asset)) this.fields.get(def.key)?.setValue(values[def.key] ?? def.default);
this.markObjectRows(object, asset, templateLinkOf(this.state.project, scene, object));
@@ -569,6 +607,8 @@ export class Inspector extends Panel {
*/
private markObjectRows(object: SceneObject, asset: Asset, link: TemplateLink | undefined): void {
for (const axis of ['x', 'y', 'z'] as const) this.fields.get(axis)?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
// The offset has one base only: no offset at all. Bold means "shown somewhere else than it is".
for (const axis of ['x', 'y'] as const) this.fields.get(`editorOffset${axis.toUpperCase()}`)?.setModified(object.editorOffset[axis] !== 0);
for (const def of schemaForAsset(asset)) {
const deviates = link ? deviatesFromTemplate(object, link.template, def.key) : deviatesFromAsset(object, asset, def.key);
this.fields.get(def.key)?.setModified(deviates);
@@ -593,6 +633,13 @@ export class Inspector extends Panel {
};
}
/** Drops the editor offset on one axis (the row's ↺). */
private resetEditorOffset(sceneId: string, objectId: string, axis: 'x' | 'y'): void {
const object = this.currentObject(sceneId, objectId);
if (!object || object.editorOffset[axis] === 0) return;
this.state.execute(new SetEditorOffsetCommand(sceneId, objectId, { ...object.editorOffset, [axis]: 0 }));
}
/** Whether the object sits somewhere else on `axis` than the container template says. */
private positionMoved(sceneId: string, objectId: string, axis: 'x' | 'y' | 'z'): boolean {
const object = this.currentObject(sceneId, objectId);
+4 -1
View File
@@ -84,8 +84,11 @@ export class BehaviorSystem implements System {
}
}
this.fireCollisions(instance, i, ctx);
// With an owner the click has to land on it; a behavior without one has no shape to
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
const pointer = world.pointer;
if (pointer.pressed && world.hitTest(instance.entity.parent ?? instance.entity, pointer.x, pointer.y)) {
const owner = instance.entity.parent;
if (pointer.pressed && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) {
i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
}
i.fire('OnUpdate', ctx, { dt });
+9 -3
View File
@@ -4,7 +4,7 @@ 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 { editorPosition, subtreeRoots, treeOrder } 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';
@@ -22,7 +22,11 @@ interface DrawItem {
name: string;
x: number;
y: number;
/** Draw order (see `Position.z`). `x`/`y`/`z` are world values (parents already applied). */
/**
* Draw order (see `Position.z`). `x`/`y`/`z` are world values (parents already applied) –
* in the scene view including the editor offsets along the chain, which is what makes an
* offset object be drawn, selected and dragged at the shifted place.
*/
z: number;
props: PropertyBag;
/** Id of the parent item, empty when top-level. */
@@ -362,7 +366,9 @@ 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, enabled: true });
// `editorPosition`, not `worldPosition`: the scene view honours the editor offsets, which
// shift how things are drawn (and hit) here without touching the model the game runs on.
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, ...editorPosition(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]));
+1
View File
@@ -51,6 +51,7 @@ describe('serialize', () => {
assetId: 'sp1',
name: '',
position: { x: 5, y: 0, z: 0 },
editorOffset: { x: 0, y: 0 },
overrides: { color: 0x00ff00, width: 1 },
parentId: '',
templateId: '',
+73 -33
View File
@@ -5,7 +5,8 @@
*
* 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)
* Coin (sprite plus trigger collider – placed ten times inside the level container)
* Win / Lose (containers shown when the coins are collected or the 30 seconds 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
@@ -36,12 +37,19 @@ const solid = (id, x, y, w, h, color, name = '') => [
{ 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: '' },
/**
* The coin is a container asset of its own: a sprite with a trigger collider child, so it reports
* the touch without blocking. The ten coins in the level are instances of it – changing the
* template changes all of them.
*/
const coinTemplate = [
{ id: 'coin-body', assetId: 'coin-sprite', name: '', position: { x: 0, y: 0, z: 1 }, overrides: {}, parentId: '', templateId: '' },
{ id: 'coin-body-c', assetId: 'trigger', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: 'coin-body', templateId: '' },
];
/** One placed coin: the container object, its content comes from the template above. */
const coin = (id, x, y) => ({ id, assetId: 'coin', name: 'Coin', position: { x, y, z: 0 }, overrides: {}, parentId: '', templateId: '' });
const text = (id, x, y, content, { size = 28, color = WHITE, align = 'center', name = '', parentId = '', z = 1 } = {}) => ({
id,
assetId: 'label',
@@ -64,22 +72,22 @@ const levelTemplate = [
...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),
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('time-label', -300, -160, 'Time 30', { size: 22, align: 'left', name: 'TimeLabel', z: 3 }),
text('coin-label', 300, -160, 'Coins 0/10', { size: 22, align: 'right', name: 'CoinLabel', z: 3 }),
];
@@ -95,12 +103,37 @@ const screenTemplate = (prefix, headline, hint, buttonLabel, behaviorId, headlin
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');
const loseTemplate = screenTemplate('lose', 'TIME UP', 'The time is over - try again.', 'PLAY AGAIN', 'restart-button');
/** Which template belongs to which container asset – the lookup `expand` needs for nesting. */
const containerTemplates = {
level: levelTemplate,
coin: coinTemplate,
title: titleTemplate,
win: winTemplate,
lose: loseTemplate,
};
/**
* A copy of every object of `template`, parented below `hostId` and remembering its template id.
* A template object that is itself a container is expanded in turn (unless the template already
* spells its content out) – exactly what `instantiateContainer` does in the editor.
*/
const expand = (template, hostId) => {
const objects = [];
for (const t of template) {
const id = `${hostId}-${t.id}`;
objects.push({ ...t, id, parentId: t.parentId ? `${hostId}-${t.parentId}` : hostId, templateId: t.id });
const nested = containerTemplates[t.assetId];
if (nested && !template.some((o) => o.parentId === t.id)) objects.push(...expand(nested, id));
}
return objects;
};
/** 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 })),
const instantiate = (containerId, assetId, name, position, overrides = {}, extra = {}) => [
{ id: containerId, assetId, name, position, overrides, parentId: '', templateId: '', ...extra },
...expand(containerTemplates[assetId], containerId),
];
// --- graphs ----------------------------------------------------------------------------------
@@ -108,12 +141,13 @@ const instantiate = (containerId, assetId, name, position, template, overrides =
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.
* "Player": gravity, walking, jumping and picking coins up – 35 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.
* On collision Touching a coin adds one to the "Game" behavior's counter and destroys the coin –
* the collider reports its body (the sprite), the coin itself is the container above.
*/
const playerGraph = {
nodes: [
@@ -152,6 +186,9 @@ const playerGraph = {
{ 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' } },
// The collider reports its body, which is the coin sprite; the whole coin is the container
// above it, so that is what gets destroyed.
{ id: 'p-coinroot', type: 'Parent', x: 1080, y: 1420, params: {} },
{ id: 'p-destroy', type: 'Destroy', x: 1280, y: 1200, params: {} },
],
edges: [
@@ -193,7 +230,8 @@ const playerGraph = {
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'),
edge('pe37', 'p-col', 'other', 'p-coinroot', 'object'),
edge('pe38', 'p-coinroot', 'parent', 'p-destroy', 'object'),
],
};
@@ -224,7 +262,7 @@ const gameGraph = {
{ 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-ceil', type: 'Ceil', x: 640, y: 940, params: {} },
{ id: 'g-join', type: 'Join', x: 840, y: 940, params: { a: 'Time ' } },
{ id: 'g-settext', type: 'SetText', x: 700, y: 520, params: {} },
@@ -268,8 +306,8 @@ const gameGraph = {
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('ge18', 'g-time2', 'value', 'g-ceil', 'value'),
edge('ge19', 'g-ceil', 'result', 'g-join', 'b'),
edge('ge20', 'g-join', 'text', 'g-settext', 'text'),
edge('ge21', 'g-settext', 'exec', 'g-settext2', 'exec'),
@@ -308,7 +346,7 @@ const startButtonGraph = {
{ 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-time', type: 'SetProperty', x: 460, y: 0, params: { property: 'timeLeft', value: 30 } },
{ 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 } },
@@ -352,12 +390,13 @@ const project = {
name: 'Level 1',
props: { backgroundColor: SKY },
objects: [
...instantiate('L', 'level', 'Level', { x: 0, y: 0, z: 0 }, levelTemplate),
...instantiate('L', 'level', 'Level', { x: 0, y: 0, z: 0 }),
// 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 }),
// The title screen carries an editor offset so it does not cover the level in the scene view.
...instantiate('T', 'title', 'Title', { x: 0, y: 0, z: 10 }, { enabled: true }, { editorOffset: { x: -750, y: -500 } }),
...instantiate('W', 'win', 'Win', { x: 0, y: 0, z: 10 }, { enabled: true }, { editorOffset: { x: 0, y: -500 } }),
...instantiate('B', 'lose', 'Lose', { x: 0, y: 0, z: 10 }, { enabled: true }, { editorOffset: { x: 750, y: -500 } }),
{ 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: '' },
],
@@ -387,9 +426,9 @@ const project = {
id: 'game',
type: 'behavior',
name: 'Game',
props: { timeLeft: 120, coins: 0, needed: 10, running: false },
props: { timeLeft: 30, coins: 0, needed: 10, running: false },
variables: [
variable('timeLeft', 'number', 120),
variable('timeLeft', 'number', 30),
variable('coins', 'number', 0),
variable('needed', 'number', 10),
variable('running', 'boolean', false),
@@ -398,6 +437,7 @@ const project = {
},
{ 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: 'coin', type: 'container', name: 'Coin', props: {}, objects: coinTemplate },
{ 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 },
+3 -3
View File
@@ -42,11 +42,11 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
// 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] };
const expected = { player: [35, 38], 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`);
check('the scene holds the seeded objects', objectCount === 77, `${objectCount} objects`);
await shot('01-editor');
// 2. Play: the Game behavior shows the title screen and switches the level off.
@@ -120,6 +120,6 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
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('stop restores the edited scene', saved.objects === 77 && 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(' | '));
});