Replace the property nodes with one Properties node
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>
This commit is contained in:
1 parent
7c63c884ef
commit
58f19a3451
36 files changed
+1040
-719
No files matched your search
@@ -251,25 +251,7 @@ export function createField(
|
||||
}
|
||||
case 'text': {
|
||||
const field = new TextField(textOf(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
||||
const setValue = (v: PropertyValue) => field.setValue(textOf(v));
|
||||
const suggestions = def.suggestions;
|
||||
if (!suggestions) return { widget: field, grow: true, setValue };
|
||||
const pick = new Dropdown('', { variant: 'ghost', width: MARK_WIDTH });
|
||||
pick.onClick = () => {
|
||||
const current = field.value.trim();
|
||||
const items = suggestions.map((s) => ({
|
||||
label: s.value === current ? `● ${s.label}` : s.label,
|
||||
onSelect: () => {
|
||||
setValue(s.value);
|
||||
handlers.onChange(s.value);
|
||||
},
|
||||
}));
|
||||
new Menu(items, { searchable: true }).openBelow(pick);
|
||||
};
|
||||
const combo = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
combo.add(field, 1);
|
||||
combo.add(pick);
|
||||
return { widget: combo, grow: true, setValue };
|
||||
return { widget: field, grow: true, setValue: (v) => field.setValue(textOf(v)) };
|
||||
}
|
||||
case 'color': {
|
||||
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
||||
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
type GraphNode,
|
||||
inlineInputDef,
|
||||
inlineInputs,
|
||||
OBJECT_BEHAVIOR_PARAM,
|
||||
OBJECT_TYPE_PARAM,
|
||||
portSignature,
|
||||
resolveDef,
|
||||
} from '../../core/graph';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Label } from '../../ui';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
@@ -33,9 +36,11 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
|
||||
.map((p) => p.key)
|
||||
: [];
|
||||
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}`).join(',');
|
||||
// They decide which rows the inspector builds, and two behaviors may share a name in the title.
|
||||
const target = `${textOf(node.params[OBJECT_TYPE_PARAM] ?? '')}:${textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '')}`;
|
||||
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${asset.variables
|
||||
.map((v) => v.key)
|
||||
.join(',')}`;
|
||||
.join(',')}|${target}`;
|
||||
}
|
||||
|
||||
/** Description, settings (params) and the inline values of unconnected data inputs. */
|
||||
@@ -51,12 +56,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
addNote(host, def.description);
|
||||
const problem = callProblem(node, behaviorLookup(host.state.project), assetId);
|
||||
if (problem) addNote(host, problem, 'warning');
|
||||
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, asset.variables);
|
||||
else if (declaresPropertyPorts(node.type)) buildPropertyPortSection(host, assetId, node);
|
||||
else if (def.declaresPorts) buildPortSection(host, assetId, node, asset.variables);
|
||||
|
||||
if (def.params.length > 0) addTitle(host, 'SETTINGS');
|
||||
for (const param of def.params) {
|
||||
const addSetting = (param: PropertyDef) => {
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
param,
|
||||
@@ -65,6 +65,15 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
);
|
||||
host.fields.set(`param:${param.key}`, row);
|
||||
host.body.add(row.row);
|
||||
};
|
||||
const ownSettings = declaresPropertyPorts(node.type);
|
||||
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, asset.variables);
|
||||
else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting);
|
||||
else if (def.declaresPorts) buildPortSection(host, assetId, node, asset.variables);
|
||||
|
||||
if (!ownSettings && def.params.length > 0) {
|
||||
addTitle(host, 'SETTINGS');
|
||||
for (const param of def.params) addSetting(param);
|
||||
}
|
||||
|
||||
const free = inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length === 0);
|
||||
|
||||
@@ -14,16 +14,18 @@ import {
|
||||
type GraphNode,
|
||||
isTerminalNode,
|
||||
missingFieldPorts,
|
||||
objectPropertyDef,
|
||||
OBJECT_BEHAVIOR_PARAM,
|
||||
OBJECT_BEHAVIOR_SETTING,
|
||||
OBJECT_TYPE_SETTING,
|
||||
portKindOf,
|
||||
propertyPortOf,
|
||||
propertyTargetOf,
|
||||
structShapeOf,
|
||||
targetLabel,
|
||||
targetProperties,
|
||||
} from '../../core/graph';
|
||||
import {
|
||||
OBJECT_PROPERTY_CHOICES,
|
||||
type PropertyDef,
|
||||
VARIABLE_KINDS,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import { type PropertyDef, schemaForAsset, VARIABLE_KINDS } from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
@@ -50,25 +52,54 @@ export function buildVariablePortSection(
|
||||
node: GraphNode,
|
||||
variables: readonly PropertyDef[],
|
||||
): void {
|
||||
const choices = variables.map((def) => ({ label: def.key, def }));
|
||||
buildPickedPortSection(host, assetId, node, { noun: 'Variable', choices });
|
||||
buildPickedPortSection(host, assetId, node, {
|
||||
noun: 'Variable',
|
||||
choices: variables,
|
||||
owner: 'this behavior',
|
||||
empty: 'This behavior has no variables yet.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The ports of a Get/Set properties node: a built-in property is picked like a variable, and a name
|
||||
* the list does not know (a variable of another behavior's object) is typed, with a kind of its own.
|
||||
*/
|
||||
export function buildPropertyPortSection(host: InspectorHost, assetId: string, node: GraphNode): void {
|
||||
/** The type decides what the ports are picked from; Any names them by hand, with a kind picked. */
|
||||
export function buildPropertyPortSection(
|
||||
host: InspectorHost,
|
||||
assetId: string,
|
||||
node: GraphNode,
|
||||
addSetting: (def: PropertyDef) => void,
|
||||
): void {
|
||||
const target = propertyTargetOf(node.params);
|
||||
addSetting(OBJECT_TYPE_SETTING);
|
||||
if (target === 'any') {
|
||||
buildPickedPortSection(host, assetId, node, { noun: 'Property', choices: [], owner: '', custom: true });
|
||||
return;
|
||||
}
|
||||
if (target !== 'behavior') {
|
||||
const owner = `a ${targetLabel(target)}`;
|
||||
buildPickedPortSection(host, assetId, node, {
|
||||
noun: 'Property',
|
||||
choices: targetProperties(target),
|
||||
owner,
|
||||
});
|
||||
return;
|
||||
}
|
||||
addSetting(OBJECT_BEHAVIOR_SETTING);
|
||||
const id = textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '');
|
||||
const behavior = id ? findAssetOf(host.state.project, id, 'behavior') : undefined;
|
||||
buildPickedPortSection(host, assetId, node, {
|
||||
noun: 'Property',
|
||||
choices: OBJECT_PROPERTY_CHOICES,
|
||||
custom: true,
|
||||
choices: behavior ? schemaForAsset(behavior) : [],
|
||||
owner: behavior ? `"${behavior.name}"` : 'a behavior',
|
||||
empty: id ? 'That behavior is gone.' : 'Pick a behavior first.',
|
||||
});
|
||||
}
|
||||
|
||||
interface PickedPorts {
|
||||
noun: 'Variable' | 'Property';
|
||||
choices: readonly { label: string; def: PropertyDef }[];
|
||||
choices: readonly PropertyDef[];
|
||||
/** Whose ports these are, for the warning on one it does not have. */
|
||||
owner: string;
|
||||
empty?: string;
|
||||
/** Named by hand instead of picked. */
|
||||
custom?: boolean;
|
||||
}
|
||||
|
||||
@@ -76,82 +107,67 @@ function buildPickedPortSection(
|
||||
host: InspectorHost,
|
||||
assetId: string,
|
||||
node: GraphNode,
|
||||
{ noun, choices, custom = false }: PickedPorts,
|
||||
{ noun, choices, owner, empty, custom = false }: PickedPorts,
|
||||
): void {
|
||||
const plural = noun === 'Property' ? 'properties' : 'variables';
|
||||
const ports = node.ports ?? [];
|
||||
const taken = new Set(ports.map((p) => p.key));
|
||||
const portOf = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) });
|
||||
const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] =>
|
||||
choices.map(({ label, def }) => ({
|
||||
label,
|
||||
choices.map((def) => ({
|
||||
label: def.key,
|
||||
disabled: taken.has(def.key) && def.key !== own,
|
||||
onSelect: () => onPick(portOf(def)),
|
||||
}));
|
||||
const typed = (): MenuItem[] =>
|
||||
VARIABLE_KINDS.map((kind) => ({
|
||||
label: `Custom ${KIND_LABELS[kind].toLowerCase()}`,
|
||||
label: KIND_LABELS[kind],
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }),
|
||||
),
|
||||
}));
|
||||
const searchable = noun === 'Property';
|
||||
|
||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
header.add(new Label(plural.toUpperCase(), { style: 'title' }), 1);
|
||||
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
|
||||
new Menu(custom ? [...items, ...typed()] : items, { searchable: custom }).openBelow(add);
|
||||
new Menu(custom ? typed() : items, { searchable }).openBelow(add);
|
||||
};
|
||||
add.setEnabled(custom || choices.some(({ def }) => !taken.has(def.key)));
|
||||
add.setEnabled(custom || choices.some((def) => !taken.has(def.key)));
|
||||
header.add(add);
|
||||
host.body.add(header);
|
||||
|
||||
if (choices.length === 0) addNote(host, `This behavior has no ${plural} yet.`);
|
||||
if (!custom && choices.length === 0 && empty) addNote(host, empty);
|
||||
else if (ports.length === 0) addNote(host, `No ${plural} picked yet. Each one adds a port.`);
|
||||
|
||||
for (const port of ports) {
|
||||
const known = choices.some(({ def }) => def.key === port.key);
|
||||
if (!known && custom) {
|
||||
portRow(host, assetId, node, port, customName(host, assetId, node, port));
|
||||
const toggle = writeToggle(host, assetId, node, port);
|
||||
if (custom) {
|
||||
const name = new TextField(port.key, {
|
||||
width: NAME_WIDTH,
|
||||
onChange: (next) =>
|
||||
host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
|
||||
});
|
||||
portRow(host, assetId, node, port, name, toggle);
|
||||
continue;
|
||||
}
|
||||
const def = choices.find((d) => d.key === port.key);
|
||||
const known = def !== undefined && portKindOf(def) === port.kind;
|
||||
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
|
||||
choose.onClick = () => {
|
||||
const items = pick(port.key, (next) =>
|
||||
host.state.execute(new RepointPortCommand(assetId, node.id, port.key, next)),
|
||||
);
|
||||
new Menu(items, { searchable: custom }).openBelow(choose);
|
||||
new Menu(items, { searchable }).openBelow(choose);
|
||||
};
|
||||
portRow(
|
||||
host,
|
||||
assetId,
|
||||
node,
|
||||
port,
|
||||
choose,
|
||||
noun === 'Variable' ? writeToggle(host, assetId, node, port) : [],
|
||||
);
|
||||
if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning');
|
||||
portRow(host, assetId, node, port, choose, toggle);
|
||||
if (!known) addNote(host, `"${port.key}" is not a ${noun.toLowerCase()} of ${owner}.`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
/** Typing a built-in property's name turns the port into that property, kind included. */
|
||||
function customName(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
|
||||
return new TextField(port.key, {
|
||||
width: NAME_WIDTH,
|
||||
onChange: (text) => {
|
||||
const next = text.trim();
|
||||
const builtIn = objectPropertyDef(next);
|
||||
host.state.execute(
|
||||
builtIn
|
||||
? new RepointPortCommand(assetId, node.id, port.key, propertyPortOf(builtIn))
|
||||
: new RenamePortCommand(assetId, node.id, port.key, next),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The ports a node declares for itself: the signature of a behavior seen as a node, or the fields
|
||||
* of a struct. A start node's are named from the caller's side – what it hands into the graph is
|
||||
|
||||
Reference in new issue
Block a user