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>
274 lines
9.3 KiB
TypeScript
274 lines
9.3 KiB
TypeScript
import {
|
||
AddPortCommand,
|
||
AddPortsCommand,
|
||
nextPortName,
|
||
RemovePortCommand,
|
||
RenamePortCommand,
|
||
RepointPortCommand,
|
||
SetPortWriteCommand,
|
||
} from '../../core/commands/portCommands';
|
||
import {
|
||
BEHAVIOR_START,
|
||
DECLARED_PORT_KINDS,
|
||
type DeclaredPort,
|
||
type GraphNode,
|
||
isTerminalNode,
|
||
missingFieldPorts,
|
||
OBJECT_BEHAVIOR_PARAM,
|
||
OBJECT_BEHAVIOR_SETTING,
|
||
OBJECT_TYPE_SETTING,
|
||
portKindOf,
|
||
propertyTargetOf,
|
||
structShapeOf,
|
||
targetLabel,
|
||
targetProperties,
|
||
} from '../../core/graph';
|
||
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,
|
||
HStack,
|
||
Label,
|
||
Menu,
|
||
type MenuItem,
|
||
Spacer,
|
||
TextField,
|
||
Theme,
|
||
type Widget,
|
||
} from '../../ui';
|
||
import { NAME_WIDTH, removeButton } from './fieldFactory';
|
||
import { addNote, type InspectorHost } from './host';
|
||
import { KIND_LABELS, kindMark, markLabel } from './variableRows';
|
||
|
||
/**
|
||
* The ports of a Variables node: each one *is* a variable of the behavior, so it is picked from that
|
||
* list rather than named, and its kind follows the variable's declaration. Set makes it written too.
|
||
*/
|
||
export function buildVariablePortSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
variables: readonly PropertyDef[],
|
||
): void {
|
||
buildPickedPortSection(host, assetId, node, {
|
||
noun: 'Variable',
|
||
choices: variables,
|
||
owner: 'this behavior',
|
||
empty: 'This behavior has no variables yet.',
|
||
});
|
||
}
|
||
|
||
/** 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: 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 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;
|
||
}
|
||
|
||
function buildPickedPortSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
{ 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((def) => ({
|
||
label: def.key,
|
||
disabled: taken.has(def.key) && def.key !== own,
|
||
onSelect: () => onPick(portOf(def)),
|
||
}));
|
||
const typed = (): MenuItem[] =>
|
||
VARIABLE_KINDS.map((kind) => ({
|
||
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 ? typed() : items, { searchable }).openBelow(add);
|
||
};
|
||
add.setEnabled(custom || choices.some((def) => !taken.has(def.key)));
|
||
header.add(add);
|
||
host.body.add(header);
|
||
|
||
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 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 }).openBelow(choose);
|
||
};
|
||
portRow(host, assetId, node, port, choose, toggle);
|
||
if (!known) addNote(host, `"${port.key}" is not a ${noun.toLowerCase()} of ${owner}.`, 'warning');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
* an *input* of the behavior – which is why the side comes from the node type, not from here.
|
||
*/
|
||
export function buildPortSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
variables: readonly PropertyDef[] = [],
|
||
): void {
|
||
const onStart = node.type === BEHAVIOR_START;
|
||
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
|
||
const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out';
|
||
|
||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||
header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1);
|
||
if (noun === 'Field') addShapePicker(host, assetId, node, variables, header);
|
||
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||
add.onClick = () => {
|
||
const items = DECLARED_PORT_KINDS.map((kind) => ({
|
||
label: KIND_LABELS[kind],
|
||
onSelect: () =>
|
||
host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })),
|
||
}));
|
||
new Menu(items).openBelow(add);
|
||
};
|
||
header.add(add);
|
||
host.body.add(header);
|
||
|
||
const ports = node.ports ?? [];
|
||
if (ports.length === 0)
|
||
addNote(
|
||
host,
|
||
noun === 'Field'
|
||
? 'No fields yet. Every field added here becomes a port of the struct.'
|
||
: onStart
|
||
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior.'
|
||
: 'No outputs yet. Every output added here becomes a result of running this behavior.',
|
||
);
|
||
|
||
for (const port of ports) {
|
||
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);
|
||
}
|
||
}
|
||
|
||
function writeToggle(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] {
|
||
const box = new Checkbox(port.write === true);
|
||
box.onChange = (on) => host.state.execute(new SetPortWriteCommand(assetId, node.id, port.key, on));
|
||
return [new Label('Set', { style: 'muted' }), box];
|
||
}
|
||
|
||
function portRow(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
port: DeclaredPort,
|
||
name: Widget,
|
||
extras: readonly Widget[] = [],
|
||
): void {
|
||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||
row.minHeight = Theme.rowHeight;
|
||
row.add(markLabel(kindMark(port.kind)));
|
||
row.add(name, 1);
|
||
for (const extra of extras) row.add(extra);
|
||
row.add(new Spacer());
|
||
row.add(removeButton(() => host.state.execute(new RemovePortCommand(assetId, node.id, port.key))));
|
||
host.body.add(row);
|
||
}
|
||
|
||
/**
|
||
* Takes the fields of a Pack/Expand from a variable that already declares the shape, instead of
|
||
* typing them again. Only offered where there is a shape to copy; a field the node already has
|
||
* stays as it is, so this adds and never rewrites.
|
||
*/
|
||
function addShapePicker(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
variables: readonly PropertyDef[],
|
||
header: HStack,
|
||
): void {
|
||
const shaped = variables.filter((v) => structShapeOf(v) !== undefined);
|
||
if (shaped.length === 0) return;
|
||
|
||
const pick = new Dropdown('From variable', { variant: 'ghost' });
|
||
pick.onClick = () => {
|
||
const items: MenuItem[] = shaped.map((v) => {
|
||
const ports = missingFieldPorts(node, structShapeOf(v) ?? []);
|
||
return {
|
||
label: v.kind === 'list' ? `${v.key} (item)` : v.key,
|
||
disabled: ports.length === 0,
|
||
onSelect: () => host.state.execute(new AddPortsCommand(assetId, node.id, ports)),
|
||
};
|
||
});
|
||
new Menu(items).openBelow(pick);
|
||
};
|
||
header.add(pick);
|
||
}
|