Files
feuerwerk/tools/e2e/24-property-nodes.mjs
T
rootandClaude Opus 5 854ae2e756 Add Get/Set properties nodes and a breadcrumb for opened sub-behaviors
Get properties / Set properties work like their variable counterparts:
one port per chosen property, an unconnected input writes its inline
value. A port chosen from the built-in list keeps that property's field
and default (height starts at 100, color is a colour field). "object" is
reserved for the fixed input, checked by the command and on load.

The property picker names the owning type ("Sprite width"), and text
fields can now carry a suggestion list; Get/Set property use it.

Double-clicking a call node shows the path of opened behaviors under the
graph header. Clicking an entry jumps back and trims the path; choosing
a behavior any other way hides it. Reopening a behavior already on the
path steps back to it instead of repeating it. The bar shows before the
graph switches, otherwise it clips the graph that was just fitted.

The "Add node..." header button is gone; nodes come from the context menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:07:07 +02:00

219 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edge, node, port, variable } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* Get properties / Set properties: several properties of an object in one node, picked the way
* Get/Set variables pick theirs. A built-in property keeps its own field and default; a name the
* list does not know is typed. The step brings its own box and behavior and removes them again.
*/
/** `On update → Set properties{width 40, color red} on the owner`, then its width read back into `seen`. */
const logicGraph = {
nodes: [
node('pp-update', 'OnUpdate', 0, 0),
node('pp-owner', 'Owner', 0, 240),
node('pp-set', 'SetProperties', 260, 0, { width: 40, color: 0xff0000 }, [
port('width', 'number'),
port('color', 'number'),
]),
node('pp-get', 'GetProperties', 260, 300, {}, [port('width', 'number')]),
node('pp-store', 'SetVariable', 560, 0, { variable: 'seen' }),
node('pp-single', 'SetProperty', 560, 300, { property: 'width' }),
],
edges: [
edge('pp-e1', 'pp-update', 'exec', 'pp-set', 'exec'),
edge('pp-e2', 'pp-owner', 'object', 'pp-set', 'object'),
edge('pp-e3', 'pp-set', 'exec', 'pp-store', 'exec'),
edge('pp-e4', 'pp-owner', 'object', 'pp-get', 'object'),
edge('pp-e5', 'pp-get', 'width', 'pp-store', 'value'),
],
};
export async function propertyNodes(t) {
const { check, sleep, evaluate, json, shot, click, doubleClick, locate, find, undo, entity, asset } = t;
const { mutate, stop, typeText, press } = t;
const portsOf = async (nodeId) =>
((await asset('pp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? []).map(
(p) => `${p.key}:${p.kind}`,
);
const select = async (nodeId) => {
await evaluate(
`__gm.state.select({ kind: 'node', assetId: 'pp-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
);
await sleep(300);
};
/** The inspector is drawn over the graph, so the last match is the row rather than the port. */
const clickLast = async (text) => {
const p = await locate(text, true);
await click(p.x, p.y);
await sleep(200);
};
const addProperty = async (search) => {
await clickLast('+ Property');
await typeText(search);
await sleep(150);
await press('Enter');
await sleep(300);
};
/** The name field, not the node's port or the unconnected-input row below it that say the same. */
const rename = async (from, to) => {
const canvas = await json('__gm.graph.canvasRect()');
const hits = await json(`__gm.locateAll(${JSON.stringify(from)})`);
const field = hits.find((p) => p.x > canvas.x + canvas.w);
await doubleClick(field.x, field.y);
await typeText(to);
await press('Enter');
await sleep(300);
};
// 24a. A box wearing a behavior that reshapes it through one node.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const a = structuredClone(p.assets.find((x) => x.id === 'mover'));
Object.assign(a, {
id: 'pp-logic',
name: 'Reshape',
props: { enabled: true, runInEditor: false, seen: 0 },
variables: ${JSON.stringify([variable('seen', 'number', 0)])},
graph: ${JSON.stringify(logicGraph)},
});
p.assets.unshift(a);
${MAKE_OBJECT}
scene.objects.push(make('pp-box', 'crate', '', 900, 'Reshaped box'), make('pp-brain', 'pp-logic', 'pp-box', 900, 'Reshape'));
`);
await sleep(400);
// 24b. The run: one node writes both properties, another reads one of them back.
await t.play();
await sleep(400);
const box = (await entity('pp-box'))?.props;
const seen = (await entity('pp-brain'))?.props.seen;
await stop();
await sleep(300);
check(
'one node writes several properties, another reads them back',
box?.width === 40 && box?.color === 0xff0000 && seen === 40,
JSON.stringify({ width: box?.width, color: box?.color, seen }),
);
// 24c. A typed-in value reads in the words of its property: a color as its hex.
await select('pp-set');
check(
'the node shows a typed-in color as a color',
(await find('color #ff0000')) !== null,
JSON.stringify(await json(`__gm.graph.nodeLabel('pp-set')`)),
);
await shot('01-property-ports');
// 24d. The list names the type each property belongs to; a key several types share is there once
// per type, and taken for all of them once picked.
await clickLast('+ Property');
const offered = await json('__gm.overlayTexts()');
await press('Escape');
await sleep(200);
check(
'the list names each property with its type',
['Object enabled', 'Sprite height', 'Camera zoom', 'Text align'].every((l) => offered.includes(l)) &&
!offered.includes('height'),
offered.join(' | '),
);
// 24e. Picked from the built-in properties, and an unconnected one starts at that property's
// default – a height of 100, not a bare 0 that would flatten the box.
await addProperty('height');
const grown = await portsOf('pp-set');
check(
'picking a property adds it as a port',
grown.join(',') === 'width:number,color:number,height:number',
grown.join(','),
);
check(
"and its typed-in value starts at the property's own default",
(await find('height 100')) !== null,
JSON.stringify(await find('height 0')),
);
await shot('02-property-added');
await undo();
await sleep(300);
check('undo takes it back', (await portsOf('pp-set')).length === 2);
// 24f. A name the list does not know – a variable of another behavior's object – is typed, with
// a kind of its own. Typing a built-in name turns it into that property, kind and all.
await addProperty('custom text');
check('a custom port is named and typed by hand', (await portsOf('pp-set'))[2] === 'property1:text');
await rename('property1', 'speed');
check(
'and renamed by typing',
(await portsOf('pp-set'))[2] === 'speed:text',
(await portsOf('pp-set')).join(),
);
await rename('speed', 'radius');
check(
'typing a built-in name makes it that property, with its kind',
(await portsOf('pp-set'))[2] === 'radius:number',
(await portsOf('pp-set')).join(),
);
await shot('03-custom-renamed');
await addProperty('custom number');
await evaluate("__gm.state.notice = ''");
await rename('property1', 'object');
const notice = await json('__gm.state.notice');
check(
'the object input keeps its key: a port cannot take it',
(await portsOf('pp-set'))[3] === 'property1:number' && notice.includes('"object" is taken'),
JSON.stringify({ ports: await portsOf('pp-set'), notice }),
);
// 24g. Get properties grows outputs from the same list.
await select('pp-get');
await addProperty('color');
check(
'Get properties picks from the same list, as outputs',
(await portsOf('pp-get')).join(',') === 'width:number,color:number' &&
Boolean(await json(`__gm.graph.portAt('pp-get', 'color', true)`)),
(await portsOf('pp-get')).join(','),
);
// 24h. The single Set property names its property by text; the button beside it suggests the
// same list, and picking an entry writes its key.
await select('pp-single');
const param = async () =>
(await asset('pp-logic')).graph.nodes.find((n) => n.id === 'pp-single').params.property;
const field = await json(`__gm.inspectorField('Property', true)`);
await click(2 * field.x - field.left - 14, field.y);
await sleep(200);
const suggested = await json('__gm.overlayTexts()');
check(
'the property setting suggests the built-in properties, the current one marked',
suggested.includes('● Sprite width') && suggested.includes('Text align'),
suggested.join(' | '),
);
await shot('04-property-suggestions');
await typeText('text align');
await sleep(150);
await press('Enter');
await sleep(300);
check(
'picking a suggestion writes its key',
(await param()) === 'align' && (await find('Property: align')) !== null,
JSON.stringify(await param()),
);
await undo();
await sleep(300);
check('undo takes it back', (await param()) === 'width', JSON.stringify(await param()));
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'pp-logic');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'pp-box' && o.id !== 'pp-brain');
__gm.state.select(null);
__gm.state.preview();
`);
await sleep(300);
const left = await json('__gm.state.project.assets.filter((a) => a.id === "pp-logic").length');
check('the step leaves the fixture as it found it', left === 0, String(left));
}