From 3234eb480f52428223f53fde2162864280e9e4f6 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 07:34:00 +0200 Subject: [PATCH] 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 --- README.md | 18 +++- src/core/commands/containerCommands.ts | 2 + src/core/commands/sceneObjectCommands.ts | 29 +++++++ src/core/graph/nodes.ts | 2 +- src/core/model/SceneObject.ts | 7 ++ src/core/model/container.ts | 4 +- src/core/model/hierarchy.ts | 38 ++++++-- src/core/store/serialize.ts | 3 + src/editor/panels/Inspector.ts | 49 ++++++++++- src/runtime/systems/BehaviorSystem.ts | 5 +- src/viewport/Viewport.ts | 12 ++- tests/core/serialize.test.ts | 1 + tools/seed-jumprun.mjs | 106 ++++++++++++++++------- tools/smoke-jumprun.mjs | 6 +- 14 files changed, 229 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index c254f5bd..4521b54c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts index 73c94f73..76c42986 100644 --- a/src/core/commands/containerCommands.ts +++ b/src/core/commands/containerCommands.ts @@ -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: '', diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index 3ed420a9..be7f859a 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -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 = ''; diff --git a/src/core/graph/nodes.ts b/src/core/graph/nodes.ts index 860a574c..5ca75bd7 100644 --- a/src/core/graph/nodes.ts +++ b/src/core/graph/nodes.ts @@ -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: [], diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts index 92f6b2f4..c6007233 100644 --- a/src/core/model/SceneObject.ts +++ b/src/core/model/SceneObject.ts @@ -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: '', diff --git a/src/core/model/container.ts b/src/core/model/container.ts index 5d3888ef..5b6faa45 100644 --- a/src/core/model/container.ts +++ b/src/core/model/container.ts @@ -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)); diff --git a/src/core/model/hierarchy.ts b/src/core/model/hierarchy.ts index 4f0979c2..37d9eef0 100644 --- a/src/core/model/hierarchy.ts +++ b/src/core/model/hierarchy.ts @@ -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([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; } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index d1748519..7b4e2d7b 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -77,11 +77,14 @@ function normalizeObjects(raw: unknown, schemas: Map): 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 : '', diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts index 6284d036..68a7f9d4 100644 --- a/src/editor/panels/Inspector.ts +++ b/src/editor/panels/Inspector.ts @@ -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); diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 6559da1d..95dc70f4 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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 }); diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts index bc7dbca2..bcf105be 100644 --- a/src/viewport/Viewport.ts +++ b/src/viewport/Viewport.ts @@ -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])); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index febeb077..79ea9bf8 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -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: '', diff --git a/tools/seed-jumprun.mjs b/tools/seed-jumprun.mjs index 2ab58ff6..d1e00a38 100644 --- a/tools/seed-jumprun.mjs +++ b/tools/seed-jumprun.mjs @@ -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 }, diff --git a/tools/smoke-jumprun.mjs b/tools/smoke-jumprun.mjs index d13b36d5..0424fd98 100644 --- a/tools/smoke-jumprun.mjs +++ b/tools/smoke-jumprun.mjs @@ -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(' | ')); });