Spawn node: place assets at runtime into the play copy, Spawner demo behavior

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 16:57:19 +02:00
1 parent c3a533c4e5
commit 91ee2feadd
13 files changed
+211 -29

No files matched your search

+5 -3
View File
@@ -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
+6
View File
@@ -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;
}
+16
View File
@@ -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',
+1 -1
View File
@@ -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)) {
+13 -7
View File
@@ -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);
}
+9 -1
View File
@@ -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;
}
+43 -9
View File
@@ -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,6 +54,18 @@ export class World {
for (const object of scene.objects) {
const asset = findAsset(this.project, object.assetId);
if (!asset || asset.type === 'scene') continue;
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).
for (const entity of this.entities) {
const object = scene.objects.find((o) => o.id === entity.objectId);
entity.parent = object?.parentId ? this.findByObjectId(object.parentId) ?? null : null;
}
}
/** 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 };
@@ -62,24 +80,40 @@ export class World {
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);
}
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).
for (const entity of this.entities) {
const object = scene.objects.find((o) => o.id === entity.objectId);
entity.parent = object?.parentId ? this.findByObjectId(object.parentId) ?? null : null;
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. */
+4
View File
@@ -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);
+40 -1
View File
@@ -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> = {}): 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');
});
});
+1 -1
View File
@@ -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']);
});
+35
View File
@@ -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);
+21 -6
View File
@@ -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('+');
+17
View File
@@ -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 },
],
};