diff --git a/README.md b/README.md index 13465637..0cf74a4c 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,14 @@ shown on the node and editable in the inspector. - **Node set** (`src/core/graph/nodes.ts`): events `On start`, `On update`, `On key down/up/held`, `On timer` · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Object by name`, `Get/Set variable`, `Get/Set property`, `Get position`, literals, `Add/Subtract/Multiply/Divide`, - `Compare`, `And/Or/Not`, `Key is down` · actions `Move`, `Set position`, `Destroy`, `Log`, - `Stop game`. A node type is one `registerNode()` call; palette, ports, validation and - inspector fields are generated from it. + `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` · + 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 + 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 + input.x, dy = Axis input.y, scale = Get variable speed, dt = On update.dt)`. - **Editing**: right-click the canvas for the searchable palette, drag port → port to connect (types are checked; a data input holds one link and is replaced), drag nodes, right-click a node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 0d793f67..a3a9c8f0 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -1,5 +1,5 @@ import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; -import { hasExecInput, isEventNode, type NodeContext, requireNodeDef, type Value } from './NodeRegistry'; +import { hasExecInput, inlineValue, isEventNode, type NodeContext, requireNodeDef, type Value } from './NodeRegistry'; /** * Guards against runaway flows (a Sequence looping back into itself, …). The interpreter @@ -83,7 +83,7 @@ export class GraphInterpreter { if (port.type === 'exec') continue; const edge = edgesInto(this.graph, node.id, port.key)[0]; if (edge) inputs[port.key] = this.readOutput(edge.from.node, edge.from.port, ctx); - else inputs[port.key] = node.params[port.key] ?? null; + else inputs[port.key] = inlineValue(node, port); } return inputs; } diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 075cc156..0fc74c78 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -44,6 +44,8 @@ export interface PortDef { key: string; label: string; type: PortType; + /** Inline value used while the input is unconnected and the node has no own value (default 0/''/false). */ + default?: PropertyValue; } export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action'; @@ -117,15 +119,20 @@ export function inlineInputs(def: NodeDef): PortDef[] { export function inlineInputDef(port: PortDef): PropertyDef { switch (port.type) { case 'boolean': - return { key: port.key, label: port.label, kind: 'boolean', default: false }; + return { key: port.key, label: port.label, kind: 'boolean', default: asBoolean(port.default ?? false) }; case 'string': case 'any': - return { key: port.key, label: port.label, kind: 'text', default: '' }; + return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') }; default: - return { key: port.key, label: port.label, kind: 'number', default: 0, step: 1 }; + return { key: port.key, label: port.label, kind: 'number', default: asNumber(port.default ?? 0), step: 1 }; } } +/** The value an unconnected input carries: the node's own inline value, else the port default. */ +export function inlineValue(node: { params: PropertyBag }, port: PortDef): Value { + return node.params[port.key] ?? port.default ?? null; +} + // --- value helpers for node implementations --------------------------------------------- export function asNumber(value: Value | undefined): number { diff --git a/src/core/graph/nodes.ts b/src/core/graph/nodes.ts index 08b3e3b3..5dbb4c37 100644 --- a/src/core/graph/nodes.ts +++ b/src/core/graph/nodes.ts @@ -166,6 +166,72 @@ const MATH: [string, string, string, (a: number, b: number) => number][] = [ for (const [type, label, description, fn] of MATH) { registerNode({ type, label, category: 'data', description, inputs: [num('a'), num('b')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: fn(asNumber(i.a), asNumber(i.b)) } }) }); } +registerNode({ type: 'Min', label: 'Min', category: 'data', description: 'The smaller of a and b.', inputs: [num('a'), num('b')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.min(asNumber(i.a), asNumber(i.b)) } }) }); +registerNode({ type: 'Max', label: 'Max', category: 'data', description: 'The larger of a and b.', inputs: [num('a'), num('b')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.max(asNumber(i.a), asNumber(i.b)) } }) }); +registerNode({ type: 'Abs', label: 'Abs', category: 'data', description: 'Absolute value (drops the sign).', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.abs(asNumber(i.value)) } }) }); +registerNode({ type: 'Sqrt', label: 'Sqrt', category: 'data', description: 'Square root (0 for negative input).', inputs: [num('value')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.sqrt(Math.max(0, asNumber(i.value))) } }) }); +registerNode({ + type: 'Clamp', + label: 'Clamp', + category: 'data', + description: 'Limits a value to the range min…max.', + inputs: [num('value'), { ...num('min'), default: 0 }, { ...num('max'), default: 1 }], + outputs: [num('result')], + params: [], + run: (_c, i) => ({ outputs: { result: Math.min(Math.max(asNumber(i.value), asNumber(i.min)), asNumber(i.max)) } }), +}); + +// --- data: vectors ------------------------------------------------------------------------- + +registerNode({ type: 'Length', label: 'Length', category: 'data', description: 'Length of the vector (x, y).', inputs: [num('x'), num('y')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: Math.hypot(asNumber(i.x), asNumber(i.y)) } }) }); +registerNode({ + type: 'Distance', + label: 'Distance', + category: 'data', + description: 'Distance between two objects.', + inputs: [obj('a'), obj('b')], + outputs: [num('result')], + params: [], + run: (_c, i) => { + const a = asEntity(i.a); + const b = asEntity(i.b); + return { outputs: { result: a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0 } }; + }, +}); +registerNode({ + type: 'Normalize', + label: 'Normalize', + category: 'data', + description: 'Scales the vector (x, y) to length 1; (0, 0) stays (0, 0).', + inputs: [num('x'), num('y')], + outputs: [num('x'), num('y'), num('length')], + params: [], + run: (_c, i) => ({ outputs: normalize(asNumber(i.x), asNumber(i.y)) }), +}); +registerNode({ + type: 'AxisInput', + label: 'Axis input', + category: 'data', + description: 'Direction from four keys as a normalized vector (diagonals are not faster).', + inputs: [], + outputs: [num('x'), num('y'), bool('active')], + params: [ + { key: 'left', label: 'Left', kind: 'key', default: 'ArrowLeft' }, + { key: 'right', label: 'Right', kind: 'key', default: 'ArrowRight' }, + { key: 'up', label: 'Up', kind: 'key', default: 'ArrowUp' }, + { key: 'down', label: 'Down', kind: 'key', default: 'ArrowDown' }, + ], + run: (ctx, _i, p) => { + const held = (key: string, fallback: string) => (ctx.isKeyDown(String(p[key] ?? fallback)) ? 1 : 0); + const { x, y, length } = normalize(held('right', 'ArrowRight') - held('left', 'ArrowLeft'), held('down', 'ArrowDown') - held('up', 'ArrowUp')); + return { outputs: { x, y, active: length > 0 } }; + }, +}); + +function normalize(x: number, y: number): { x: number; y: number; length: number } { + const length = Math.hypot(x, y); + return length === 0 ? { x: 0, y: 0, length: 0 } : { x: x / length, y: y / length, length }; +} const COMPARE: Record boolean> = { '==': (a, b) => a === b, @@ -196,15 +262,16 @@ registerNode({ type: 'Move', label: 'Move', category: 'action', - description: 'Moves an object by dx/dy.', - inputs: [exec(), obj('object'), num('dx'), num('dy')], + description: 'Moves an object by (dx, dy) × scale × dt. Connect dt to an event\'s dt for per-second movement; leave it at 1 for per-frame.', + inputs: [exec(), obj('object'), num('dx'), num('dy'), { ...num('scale'), default: 1 }, { ...num('dt'), default: 1 }], outputs: [exec()], params: [], - run: (_c, i) => { + run: (_ctx, i) => { const target = asEntity(i.object); if (target) { - target.x += asNumber(i.dx); - target.y += asNumber(i.dy); + const factor = asNumber(i.scale) * asNumber(i.dt); + target.x += asNumber(i.dx) * factor; + target.y += asNumber(i.dy) * factor; } return { next: 'exec' }; }, diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 5944276c..fd052bcf 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -1,5 +1,5 @@ import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js'; -import { edgesInto, type Graph, type GraphNode, inlineInputs, type NodeCategory, type NodeDef, type PortDef, type PortType } from '../../core/graph'; +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 { Theme, textStyle, Widget } from '../../ui'; import { keyLabel } from '../inspector/fieldFactory'; @@ -74,7 +74,7 @@ export class NodeView extends Widget { /** 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], asset)}`); + this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p.kind, node.params[p.key] ?? p.default, asset)}`); 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; @@ -134,8 +134,8 @@ export class NodeView extends Widget { if (input) { let label = input.label; if (input.type !== 'exec' && input.type !== 'object' && !this.connected.has(input.key)) { - const value = this.node.params[input.key]; - if (value !== undefined && value !== '') label += ` ${formatValue(value)}`; + const value = inlineValue(this.node, input); + if (value !== null && value !== '') label += ` ${formatValue(value)}`; } add(label || '', input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20); } diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 83f2a199..4b79d42b 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -107,6 +107,96 @@ describe('GraphInterpreter', () => { expect(ctx.self.y).toBe(-3); }); + it('uses port defaults for unconnected inputs (Move scale = 1, Clamp 0…1)', () => { + const project = createEmptyProject('p', 'P'); + const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; + project.assets.push(behavior); + const stack = new CommandStack(project); + const start = new AddNodeCommand('b', 'OnStart', 0, 0); + const self = new AddNodeCommand('b', 'Self', 0, 0); + const clamp = new AddNodeCommand('b', 'Clamp', 0, 0, { value: 5 }); + const move = new AddNodeCommand('b', 'Move', 0, 0, { dx: 4 }); + for (const c of [start, self, clamp, move]) stack.execute(c); + stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' })); + stack.execute(new ConnectCommand('b', { node: self.node.id, port: 'object' }, { node: move.node.id, port: 'object' })); + stack.execute(new ConnectCommand('b', { node: clamp.node.id, port: 'result' }, { node: move.node.id, port: 'dy' })); + const ctx = context(); + new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0 + expect(ctx.self.y).toBe(1); // clamp(5, 0, 1) + }); + + it('five-node mover: Axis input → Move(dt from On update) moves speed × dt, diagonals normalized', () => { + const project = createEmptyProject('p', 'P'); + const behavior = buildAsset('behavior', 'Mover', 'mover') as BehaviorAsset; + project.assets.push(behavior); + const stack = new CommandStack(project); + stack.execute(new AddVariableCommand('mover', 'speed', 'number')); + const update = new AddNodeCommand('mover', 'OnUpdate', 0, 0); + const owner = new AddNodeCommand('mover', 'Owner', 0, 0); + const axis = new AddNodeCommand('mover', 'AxisInput', 0, 0); + const speed = new AddNodeCommand('mover', 'GetVariable', 0, 0, { variable: 'speed' }); + const move = new AddNodeCommand('mover', 'Move', 0, 0); + for (const c of [update, owner, axis, speed, move]) stack.execute(c); + const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) => + stack.execute(new ConnectCommand('mover', { node: from.node.id, port: fp }, { node: to.node.id, port: tp })); + connect(update, 'exec', move, 'exec'); + connect(update, 'dt', move, 'dt'); + connect(owner, 'object', move, 'object'); + connect(axis, 'x', move, 'dx'); + connect(axis, 'y', move, 'dy'); + connect(speed, 'value', move, 'scale'); + expect(behavior.graph.nodes).toHaveLength(5); + + const interpreter = new GraphInterpreter(behavior.graph); + const run = (keys: string[]) => { + const ctx = context({ variables: { speed: 100 }, dt: 0.5, isKeyDown: (k) => keys.includes(k) }); + interpreter.fire('OnUpdate', ctx, { dt: 0.5 }); + return { x: ctx.owner!.x, y: ctx.owner!.y }; + }; + expect(run([])).toEqual({ x: 0, y: 0 }); + expect(run(['ArrowRight'])).toEqual({ x: 50, y: 0 }); + expect(run(['ArrowLeft'])).toEqual({ x: -50, y: 0 }); + expect(run(['ArrowUp'])).toEqual({ x: 0, y: -50 }); + const diagonal = run(['ArrowLeft', 'ArrowUp']); + expect(Math.hypot(diagonal.x, diagonal.y)).toBeCloseTo(50, 6); // same distance as a straight move + expect(diagonal.x).toBeCloseTo(-50 / Math.SQRT2, 6); + expect(diagonal.y).toBeCloseTo(-50 / Math.SQRT2, 6); + expect(run(['ArrowLeft', 'ArrowRight'])).toEqual({ x: 0, y: 0 }); // opposite keys cancel + }); + + it('vector and math helpers', () => { + const project = createEmptyProject('p', 'P'); + const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; + project.assets.push(behavior); + const stack = new CommandStack(project); + const start = new AddNodeCommand('b', 'OnStart', 0, 0); + const norm = new AddNodeCommand('b', 'Normalize', 0, 0, { x: 3, y: 4 }); + const len = new AddNodeCommand('b', 'Length', 0, 0, { x: 3, y: 4 }); + const sqrt = new AddNodeCommand('b', 'Sqrt', 0, 0, { value: 16 }); + const abs = new AddNodeCommand('b', 'Abs', 0, 0, { value: -2 }); + const min = new AddNodeCommand('b', 'Min', 0, 0, { a: 3, b: -1 }); + const max = new AddNodeCommand('b', 'Max', 0, 0, { a: 3, b: -1 }); + const logs = [norm, len, sqrt, abs, min, max].map(() => new AddNodeCommand('b', 'Log', 0, 0)); + for (const c of [start, norm, len, sqrt, abs, min, max, ...logs]) stack.execute(c); + const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) => + stack.execute(new ConnectCommand('b', { node: from.node.id, port: fp }, { node: to.node.id, port: tp })); + let previous = start; + for (const log of logs) { + connect(previous, 'exec', log, 'exec'); + previous = log; + } + connect(norm, 'x', logs[0]!, 'message'); + connect(len, 'result', logs[1]!, 'message'); + connect(sqrt, 'result', logs[2]!, 'message'); + connect(abs, 'result', logs[3]!, 'message'); + connect(min, 'result', logs[4]!, 'message'); + connect(max, 'result', logs[5]!, 'message'); + const ctx = context(); + new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + expect(ctx.logs).toEqual(['0.6', '5', '4', '2', '-1', '3']); + }); + it('follows Branch, writes variables and logs', () => { const project = createEmptyProject('p', 'P'); const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; diff --git a/tools/e2e.mjs b/tools/e2e.mjs index ba021573..c8be0f5f 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -200,7 +200,8 @@ try { // 7. Play: the camera frame is rendered (both crates lie inside the 640×360 frame at the // origin; the behavior symbol is editor-only). The "Mover" behavior owns the red crate: - // holding ArrowRight moves it in the world (speed × dt), Esc stops without touching the model. + // holding ArrowRight moves it in the world (Axis input × speed × dt), Esc stops without + // touching the model. await clickText('Renamed crate'); const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); await clickText('▶ Play'); @@ -220,7 +221,7 @@ try { await sleep(250); await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); const afterLeft = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o1'); - check('inline dx (−3 per frame) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`); + check('ArrowLeft (axis x = −1) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`); await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); await sleep(300); s = JSON.parse(await stateJson()); @@ -350,16 +351,18 @@ try { const graphCounts = () => evaluate(`(() => { const g = __gm.state.project.assets.find(a => a.id === 'mover').graph; return JSON.stringify({ nodes: g.nodes.length, edges: g.edges.length }); })()`).then(JSON.parse); // 13a. Drag a node: clicking its title selects it, dragging moves it (undoable). - const mulBefore = await nodeOf('n-mul'); - const mulPos = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.nodeAt('n-mul'))`)); + const mulBefore = await nodeOf('n-axis'); + const mulPos = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.nodeAt('n-axis'))`)); await drag(mulPos.x, mulPos.y, mulPos.x + 60, mulPos.y + 40); s = JSON.parse(await stateJson()); - const mulAfter = await nodeOf('n-mul'); - check('dragging a node selects and moves it', s.selection?.kind === 'node' && s.selection.nodeId === 'n-mul' && mulAfter.x === mulBefore.x + 60 && mulAfter.y === mulBefore.y + 40, `${JSON.stringify(s.selection)} ${mulBefore.x},${mulBefore.y} → ${mulAfter.x},${mulAfter.y}`); - check('node inspector shows the node', Boolean(await evaluate(`__gm.locate('Multiply')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`))); + const mulAfter = await nodeOf('n-axis'); + check('dragging a node selects and moves it', s.selection?.kind === 'node' && s.selection.nodeId === 'n-axis' && mulAfter.x === mulBefore.x + 60 && mulAfter.y === mulBefore.y + 40, `${JSON.stringify(s.selection)} ${mulBefore.x},${mulBefore.y} → ${mulAfter.x},${mulAfter.y}`); + check('node inspector shows the node', Boolean(await evaluate(`__gm.locate('Axis input')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`))); + // The Move node's unconnected `scale` shows its port default (1) once its edge is removed; + // with the edge present the inspector hides it. Port defaults are covered by unit tests. await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); await sleep(150); - const mulUndone = await nodeOf('n-mul'); + const mulUndone = await nodeOf('n-axis'); check('undo moves the node back', mulUndone.x === mulBefore.x && mulUndone.y === mulBefore.y, `${mulUndone.x},${mulUndone.y}`); // 13a. Regression: typing a query and then *clicking* a row must add exactly one node. diff --git a/tools/seed-demo.mjs b/tools/seed-demo.mjs index b5a32688..bfaf3e50 100644 --- a/tools/seed-demo.mjs +++ b/tools/seed-demo.mjs @@ -11,27 +11,25 @@ if (!projectId) { } /** - * "Mover" behavior: ArrowRight moves the owner by speed × dt per frame (data flow through - * a variable and a Multiply node), ArrowLeft moves it by an inline −3 per frame. + * "Mover" behavior in five nodes: every frame, move the owner along the normalized arrow-key + * axis, scaled by the `speed` variable per second (On update's dt feeds Move's dt input). + * Diagonals are not faster than straight moves because Axis input normalizes the vector. */ const moverGraph = { nodes: [ - { id: 'n-right', type: 'OnKeyHeld', x: 0, y: 0, params: { key: 'ArrowRight' } }, - { id: 'n-owner', type: 'Owner', x: 0, y: 140, params: {} }, - { id: 'n-speed', type: 'GetVariable', x: 0, y: 220, params: { variable: 'speed' } }, - { id: 'n-mul', type: 'Multiply', x: 220, y: 180, params: {} }, - { id: 'n-move', type: 'Move', x: 440, y: 0, params: { dy: 0 } }, - { id: 'n-left', type: 'OnKeyHeld', x: 0, y: 340, params: { key: 'ArrowLeft' } }, - { id: 'n-move2', type: 'Move', x: 440, y: 340, params: { dx: -3, dy: 0 } }, + { id: 'n-update', type: 'OnUpdate', x: 0, y: 0, params: {} }, + { id: 'n-owner', type: 'Owner', x: 0, y: 120, params: {} }, + { id: 'n-axis', type: 'AxisInput', x: 0, y: 200, params: {} }, + { id: 'n-speed', type: 'GetVariable', x: 0, y: 400, params: { variable: 'speed' } }, + { id: 'n-move', type: 'Move', x: 340, y: 0, params: {} }, ], edges: [ - { id: 'e1', from: { node: 'n-right', port: 'exec' }, to: { node: 'n-move', port: 'exec' } }, + { id: 'e1', from: { node: 'n-update', port: 'exec' }, to: { node: 'n-move', port: 'exec' } }, + { id: 'e6', from: { node: 'n-update', port: 'dt' }, to: { node: 'n-move', port: 'dt' } }, { id: 'e2', from: { node: 'n-owner', port: 'object' }, to: { node: 'n-move', port: 'object' } }, - { id: 'e3', from: { node: 'n-speed', port: 'value' }, to: { node: 'n-mul', port: 'a' } }, - { id: 'e4', from: { node: 'n-right', port: 'dt' }, to: { node: 'n-mul', port: 'b' } }, - { id: 'e5', from: { node: 'n-mul', port: 'result' }, to: { node: 'n-move', port: 'dx' } }, - { id: 'e6', from: { node: 'n-left', port: 'exec' }, to: { node: 'n-move2', port: 'exec' } }, - { id: 'e7', from: { node: 'n-owner', port: 'object' }, to: { node: 'n-move2', port: 'object' } }, + { id: 'e3', from: { node: 'n-axis', port: 'x' }, to: { node: 'n-move', port: 'dx' } }, + { id: 'e4', from: { node: 'n-axis', port: 'y' }, to: { node: 'n-move', port: 'dy' } }, + { id: 'e5', from: { node: 'n-speed', port: 'value' }, to: { node: 'n-move', port: 'scale' } }, ], };