Replace Move 'per second' setting with a dt data input

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 14:21:46 +02:00
1 parent 06b1007015
commit 9060da615a
8 files changed
+210 -40

No files matched your search

+8 -3
View File
@@ -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`, - **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 `On timer` · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Object by name`, `Get/Set
variable`, `Get/Set property`, `Get position`, literals, `Add/Subtract/Multiply/Divide`, variable`, `Get/Set property`, `Get position`, literals, `Add/Subtract/Multiply/Divide`,
`Compare`, `And/Or/Not`, `Key is down` · actions `Move`, `Set position`, `Destroy`, `Log`, `Min/Max/Abs/Sqrt/Clamp`, `Length`, `Distance`, `Normalize`, `Axis input` (four keys → a
`Stop game`. A node type is one `registerNode()` call; palette, ports, validation and normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down` ·
inspector fields are generated from it. 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 - **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 (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 node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging
+2 -2
View File
@@ -1,5 +1,5 @@
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; 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 * Guards against runaway flows (a Sequence looping back into itself, …). The interpreter
@@ -83,7 +83,7 @@ export class GraphInterpreter {
if (port.type === 'exec') continue; if (port.type === 'exec') continue;
const edge = edgesInto(this.graph, node.id, port.key)[0]; const edge = edgesInto(this.graph, node.id, port.key)[0];
if (edge) inputs[port.key] = this.readOutput(edge.from.node, edge.from.port, ctx); 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; return inputs;
} }
+10 -3
View File
@@ -44,6 +44,8 @@ export interface PortDef {
key: string; key: string;
label: string; label: string;
type: PortType; 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'; export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action';
@@ -117,15 +119,20 @@ export function inlineInputs(def: NodeDef): PortDef[] {
export function inlineInputDef(port: PortDef): PropertyDef { export function inlineInputDef(port: PortDef): PropertyDef {
switch (port.type) { switch (port.type) {
case 'boolean': 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 'string':
case 'any': 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: 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 --------------------------------------------- // --- value helpers for node implementations ---------------------------------------------
export function asNumber(value: Value | undefined): number { export function asNumber(value: Value | undefined): number {
+72 -5
View File
@@ -166,6 +166,72 @@ const MATH: [string, string, string, (a: number, b: number) => number][] = [
for (const [type, label, description, fn] of MATH) { 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, 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<string, (a: number, b: number) => boolean> = { const COMPARE: Record<string, (a: number, b: number) => boolean> = {
'==': (a, b) => a === b, '==': (a, b) => a === b,
@@ -196,15 +262,16 @@ registerNode({
type: 'Move', type: 'Move',
label: 'Move', label: 'Move',
category: 'action', category: 'action',
description: 'Moves an object by dx/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')], inputs: [exec(), obj('object'), num('dx'), num('dy'), { ...num('scale'), default: 1 }, { ...num('dt'), default: 1 }],
outputs: [exec()], outputs: [exec()],
params: [], params: [],
run: (_c, i) => { run: (_ctx, i) => {
const target = asEntity(i.object); const target = asEntity(i.object);
if (target) { if (target) {
target.x += asNumber(i.dx); const factor = asNumber(i.scale) * asNumber(i.dt);
target.y += asNumber(i.dy); target.x += asNumber(i.dx) * factor;
target.y += asNumber(i.dy) * factor;
} }
return { next: 'exec' }; return { next: 'exec' };
}, },
+4 -4
View File
@@ -1,5 +1,5 @@
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js'; 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 type { BehaviorAsset } from '../../core/model/Asset';
import { Theme, textStyle, Widget } from '../../ui'; import { Theme, textStyle, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory'; import { keyLabel } from '../inspector/fieldFactory';
@@ -74,7 +74,7 @@ export class NodeView extends Widget {
/** Re-reads params and connections; rebuilds the text lines. */ /** Re-reads params and connections; rebuilds the text lines. */
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null): void { 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)); 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 rows = this.rowCount();
const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + rows * ROW_HEIGHT + PAD; 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) { if (input) {
let label = input.label; let label = input.label;
if (input.type !== 'exec' && input.type !== 'object' && !this.connected.has(input.key)) { if (input.type !== 'exec' && input.type !== 'object' && !this.connected.has(input.key)) {
const value = this.node.params[input.key]; const value = inlineValue(this.node, input);
if (value !== undefined && value !== '') label += ` ${formatValue(value)}`; if (value !== null && value !== '') label += ` ${formatValue(value)}`;
} }
add(label || '', input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20); add(label || '', input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20);
} }
+90
View File
@@ -107,6 +107,96 @@ describe('GraphInterpreter', () => {
expect(ctx.self.y).toBe(-3); 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', () => { it('follows Branch, writes variables and logs', () => {
const project = createEmptyProject('p', 'P'); const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
+11 -8
View File
@@ -200,7 +200,8 @@ try {
// 7. Play: the camera frame is rendered (both crates lie inside the 640×360 frame at the // 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: // 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'); await clickText('Renamed crate');
const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
await clickText('▶ Play'); await clickText('▶ Play');
@@ -220,7 +221,7 @@ try {
await sleep(250); await sleep(250);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); 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'); 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 keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(300); await sleep(300);
s = JSON.parse(await stateJson()); 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); 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). // 13a. Drag a node: clicking its title selects it, dragging moves it (undoable).
const mulBefore = await nodeOf('n-mul'); const mulBefore = await nodeOf('n-axis');
const mulPos = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.nodeAt('n-mul'))`)); 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); await drag(mulPos.x, mulPos.y, mulPos.x + 60, mulPos.y + 40);
s = JSON.parse(await stateJson()); s = JSON.parse(await stateJson());
const mulAfter = await nodeOf('n-mul'); const mulAfter = await nodeOf('n-axis');
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('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('Multiply')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`))); 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 keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150); 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}`); 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. // 13a. Regression: typing a query and then *clicking* a row must add exactly one node.
+13 -15
View File
@@ -11,27 +11,25 @@ if (!projectId) {
} }
/** /**
* "Mover" behavior: ArrowRight moves the owner by speed × dt per frame (data flow through * "Mover" behavior in five nodes: every frame, move the owner along the normalized arrow-key
* a variable and a Multiply node), ArrowLeft moves it by an inline −3 per frame. * 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 = { const moverGraph = {
nodes: [ nodes: [
{ id: 'n-right', type: 'OnKeyHeld', x: 0, y: 0, params: { key: 'ArrowRight' } }, { id: 'n-update', type: 'OnUpdate', x: 0, y: 0, params: {} },
{ id: 'n-owner', type: 'Owner', x: 0, y: 140, params: {} }, { id: 'n-owner', type: 'Owner', x: 0, y: 120, params: {} },
{ id: 'n-speed', type: 'GetVariable', x: 0, y: 220, params: { variable: 'speed' } }, { id: 'n-axis', type: 'AxisInput', x: 0, y: 200, params: {} },
{ id: 'n-mul', type: 'Multiply', x: 220, y: 180, params: {} }, { id: 'n-speed', type: 'GetVariable', x: 0, y: 400, params: { variable: 'speed' } },
{ id: 'n-move', type: 'Move', x: 440, y: 0, params: { dy: 0 } }, { id: 'n-move', type: 'Move', x: 340, y: 0, params: {} },
{ 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 } },
], ],
edges: [ 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: '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: 'e3', from: { node: 'n-axis', port: 'x' }, to: { node: 'n-move', port: 'dx' } },
{ id: 'e4', from: { node: 'n-right', port: 'dt' }, to: { node: 'n-mul', port: 'b' } }, { id: 'e4', from: { node: 'n-axis', port: 'y' }, to: { node: 'n-move', port: 'dy' } },
{ id: 'e5', from: { node: 'n-mul', port: 'result' }, to: { node: 'n-move', port: 'dx' } }, { id: 'e5', from: { node: 'n-speed', port: 'value' }, to: { node: 'n-move', port: 'scale' } },
{ 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' } },
], ],
}; };