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
@@ -49,6 +49,7 @@ const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } =>
|
||||
};
|
||||
|
||||
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
|
||||
const painted = fakeHandle({ width: 100, text: '' });
|
||||
|
||||
const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({
|
||||
pointer: { ...NO_POINTER, ...overrides },
|
||||
@@ -221,6 +222,29 @@ const CASES: Record<string, Case[]> = {
|
||||
},
|
||||
{ name: 'no object is off', inputs: { object: null }, outputs: { result: false, own: false } },
|
||||
],
|
||||
GetProperties: [
|
||||
{
|
||||
name: 'reads one output per property it names',
|
||||
ports: [
|
||||
{ key: 'width', kind: 'number' },
|
||||
{ key: 'speed', kind: 'number' },
|
||||
],
|
||||
inputs: { object: fakeHandle({ width: 42, speed: 3 }) },
|
||||
outputs: { width: 42, speed: 3 },
|
||||
},
|
||||
{
|
||||
name: 'an absent property reads as null',
|
||||
ports: [{ key: 'radius', kind: 'number' }],
|
||||
inputs: { object: fakeHandle({}) },
|
||||
outputs: { radius: null },
|
||||
},
|
||||
{
|
||||
name: 'no object reads null for every port',
|
||||
ports: [{ key: 'width', kind: 'number' }],
|
||||
inputs: { object: null },
|
||||
outputs: { width: null },
|
||||
},
|
||||
],
|
||||
GetProperty: [
|
||||
{
|
||||
name: 'reads a property',
|
||||
@@ -617,6 +641,24 @@ const CASES: Record<string, Case[]> = {
|
||||
outputs: { blockedX: false, blockedY: false },
|
||||
},
|
||||
],
|
||||
SetProperties: [
|
||||
{
|
||||
name: 'writes every property it names, each keeping the type it already had',
|
||||
ports: [
|
||||
{ key: 'width', kind: 'number' },
|
||||
{ key: 'text', kind: 'text' },
|
||||
],
|
||||
inputs: { object: painted, width: '250', text: 7 },
|
||||
next: 'exec',
|
||||
after: () => expect(painted.props).toEqual({ width: 250, text: '7' }),
|
||||
},
|
||||
{
|
||||
name: 'no object is a no-op',
|
||||
ports: [{ key: 'width', kind: 'number' }],
|
||||
inputs: { object: null, width: 5 },
|
||||
next: 'exec',
|
||||
},
|
||||
],
|
||||
SetPosition: [
|
||||
{
|
||||
name: 'places the object and continues',
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Get/Set properties: the plural of Get/Set property, built like Get/Set variables. A port naming a
|
||||
* built-in property carries that property – its default and its field – while a custom name is a
|
||||
* plain port for a variable of another behavior's object.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import { AddPortCommand, RenamePortCommand } from '../../src/core/commands/portCommands';
|
||||
import { RenameVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import {
|
||||
GET_PROPERTIES,
|
||||
GraphInterpreter,
|
||||
inlineInputDef,
|
||||
propertyPort,
|
||||
requireNodeDef,
|
||||
resolveDef,
|
||||
SET_PROPERTIES,
|
||||
} from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { OBJECT_PROPERTIES, OBJECT_PROPERTY_CHOICES } from '../../src/core/properties/PropertySchema';
|
||||
import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
|
||||
|
||||
/** Self's `width` and `color` are set with nothing wired into them, then read back into `seen`. */
|
||||
function typedIn(params: Record<string, number> = {}) {
|
||||
const fixture = makeProject();
|
||||
const behavior = makeBehavior(fixture, 'B', ['seen']);
|
||||
const edit = graphEditor(fixture, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const self = edit.addNode('Self');
|
||||
const set = edit.addNode(SET_PROPERTIES, params);
|
||||
const get = edit.addNode(GET_PROPERTIES);
|
||||
const store = edit.addNode('SetVariable', { variable: 'seen' });
|
||||
for (const key of ['width', 'color'])
|
||||
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key, kind: 'number' }));
|
||||
fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'width', kind: 'number' }));
|
||||
edit.wire([
|
||||
[start, 'exec', set, 'exec'],
|
||||
[self, 'object', set, 'object'],
|
||||
[set, 'exec', store, 'exec'],
|
||||
[self, 'object', get, 'object'],
|
||||
[get, 'width', store, 'value'],
|
||||
]);
|
||||
return { fixture, behavior, set, get };
|
||||
}
|
||||
|
||||
function run(behavior: BehaviorAsset, fixture: ReturnType<typeof makeProject>) {
|
||||
const self = { ...fakeEntity('self'), props: { width: 10, color: 0 } };
|
||||
const ctx = fakeContext({ self, variables: { seen: 0 } });
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||||
return { props: self.props, seen: ctx.variables.seen };
|
||||
}
|
||||
|
||||
describe('a node that reads or writes several properties at once', () => {
|
||||
it("writes an unconnected input as the property's own default, not as a bare 0", () => {
|
||||
const { fixture, behavior } = typedIn();
|
||||
expect(run(behavior, fixture)).toEqual({ props: { width: 100, color: 0xffffff }, seen: 100 });
|
||||
});
|
||||
|
||||
it('writes the value typed on the node once there is one', () => {
|
||||
const { fixture, behavior } = typedIn({ width: 64 });
|
||||
expect(run(behavior, fixture).props.width).toBe(64);
|
||||
});
|
||||
|
||||
it('edits a typed-in value with the field of the property it names', () => {
|
||||
expect(inlineInputDef(propertyPort({ key: 'color', kind: 'number' })).kind).toBe('color');
|
||||
expect(inlineInputDef(propertyPort({ key: 'align', kind: 'text' })).kind).toBe('enum');
|
||||
expect(inlineInputDef(propertyPort({ key: 'speed', kind: 'number' })).kind).toBe('number');
|
||||
// A kind the property does not travel as (a hand-edited file) is left a plain port.
|
||||
expect(propertyPort({ key: 'width', kind: 'text' }).field).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the fixed object input its own, so no declared port can take its key', () => {
|
||||
const { fixture, behavior, set, get } = typedIn();
|
||||
const add = (nodeId: string) =>
|
||||
new AddPortCommand(behavior.id, nodeId, { key: 'object', kind: 'number' });
|
||||
expect(() => fixture.stack.execute(add(set.id))).toThrow(CommandError);
|
||||
expect(() =>
|
||||
fixture.stack.execute(new RenamePortCommand(behavior.id, set.id, 'width', 'object')),
|
||||
).toThrow(CommandError);
|
||||
// Get properties has no *output* called object, so there the name is free.
|
||||
fixture.stack.execute(add(get.id));
|
||||
const keys = resolveDef(set, behavior.graph, () => null)!.inputs.map((p) => p.key);
|
||||
expect(keys).toEqual(['exec', 'object', 'width', 'color']);
|
||||
});
|
||||
|
||||
it('drops a port from a file that would shadow the object input', () => {
|
||||
const { fixture, behavior, set } = typedIn();
|
||||
set.ports = [...(set.ports ?? []), { key: 'object', kind: 'number' }];
|
||||
const loaded = roundTrip(fixture.project);
|
||||
const asset = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset;
|
||||
const node = asset.graph.nodes.find((n) => n.id === set.id)!;
|
||||
expect((node.ports ?? []).map((p) => p.key)).toEqual(['width', 'color']);
|
||||
});
|
||||
|
||||
it('does not follow a variable that happens to share a property name', () => {
|
||||
const fixture = makeProject();
|
||||
const behavior = makeBehavior(fixture, 'B', ['width']);
|
||||
const set = graphEditor(fixture, behavior).addNode(SET_PROPERTIES);
|
||||
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key: 'width', kind: 'number' }));
|
||||
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'width', 'w'));
|
||||
expect((set.ports ?? []).map((p) => p.key)).toEqual(['width']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the properties on offer', () => {
|
||||
it('lists each key once, with the def of a sprite where types share it', () => {
|
||||
const keys = OBJECT_PROPERTIES.map((d) => d.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(OBJECT_PROPERTIES.find((d) => d.key === 'width')).toMatchObject({ label: 'Width', min: 1 });
|
||||
expect(keys).toEqual(expect.arrayContaining(['enabled', 'color', 'texture', 'text', 'zoom', 'radius']));
|
||||
// The asset's own switches and a scene's background are not properties of a placed object.
|
||||
expect(keys).not.toContain('runInEditor');
|
||||
expect(keys).not.toContain('backgroundColor');
|
||||
});
|
||||
|
||||
it('names each entry with the type it belongs to, a shared key once per type', () => {
|
||||
const labels = OBJECT_PROPERTY_CHOICES.map((c) => c.label);
|
||||
expect(labels).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Object enabled',
|
||||
'Sprite width',
|
||||
'Collider width',
|
||||
'Camera width',
|
||||
'Text align',
|
||||
]),
|
||||
);
|
||||
expect(new Set(labels).size).toBe(labels.length);
|
||||
expect(OBJECT_PROPERTY_CHOICES.find((c) => c.label === 'Camera width')?.def.key).toBe('width');
|
||||
});
|
||||
|
||||
it('offers the same entries as suggestions for the property a single Get/Set property names', () => {
|
||||
for (const type of ['GetProperty', 'SetProperty']) {
|
||||
const param = requireNodeDef(type).params.find((p) => p.key === 'property');
|
||||
expect(param?.kind === 'text' && param.suggestions).toContainEqual({
|
||||
value: 'width',
|
||||
label: 'Camera width',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* it names when that is renamed or swapped, and take its edges along.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddPortCommand, SetVariablePortCommand } from '../../src/core/commands/portCommands';
|
||||
import { AddPortCommand, RepointPortCommand } from '../../src/core/commands/portCommands';
|
||||
import {
|
||||
AddStructFieldCommand,
|
||||
AddVariableCommand,
|
||||
@@ -90,7 +90,7 @@ describe('a node that reads or writes several variables at once', () => {
|
||||
const { fixture, behavior, get } = bothWays();
|
||||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
|
||||
fixture.stack.execute(
|
||||
new SetVariablePortCommand(behavior.id, get.id, 'px', { key: 'alive', kind: 'boolean' }),
|
||||
new RepointPortCommand(behavior.id, get.id, 'px', { key: 'alive', kind: 'boolean' }),
|
||||
);
|
||||
expect(portKeys(behavior, get, 'outputs')).toEqual(['alive:boolean', 'py:number']);
|
||||
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'alive')).toBe(true);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { backTo, descend, follow } from '../../src/editor/graph/trail';
|
||||
|
||||
const alive = () => true;
|
||||
|
||||
describe('breadcrumb trail', () => {
|
||||
it('starts at the graph the call was opened from', () => {
|
||||
expect(descend([], 'game', 'move')).toEqual(['game', 'move']);
|
||||
});
|
||||
|
||||
it('grows by one per call opened further down', () => {
|
||||
expect(descend(['game', 'move'], 'move', 'other')).toEqual(['game', 'move', 'other']);
|
||||
});
|
||||
|
||||
it('starts over when the call sits in a graph the trail does not end at', () => {
|
||||
expect(descend(['game', 'move'], 'jump', 'other')).toEqual(['jump', 'other']);
|
||||
});
|
||||
|
||||
it('steps back instead of repeating a behavior already on the trail', () => {
|
||||
expect(descend(['game', 'move', 'other'], 'other', 'move')).toEqual(['game', 'move']);
|
||||
expect(descend(['game', 'move'], 'move', 'game')).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves the trail alone when a behavior calls itself', () => {
|
||||
expect(descend(['game', 'move'], 'move', 'move')).toEqual(['game', 'move']);
|
||||
});
|
||||
|
||||
it('cuts back to the crumb clicked, and disappears at the root', () => {
|
||||
const trail = ['game', 'move', 'other'];
|
||||
expect(backTo(trail, 1)).toEqual(['game', 'move']);
|
||||
expect(backTo(trail, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives only while it still leads to the graph shown', () => {
|
||||
const trail = ['game', 'move'];
|
||||
expect(follow(trail, 'move', alive)).toEqual(trail);
|
||||
expect(follow(trail, 'game', alive)).toEqual([]);
|
||||
expect(follow(trail, '', alive)).toEqual([]);
|
||||
});
|
||||
|
||||
it('is dropped when a behavior along it was deleted', () => {
|
||||
expect(follow(['game', 'move', 'other'], 'other', (id) => id !== 'move')).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user