From 91ee2feaddbba007d06d750e3bb4bb573fe83910 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 7 Sep 2026 16:57:19 +0200 Subject: [PATCH] Spawn node: place assets at runtime into the play copy, Spawner demo behavior Co-Authored-By: Claude Fable 5.1 --- README.md | 8 ++-- src/core/graph/NodeRegistry.ts | 6 +++ src/core/graph/nodes.ts | 16 +++++++ src/editor/graph/GraphEditor.ts | 2 +- src/editor/graph/NodeView.ts | 20 +++++--- src/editor/panels/Inspector.ts | 10 +++- src/runtime/World.ts | 68 ++++++++++++++++++++------- src/runtime/systems/BehaviorSystem.ts | 4 ++ tests/core/graph.test.ts | 41 +++++++++++++++- tests/core/hierarchy.test.ts | 2 +- tests/core/world-sync.test.ts | 35 ++++++++++++++ tools/e2e.mjs | 27 ++++++++--- tools/seed-demo.mjs | 17 +++++++ 13 files changed, 219 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 39e42835..a4936893 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,9 @@ Scene tab shows the live positions (symbols, gizmos and dragging included), the inspector show and edit the running objects, the graph can be changed, undo/redo work on the play session's own history. All of it happens in the runtime copy: values a behavior changes appear as overrides (bold, ↺ resets to the asset value), objects a graph destroys vanish from -the lists, and Esc/Stop brings back exactly what was saved. Autosave pauses while playing. +the lists, objects a graph spawns (`Spawn` node) appear in them – selectable, editable, still +only in the runtime copy –, and Esc/Stop brings back exactly what was saved. Autosave pauses +while playing. ## Behaviors (node graphs) @@ -54,8 +56,8 @@ shown on the node and editable in the inspector. 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`, `Destroy`, - `Log`, `Stop game`. A node type is one `registerNode()` call; palette, ports, validation and + 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 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. - **Idiomatic movement** (the demo's "Mover", five nodes): `On update → Move(Owner, dx = Axis diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index fa5d6b04..d89c42aa 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -62,6 +62,12 @@ export interface NodeContext { */ moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean }; log(message: string): void; + /** + * Places a new instance of an asset into the running scene and returns it. The position is + * local to `parent` (world when none). Returns null when the asset does not exist or cannot + * be placed (a scene). Spawned behaviors run their `On start` on the next tick. + */ + spawn(assetId: string, position: { x: number; y: number; z: number }, parent: EntityHandle | null): EntityHandle | null; destroy(target: EntityHandle): void; stop(): void; } diff --git a/src/core/graph/nodes.ts b/src/core/graph/nodes.ts index 456a6f48..a8393839 100644 --- a/src/core/graph/nodes.ts +++ b/src/core/graph/nodes.ts @@ -346,6 +346,22 @@ registerNode({ return { next: 'exec' }; }, }); +registerNode({ + type: 'Spawn', + label: 'Spawn', + category: 'action', + description: 'Creates a new instance of an asset in the running scene at (x, y, z) – local to the parent when one is connected, world coordinates otherwise. Outputs the new object.', + inputs: [exec(), num('x'), num('y'), num('z'), obj('parent')], + outputs: [exec(), obj('object')], + // Placeholder options: the graph inspector lists the project's placeable assets. + params: [{ key: 'asset', label: 'Asset', kind: 'enum', default: '', options: [] }], + run: (ctx, i, p) => { + const assetId = String(p.asset ?? ''); + const spawned = assetId ? ctx.spawn(assetId, { x: asNumber(i.x), y: asNumber(i.y), z: asNumber(i.z) }, asEntity(i.parent)) : null; + if (!spawned) ctx.log(assetId ? `Spawn: asset ${assetId} not found` : 'Spawn: no asset selected'); + return { outputs: { object: spawned }, next: 'exec' }; + }, +}); registerNode({ type: 'Log', label: 'Log', diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index 6c4dfaa0..0e23385f 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -131,7 +131,7 @@ export class GraphEditor extends Widget { this.views.set(node.id, view); this.nodeLayer.addChild(view); } - view.sync(node, asset.graph, asset); + view.sync(node, asset.graph, asset, this.state.project); } for (const [id, view] of this.views) { if (!seen.has(id)) { diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index fd052bcf..dfeb0c36 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -1,6 +1,8 @@ import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js'; import { edgesInto, type Graph, type GraphNode, inlineInputs, inlineValue, type NodeCategory, type NodeDef, type PortDef, type PortType } from '../../core/graph'; import type { BehaviorAsset } from '../../core/model/Asset'; +import { findAsset, type Project } from '../../core/model/Project'; +import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Theme, textStyle, Widget } from '../../ui'; import { keyLabel } from '../inspector/fieldFactory'; @@ -59,7 +61,7 @@ export class NodeView extends Widget { super(); this.cursor = 'grab'; this.addChild(this.box, this.ports); - this.sync(node, { nodes: [node], edges: [] }, null); + this.sync(node, { nodes: [node], edges: [] }, null, null); } get titleText(): string { @@ -72,9 +74,9 @@ export class NodeView extends Widget { this.hitArea = new Rectangle(-PORT_HIT_RADIUS, 0, w + PORT_HIT_RADIUS * 2, h); } - /** Re-reads params and connections; rebuilds the text lines. */ - sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null): void { - this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p.kind, node.params[p.key] ?? p.default, asset)}`); + /** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn). */ + sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void { + this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`); this.connected = new Set(inlineInputs(this.def).filter((p) => edgesInto(graph, node.id, p.key).length > 0).map((p) => p.key)); const rows = this.rowCount(); const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + rows * ROW_HEIGHT + PAD; @@ -169,10 +171,14 @@ export class NodeView extends Widget { } } -function formatParam(kind: string, value: unknown, asset: BehaviorAsset | null): string { +function formatParam(param: PropertyDef, value: unknown, asset: BehaviorAsset | null, project: Project | null): string { if (value === undefined || value === '') return '—'; - if (kind === 'key') return keyLabel(String(value)); - if (kind === 'enum' && asset && !asset.variables.some((v) => v.key === value) && asset.variables.length > 0 && String(value).length > 3) return `${value}?`; + if (param.kind === 'key') return keyLabel(String(value)); + if (param.kind === 'enum' && param.key === 'asset') { + const target = project ? findAsset(project, String(value)) : undefined; + return target ? target.name : `${value}?`; + } + if (param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value) && asset.variables.length > 0 && String(value).length > 3) return `${value}?`; return formatValue(value); } diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts index 4ddd7f42..3decae68 100644 --- a/src/editor/panels/Inspector.ts +++ b/src/editor/panels/Inspector.ts @@ -12,6 +12,7 @@ import { import { AddVariableCommand, nextVariableName, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands'; import { edgesInto, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph'; import type { Asset, BehaviorAsset, SceneAsset } from '../../core/model/Asset'; +import { isPlaceableType } from '../../core/model/AssetTypes'; import { isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy'; import type { Position, PropertyValue } from '../../core/model/Properties'; import { findAsset, findScene, findSceneObject } from '../../core/model/Project'; @@ -395,11 +396,18 @@ export class Inspector extends Panel { this.body.add(new Button('Delete node', { variant: 'danger', onClick: () => this.state.execute(new RemoveNodeCommand(assetId, nodeId)) })); } - /** Variable params pick from the asset's variables; everything else is used as declared. */ + /** + * Variable params pick from the asset's variables, asset params (Spawn) from the project's + * placeable assets; everything else is used as declared. + */ private resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef { if (param.kind === 'enum' && param.key === 'variable' && asset.type === 'behavior') { return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) }; } + if (param.kind === 'enum' && param.key === 'asset') { + const options = this.state.project.assets.filter((a) => isPlaceableType(a.type)).map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })); + return { ...param, options }; + } return param; } diff --git a/src/runtime/World.ts b/src/runtime/World.ts index cbae1bb7..19c30573 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -1,5 +1,9 @@ import { worldPositionOf } from '../core/graph'; +import { newId } from '../core/ids'; +import type { Asset } from '../core/model/Asset'; +import { isPlaceableType } from '../core/model/AssetTypes'; import { findAsset, findScene, type Project } from '../core/model/Project'; +import { createSceneObject, type SceneObject } from '../core/model/SceneObject'; import { resolveSceneObjectProps } from '../core/properties/resolve'; import type { Entity } from './Entity'; import type { System } from './systems/System'; @@ -22,6 +26,8 @@ export class World { readonly log: string[] = []; private readonly systems: System[] = []; private elapsed = 0; + /** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */ + private structureChanged = false; private constructor(readonly project: Project, readonly sceneId: string) {} @@ -48,21 +54,7 @@ export class World { for (const object of scene.objects) { const asset = findAsset(this.project, object.assetId); if (!asset || asset.type === 'scene') continue; - let entity = this.findByObjectId(object.id); - if (!entity) { - entity = { id: `e-${object.id}`, objectId: object.id, assetId: asset.id, type: asset.type, name: '', x: 0, y: 0, z: 0, props: {}, parent: null }; - this.entities.push(entity); - } - entity.assetId = asset.id; - entity.type = asset.type; - entity.name = object.name || asset.name; - entity.x = object.position.x; - entity.y = object.position.y; - entity.z = object.position.z; - const resolved = resolveSceneObjectProps(asset, object); - for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key]; - Object.assign(entity.props, resolved); - keep.add(entity); + keep.add(this.materialize(object, asset)); } for (let i = this.entities.length - 1; i >= 0; i--) if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1); // Link parents once every entity exists (storage order is not guaranteed to be parent-first). @@ -72,14 +64,56 @@ export class World { } } + /** The entity for a scene object, created when missing; position, name and props re-read. */ + private materialize(object: SceneObject, asset: Asset): Entity { + let entity = this.findByObjectId(object.id); + if (!entity) { + entity = { id: `e-${object.id}`, objectId: object.id, assetId: asset.id, type: asset.type, name: '', x: 0, y: 0, z: 0, props: {}, parent: null }; + this.entities.push(entity); + } + entity.assetId = asset.id; + entity.type = asset.type; + entity.name = object.name || asset.name; + entity.x = object.position.x; + entity.y = object.position.y; + entity.z = object.position.z; + const resolved = resolveSceneObjectProps(asset, object); + for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key]; + Object.assign(entity.props, resolved); + return entity; + } + + /** + * Creates a new instance of an asset while running (Spawn node): the object is added to + * the scene of the runtime copy – so the panels list it and a full sync keeps it – and its + * entity is created right away, without touching the other entities (they may have moved + * during this tick already). Position is local to `parent`, world when there is none. + * Returns null for unknown or unplaceable assets. + */ + spawn(assetId: string, position: { x: number; y: number; z: number }, parent: Entity | null): Entity | null { + const scene = findScene(this.project, this.sceneId); + const asset = findAsset(this.project, assetId); + if (!scene || !asset || !isPlaceableType(asset.type)) return null; + const parentEntity = parent && this.entities.includes(parent) ? parent : null; + const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? ''); + scene.objects.push(object); + const entity = this.materialize(object, asset); + entity.parent = parentEntity; + this.structureChanged = true; + return entity; + } + /** * Writes the entities' state into the scene: positions, property values that differ from * what the model resolves (stored as overrides, so the inspector marks them) and removed - * entities (their objects are dropped). Returns true when the object list changed. + * entities (their objects are dropped). Returns true when the object list changed – by a + * destroyed entity here or by a `spawn` since the last call. */ syncToModel(): boolean { const scene = findScene(this.project, this.sceneId); if (!scene) return false; + const spawned = this.structureChanged; + this.structureChanged = false; const alive = new Set(this.entities.map((e) => e.objectId)); const before = scene.objects.length; for (let i = scene.objects.length - 1; i >= 0; i--) { @@ -96,7 +130,7 @@ export class World { const base = resolveSceneObjectProps(asset, object); for (const [key, value] of Object.entries(entity.props)) if (key in base && base[key] !== value) object.overrides[key] = value; } - return scene.objects.length !== before; + return spawned || scene.objects.length !== before; } /** World position of an entity: local position plus those of all ancestors. */ diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index aa001ff1..093dd2c5 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -161,6 +161,10 @@ export class BehaviorSystem implements System { world.log.push(message); console.log(`[behavior ${entity.name}]`, message); }, + spawn: (assetId, position, parent) => { + const parentEntity = parent ? world.entities.find((e) => e === parent || e.id === parent.id) ?? null : null; + return world.spawn(assetId, position, parentEntity); + }, destroy: (target: EntityHandle) => { const found = world.entities.find((e) => e === target || e.id === target.id); if (found) world.remove(found); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index aedf3e12..c6650c30 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -4,7 +4,7 @@ import { CommandStack } from '../../src/core/commands/CommandStack'; import { AddNodeCommand, ConnectCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands'; import { PlaceAssetCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands'; -import { type EntityHandle, GraphInterpreter, type NodeContext, allNodeDefs, portsCompatible } from '../../src/core/graph'; +import { type EntityHandle, type Graph, GraphInterpreter, type NodeContext, allNodeDefs, portsCompatible } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { createEmptyProject } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; @@ -32,6 +32,7 @@ function context(overrides: Partial = {}): NodeContext & { logs: st return { blockedX: false, blockedY: false }; }, log: (m: string) => logs.push(m), + spawn: () => null, destroy: () => {}, stop: () => { ctx.stopped = true; }, ...overrides, @@ -382,3 +383,41 @@ describe('behavior serialization', () => { expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length); }); }); + +describe('Spawn node', () => { + const graph: Graph = { + nodes: [ + { id: 'start', type: 'OnStart', x: 0, y: 0, params: {} }, + { id: 'spawn', type: 'Spawn', x: 0, y: 0, params: { asset: 'crate', x: 5, y: 7 } }, + { id: 'log', type: 'Log', x: 0, y: 0, params: {} }, + ], + edges: [ + { id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'spawn', port: 'exec' } }, + { id: 'e2', from: { node: 'spawn', port: 'exec' }, to: { node: 'log', port: 'exec' } }, + { id: 'e3', from: { node: 'spawn', port: 'object' }, to: { node: 'log', port: 'message' } }, + ], + }; + + it('asks the context for a new instance at the inline position and outputs it', () => { + const calls: unknown[] = []; + const ctx = context({ + spawn: (assetId, position, parent) => { + calls.push({ assetId, position, parent }); + return entity('Crate', position.x); + }, + }); + new GraphInterpreter(graph).fire('OnStart', ctx); + expect(calls).toEqual([{ assetId: 'crate', position: { x: 5, y: 7, z: 0 }, parent: null }]); + expect(ctx.logs).toEqual(['Crate']); // the spawned object flowed into Log + }); + + it('logs a hint and continues when no asset is selected or the asset is unknown', () => { + const ctx = context({ spawn: () => null }); + const noAsset: Graph = { ...graph, nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)) }; + new GraphInterpreter(noAsset).fire('OnStart', ctx); + expect(ctx.logs).toEqual(['Spawn: no asset selected', '']); + const ctx2 = context({ spawn: () => null }); + new GraphInterpreter(graph).fire('OnStart', ctx2); + expect(ctx2.logs[0]).toBe('Spawn: asset crate not found'); + }); +}); diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts index 7db8aa9e..d9811f6c 100644 --- a/tests/core/hierarchy.test.ts +++ b/tests/core/hierarchy.test.ts @@ -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), destroy: () => {}, stop: () => {} }; + 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: () => {} }; new GraphInterpreter(graph).fire('OnStart', ctx); expect(logs).toEqual(['105', '5', '100']); }); diff --git a/tests/core/world-sync.test.ts b/tests/core/world-sync.test.ts index c260651c..4a867c24 100644 --- a/tests/core/world-sync.test.ts +++ b/tests/core/world-sync.test.ts @@ -82,6 +82,41 @@ describe('world ↔ model synchronisation (editing while playing)', () => { expect(sceneOf().objects.map((o) => o.id)).toEqual([a]); }); + it('spawn adds an object to the runtime scene and an entity at once, without resetting others', () => { + const { project, sceneId, spriteId, a, sceneOf } = setup(); + const world = World.instantiate(project, sceneId); + const entityA = world.findByObjectId(a)!; + entityA.x = 77; // moved earlier in this tick – a spawn must not pull the model value back + + const child = world.spawn(spriteId, { x: 10, y: 20, z: 1 }, entityA)!; + expect(child).not.toBeNull(); + expect(child.parent).toBe(entityA); + expect(child.type).toBe('sprite'); + expect(child.name).toBe('Box'); + expect(child.props.width).toBe(100); + expect(entityA.x).toBe(77); + const object = sceneOf().objects.find((o) => o.id === child.objectId)!; + expect(object.parentId).toBe(a); + expect(object.position).toEqual({ x: 10, y: 20, z: 1 }); + + // The spawn is a structural change the panels must see – reported once. + expect(world.syncToModel()).toBe(true); + expect(world.syncToModel()).toBe(false); + // A full sync from the model keeps the spawned entity (identity included). + world.syncFromModel(); + expect(world.findByObjectId(child.objectId)).toBe(child); + expect(child.parent).toBe(entityA); + + // Destroying the spawned entity drops its object again. + world.remove(child); + expect(world.syncToModel()).toBe(true); + expect(sceneOf().objects.some((o) => o.id === child.objectId)).toBe(false); + + // Unknown or unplaceable assets spawn nothing. + expect(world.spawn('nope', { x: 0, y: 0, z: 0 }, null)).toBeNull(); + expect(world.spawn(sceneId, { x: 0, y: 0, z: 0 }, null)).toBeNull(); + }); + it('a play-mode copy leaves the original model untouched', () => { const { project, sceneId, a, object } = setup(); const copy = deserializeProject(serializeProject(project), project.id); diff --git a/tools/e2e.mjs b/tools/e2e.mjs index 7f382ed7..c0a60559 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -237,7 +237,7 @@ try { await clickText('▶ Play'); await sleep(300); check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game'); - check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyOrder().length')) === 6); + check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyOrder().length')) === 7); await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' })`); await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400; __gm.state.preview()`); await sleep(100); @@ -247,15 +247,30 @@ try { await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 }); await sleep(150); const liveIds = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.map((e) => e.objectId); - check('Delete while playing removes the entity and its collider child from the world', !liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 4, JSON.stringify(liveIds)); + check('Delete while playing removes the entity and its collider child from the world', !liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 5, JSON.stringify(liveIds)); await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); await sleep(150); liveO2 = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o2'); check('undo while playing brings the entity back into the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2)); + // 7a-ii. Spawning: the "Spawner" behavior places a Crate at (0, 150) on Space. The new object + // shows up in the runtime scene (hierarchy), in the world and in the Game view at once; + // Stop discards it with the rest of the runtime copy. + const visibleBeforeSpawn = JSON.parse(await evaluate('JSON.stringify(__gm.gameInfo())')).visibleItems; + await keyPress(' ', 'Space', { windowsVirtualKeyCode: 32 }); + await sleep(200); + const spawnedObjects = JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects)')); + const spawned = spawnedObjects.find((o) => !['o1', 'o2', 'o3', 'o4', 'o5', 'oc1', 'oc2'].includes(o.id)); + check('Spawn node adds a crate object to the runtime scene at (0, 150)', spawned && spawned.assetId === 'crate' && spawned.parentId === '' && spawned.position.x === 0 && spawned.position.y === 150 && spawnedObjects.length === 8, JSON.stringify(spawned)); + const spawnWorld = JSON.parse(await evaluate('JSON.stringify(__gm.world())')); + check('the spawned entity lives in the world and its name reached the Log node', spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log })); + check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyOrder().length')) === 8); + info = await evaluate('JSON.stringify(__gm.gameInfo())'); + check('the Game view renders the spawned crate', JSON.parse(info).visibleItems === visibleBeforeSpawn + 1, `${info} before=${visibleBeforeSpawn}`); + await shot('08c-spawned'); await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); await sleep(300); const o2AfterEdit = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); - check('stop restores the saved model untouched by play-mode edits', o2AfterEdit === xBeforePlay && (await evaluate('__gm.state.activeScene.objects.length')) === 6, `x=${o2AfterEdit}`); + check('stop restores the saved model untouched by play-mode edits (spawned object gone)', o2AfterEdit === xBeforePlay && (await evaluate('__gm.state.activeScene.objects.length')) === 7, `x=${o2AfterEdit}`); check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene'); // 7b. Culling: move the blue crate far outside the camera frame → only one item visible. @@ -272,7 +287,7 @@ try { await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' })`); await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 }); await sleep(200); - check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 5); + check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 6); await clickText('▶ Play'); await sleep(300); info = await evaluate('JSON.stringify(__gm.gameInfo())'); @@ -284,7 +299,7 @@ try { await sleep(300); await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); await sleep(200); - check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 6); + check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 7); // 8. Context menu on the Crate asset → menu shows "Place in scene". await clickText('Crate', 'right'); @@ -295,7 +310,7 @@ try { await clickText('Place in scene'); await sleep(200); const count = await evaluate('__gm.state.activeScene.objects.length'); - check('placing from the menu adds an object', count === 7, `objects=${count}`); + check('placing from the menu adds an object', count === 8, `objects=${count}`); // 9. Create a new asset through the + menu. await clickText('+'); diff --git a/tools/seed-demo.mjs b/tools/seed-demo.mjs index 5e8d73a3..061bccc3 100644 --- a/tools/seed-demo.mjs +++ b/tools/seed-demo.mjs @@ -34,6 +34,20 @@ const moverGraph = { ], }; +/** "Spawner": pressing Space places a new Crate at world (0, 150) and logs its name. */ +const spawnerGraph = { + nodes: [ + { id: 's-key', type: 'OnKeyDown', x: 0, y: 0, params: { key: ' ' } }, + { id: 's-spawn', type: 'Spawn', x: 260, y: 0, params: { asset: 'crate', y: 150 } }, + { id: 's-log', type: 'Log', x: 520, y: 0, params: {} }, + ], + edges: [ + { id: 's-e1', from: { node: 's-key', port: 'exec' }, to: { node: 's-spawn', port: 'exec' } }, + { id: 's-e2', from: { node: 's-spawn', port: 'exec' }, to: { node: 's-log', port: 'exec' } }, + { id: 's-e3', from: { node: 's-spawn', port: 'object' }, to: { node: 's-log', port: 'message' } }, + ], +}; + const project = { formatVersion: 1, id: projectId, @@ -55,6 +69,8 @@ const project = { // instance is widened to match its 140-wide sprite override. { id: 'oc1', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: 'o1' }, { id: 'oc2', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 140 }, parentId: 'o2' }, + // Top-level spawner (no owner) left of the red crate: Space spawns a crate. + { id: 'o5', assetId: 'spawner', name: '', position: { x: -300, y: 0, z: 0 }, overrides: {}, parentId: '' }, ], }, { id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } }, @@ -68,6 +84,7 @@ const project = { variables: [{ key: 'speed', label: 'speed', kind: 'number', default: 0 }], graph: moverGraph, }, + { id: 'spawner', type: 'behavior', name: 'Spawner', props: {}, variables: [], graph: spawnerGraph }, ], };