Get/Set property, Get/Set properties, Set enabled and Set text are gone. A single Properties node takes a fixed "object" input; every chosen property is an output that reads the current value, and one switched to Set is also an input written when the flow arrives. Exec pins only exist while at least one entry writes. The node carries a type (Sprite, Text, Collider, Camera, Container, Behavior, Any) that decides what "+ Property" offers: the fields of that type, the variables of the chosen behavior, or for Any a kind to pick and a name to type. Each port takes the default and field of its type, so a Camera width starts at 640. Switching the type keeps the ports and marks those the new type lacks. At runtime the type is only an editor aid; the node writes to whatever object it gets. Old project files are converted on load. The type is inferred from the keys: the first object type that has them all, else the one behavior that declares them all, else Any. enabled belongs to every object, so a converted Set enabled lands on Any. The jump-run and tetris seeders use Properties throughout; draw-piece switches its cell on in the same node that sets the colour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
9.4 KiB
JavaScript
241 lines
9.4 KiB
JavaScript
import { edge, node, propertiesOf, variable, written } from '../lib/builders.mjs';
|
||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Properties: properties of the object handed in, each read at any time and written where it is
|
||
* switched to Set. Its type decides what `+ Property` offers. The step brings its own box and
|
||
* behavior and removes them again.
|
||
*/
|
||
|
||
/** `On update → Properties{width 40, color red} on the owner`, whose width is then read into `seen`. */
|
||
const logicGraph = {
|
||
nodes: [
|
||
node('pp-update', 'OnUpdate', 0, 0),
|
||
node('pp-owner', 'Owner', 0, 240),
|
||
node('pp-set', 'Properties', 260, 0, { ...propertiesOf('sprite'), width: 40, color: 0xff0000 }, [
|
||
written('width', 'number'),
|
||
written('color', 'number'),
|
||
]),
|
||
node('pp-store', 'Variables', 560, 0, {}, [written('seen', 'number')]),
|
||
node('pp-any', 'Properties', 260, 420),
|
||
node('pp-beh', 'Properties', 560, 420, propertiesOf('behavior')),
|
||
],
|
||
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-set', 'width', 'pp-store', 'seen'),
|
||
],
|
||
};
|
||
|
||
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}${p.write ? ':set' : ''}`,
|
||
);
|
||
const title = (nodeId) => json(`__gm.graph.nodeLabel(${JSON.stringify(nodeId)})`);
|
||
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 offered = async () => {
|
||
await clickLast('+ Property');
|
||
const texts = await json('__gm.overlayTexts()');
|
||
await press('Escape');
|
||
await sleep(200);
|
||
return texts;
|
||
};
|
||
const addProperty = async (search) => {
|
||
await clickLast('+ Property');
|
||
await typeText(search);
|
||
await sleep(150);
|
||
await press('Enter');
|
||
await sleep(300);
|
||
};
|
||
const choose = async (setting, option) => {
|
||
const field = await json(`__gm.inspectorField(${JSON.stringify(setting)}, true)`);
|
||
await click(field.x, field.y);
|
||
await sleep(200);
|
||
await clickLast(option);
|
||
await sleep(200);
|
||
};
|
||
/** 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, and its own output reads the width back after.
|
||
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 and reads them back',
|
||
box?.width === 40 && box?.color === 0xff0000 && seen === 40,
|
||
JSON.stringify({ width: box?.width, color: box?.color, seen }),
|
||
);
|
||
|
||
// 24c. Named after its type; the type is chosen above the list it decides.
|
||
await select('pp-set');
|
||
const typeRow = await json(`__gm.inspectorField('Type', true)`);
|
||
const header = await locate('PROPERTIES', true);
|
||
check(
|
||
'the node is named after its type, chosen above its properties',
|
||
(await title('pp-set')) === 'Sprite properties' && typeRow && typeRow.y < header.y,
|
||
JSON.stringify({ title: await title('pp-set'), type: typeRow?.y, header: header.y }),
|
||
);
|
||
check(
|
||
'the node shows a typed-in color as a color',
|
||
(await find('color #ff0000')) !== null,
|
||
JSON.stringify(await json(`__gm.graph.nodeInputs('pp-set')`)),
|
||
);
|
||
await shot('01-sprite-properties');
|
||
|
||
// 24d. A Sprite offers the fields of a sprite, and nothing of the other types.
|
||
const sprite = await offered();
|
||
check(
|
||
"a Sprite offers a sprite's properties",
|
||
['enabled', 'width', 'height', 'color', 'texture'].every((k) => sprite.includes(k)) &&
|
||
!sprite.includes('radius') &&
|
||
!sprite.includes('zoom'),
|
||
sprite.join(' | '),
|
||
);
|
||
|
||
// 24e. Picked, then switched to Set: its typed-in value starts at the property's own 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 an output',
|
||
grown.join(',') === 'width:number:set,color:number:set,height:number',
|
||
grown.join(','),
|
||
);
|
||
const toggle = await json(`__gm.inspectorField('Set', true)`);
|
||
await click(toggle.x, toggle.y);
|
||
await sleep(300);
|
||
const inputs = await json(`__gm.graph.nodeInputs('pp-set')`);
|
||
check(
|
||
"switched to Set, its typed-in value starts at the property's own default",
|
||
inputs.includes('height 100'),
|
||
JSON.stringify(inputs),
|
||
);
|
||
await shot('02-height-set');
|
||
await undo();
|
||
await undo();
|
||
await sleep(300);
|
||
check('undo takes both back', (await portsOf('pp-set')).length === 2, (await portsOf('pp-set')).join());
|
||
|
||
// 24f. Another type offers its own fields; a port it does not have is marked, not dropped.
|
||
await choose('Type', 'Camera');
|
||
const camera = await offered();
|
||
check(
|
||
'switching to Camera renames the node and offers the fields of a camera',
|
||
(await title('pp-set')) === 'Camera properties' && camera.includes('zoom') && !camera.includes('texture'),
|
||
JSON.stringify({ title: await title('pp-set'), offered: camera }),
|
||
);
|
||
check(
|
||
'a port the new type does not have stays, marked',
|
||
(await portsOf('pp-set')).length === 2 && (await find('"color" is not a property of a Camera.')) !== null,
|
||
(await portsOf('pp-set')).join(),
|
||
);
|
||
await shot('03-camera');
|
||
await undo();
|
||
await sleep(300);
|
||
check('undo switches back', (await title('pp-set')) === 'Sprite properties', await title('pp-set'));
|
||
|
||
// 24g. Any names its ports by hand, and the kind is picked when adding one. A name that happens
|
||
// to be a property's stays of the kind picked.
|
||
await select('pp-any');
|
||
const kinds = await offered();
|
||
check(
|
||
'Any offers the kinds to pick from, not property names',
|
||
['Number', 'Text', 'Boolean', 'List', 'Struct'].every((k) => kinds.includes(k)) &&
|
||
!kinds.includes('width'),
|
||
kinds.join(' | '),
|
||
);
|
||
await addProperty('text');
|
||
check('a port is added of the kind picked', (await portsOf('pp-any'))[0] === 'property1:text');
|
||
await rename('property1', 'width');
|
||
check(
|
||
'and renamed by typing, keeping its kind',
|
||
(await portsOf('pp-any'))[0] === 'width:text',
|
||
(await portsOf('pp-any')).join(),
|
||
);
|
||
await evaluate("__gm.state.notice = ''");
|
||
await rename('width', 'object');
|
||
const notice = await json('__gm.state.notice');
|
||
check(
|
||
'the object input keeps its key: a port cannot take it',
|
||
(await portsOf('pp-any'))[0] === 'width:text' && notice.includes('"object" is taken'),
|
||
JSON.stringify({ ports: await portsOf('pp-any'), notice }),
|
||
);
|
||
await shot('04-any');
|
||
|
||
// 24h. A Behavior offers the variables of the behavior picked.
|
||
await select('pp-beh');
|
||
const before = await offered();
|
||
check(
|
||
'a Behavior offers nothing until one is picked',
|
||
before.length === 0 && (await find('Pick a behavior first.')) !== null,
|
||
JSON.stringify(before),
|
||
);
|
||
await choose('Behavior', 'Reshape (behavior)');
|
||
const vars = await offered();
|
||
check(
|
||
'picking the behavior offers its variables and names the node after it',
|
||
vars.includes('seen') && vars.includes('enabled') && (await title('pp-beh')) === 'Reshape properties',
|
||
JSON.stringify({ title: await title('pp-beh'), offered: vars }),
|
||
);
|
||
await addProperty('seen');
|
||
check('a variable is picked as a port', (await portsOf('pp-beh')).join() === 'seen:number');
|
||
await shot('05-behavior');
|
||
|
||
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));
|
||
}
|