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>
This commit is contained in:
1 parent
e9fa592078
commit
854ae2e756
35 files changed
+1044
-101
No files matched your search
@@ -305,6 +305,7 @@ export async function graph(t) {
|
||||
Boolean(await evaluate(`__gm.locate(${JSON.stringify(readout)})`)),
|
||||
readout,
|
||||
);
|
||||
check('the header has no Add node button – the right-click palette is the way', !(await find('Add node…')));
|
||||
await select(null);
|
||||
await sleep(100);
|
||||
check(
|
||||
|
||||
@@ -266,9 +266,16 @@ export async function nestedBehavior(t) {
|
||||
`nodes ${beforeSelf.length} → ${afterSelf.length}, notice=${JSON.stringify(notice)} row=${JSON.stringify(selfRow)} dragging=${JSON.stringify(dragging)}`,
|
||||
);
|
||||
|
||||
// 19j. Double-clicking a call opens the behavior it runs.
|
||||
// 19j. Double-clicking a call opens the behavior it runs. Double calls Empty in turn, so the
|
||||
// breadcrumb below gets a third level. The call sits far down: only a graph tall enough to
|
||||
// fill the canvas shows whether it was fitted to the canvas before or after the bar.
|
||||
await mutate(`
|
||||
const double = __gm.state.project.assets.find((a) => a.id === 'nb-double');
|
||||
double.graph.nodes.push(${JSON.stringify(node('nb-call2', 'RunBehavior', 240, 900, { behavior: 'nb-empty' }))});
|
||||
`);
|
||||
const callNode = await json(`__gm.graph.nodeAt('nb-call')`);
|
||||
check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller');
|
||||
check('no breadcrumb on a graph opened from the outside', (await json('__gm.graph.crumbs()')).length === 0);
|
||||
await t.doubleClick(callNode.x, callNode.y);
|
||||
await sleep(400);
|
||||
const opened = await evaluate('__gm.graph.current()');
|
||||
@@ -278,7 +285,98 @@ export async function nestedBehavior(t) {
|
||||
`current=${JSON.stringify(opened)}`,
|
||||
);
|
||||
|
||||
// 19k. Clean up: the three behaviors and the box are gone again.
|
||||
// 19k. The breadcrumb: every behavior entered by double-click, each earlier one a way back.
|
||||
const crumbs = () => json('__gm.graph.crumbs()');
|
||||
const trail = async () => (await crumbs()).map((c) => `${c.name}${c.clickable ? '' : '*'}`).join(' › ');
|
||||
check('opening a call shows the way back', (await trail()) === 'Caller › Double*', await trail());
|
||||
const cut = await json(`(() => {
|
||||
const c = __gm.graph.canvasRect();
|
||||
return __gm.state.project.assets.find((a) => a.id === 'nb-double').graph.nodes
|
||||
.map((n) => ({ id: n.id, r: __gm.graph.nodeRect(n.id) }))
|
||||
.filter(({ r }) => r.y < c.y || r.y + r.h > c.y + c.h)
|
||||
.map(({ id }) => id);
|
||||
})()`);
|
||||
check('the graph opened is fitted below the bar, not under it', cut.length === 0, `cut off: ${cut}`);
|
||||
const inner = await json(`__gm.graph.nodeAt('nb-call2')`);
|
||||
await click(inner.x, inner.y);
|
||||
// Past the double-click window: the pair below must count as its own two presses.
|
||||
await sleep(500);
|
||||
check(
|
||||
'selecting a node inside keeps the breadcrumb',
|
||||
(await trail()) === 'Caller › Double*',
|
||||
await trail(),
|
||||
);
|
||||
await t.doubleClick(inner.x, inner.y);
|
||||
await sleep(400);
|
||||
check(
|
||||
'each call opened further down adds a crumb',
|
||||
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await trail()) === 'Caller › Double › Empty*',
|
||||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||||
);
|
||||
// A width of its own too: crumbs never laid out are all 0 wide at one spot, and overlap nothing.
|
||||
const overlaps = (await crumbs()).filter(
|
||||
(c, i, all) => c.right - c.left < 10 || (i > 0 && c.left < all[i - 1].right),
|
||||
);
|
||||
check(
|
||||
'the crumbs sit side by side, not on top of each other',
|
||||
overlaps.length === 0,
|
||||
JSON.stringify((await crumbs()).map((c) => [c.name, Math.round(c.left), Math.round(c.right)])),
|
||||
);
|
||||
await mutate(`__gm.state.project.assets.find((a) => a.id === 'nb-double').name = 'Twice'`);
|
||||
check('a renamed behavior renames its crumb', (await trail()) === 'Caller › Twice › Empty*', await trail());
|
||||
await mutate(`__gm.state.project.assets.find((a) => a.id === 'nb-double').name = 'Double'`);
|
||||
await shot('39b-breadcrumb');
|
||||
|
||||
const clickCrumb = async (name) => {
|
||||
const crumb = (await crumbs()).find((c) => c.name === name);
|
||||
await click(crumb.x, crumb.y);
|
||||
await sleep(300);
|
||||
};
|
||||
await clickCrumb('Double');
|
||||
await shot('39c-breadcrumb-back');
|
||||
check(
|
||||
'a crumb leads back, and cuts the trail there',
|
||||
(await evaluate('__gm.graph.current()')) === 'nb-double' && (await trail()) === 'Caller › Double*',
|
||||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||||
);
|
||||
await clickCrumb('Caller');
|
||||
check(
|
||||
'back at the root, the breadcrumb is gone',
|
||||
(await evaluate('__gm.graph.current()')) === 'nb-caller' && (await crumbs()).length === 0,
|
||||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||||
);
|
||||
|
||||
// Opening another behavior any other way ends the trail: the asset panel, then the picker.
|
||||
const enterDouble = async () => {
|
||||
const call = await json(`__gm.graph.nodeAt('nb-call')`);
|
||||
await t.doubleClick(call.x, call.y);
|
||||
await sleep(400);
|
||||
};
|
||||
await enterDouble();
|
||||
const emptyAgain = await json(`__gm.assetRow('nb-empty')`);
|
||||
await click(emptyAgain.x, emptyAgain.y);
|
||||
await sleep(300);
|
||||
check(
|
||||
'choosing another behavior in the asset panel ends the trail',
|
||||
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0,
|
||||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||||
);
|
||||
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'nb-caller' })`);
|
||||
await sleep(200);
|
||||
await enterDouble();
|
||||
const picker = await json('__gm.graph.pickerAt()');
|
||||
await click(picker.x, picker.y);
|
||||
await sleep(150);
|
||||
const emptyItem = await locate('Empty', true);
|
||||
await click(emptyItem.x, emptyItem.y);
|
||||
await sleep(300);
|
||||
check(
|
||||
'choosing another behavior in the picker ends the trail',
|
||||
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0,
|
||||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||||
);
|
||||
|
||||
// 19l. Clean up: the three behaviors and the box are gone again.
|
||||
await evaluate(`__gm.state.select(null)`);
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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));
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { keyRepeat } from './20-key-repeat.mjs';
|
||||
import { listTab } from './21-list.mjs';
|
||||
import { structVariable } from './22-struct.mjs';
|
||||
import { variableNodes } from './23-variable-nodes.mjs';
|
||||
import { propertyNodes } from './24-property-nodes.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -51,4 +52,5 @@ export const STEPS = [
|
||||
listTab,
|
||||
structVariable,
|
||||
variableNodes,
|
||||
propertyNodes,
|
||||
];
|
||||
Reference in new issue
Block a user