diff --git a/README.md b/README.md
index 6b07f255..8576e7b9 100644
--- a/README.md
+++ b/README.md
@@ -12,8 +12,8 @@ Live: · Widget playground: p.key === from)) return;
+ if (from === to || !ports.some((p) => p.key === from) || ports.some((p) => p.key === to)) return;
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
for (const site of ownSites(graph, node)) carryPort(site, from, to);
}
@@ -339,11 +342,25 @@ export class RepointPortCommand implements Command {
}
/**
- * Follows a renamed variable into the nodes that name it as a port. Their edges and typed-in
+ * Follows a renamed variable into the nodes that name it as a port: the Variables nodes of its own
+ * behavior, and Properties nodes anywhere that point at that behavior. Their edges and typed-in
* values move with it – the port key *is* the variable name there.
*/
-export function renameVariablePorts(graph: Graph, from: string, to: string): void {
- for (const node of graph.nodes) if (declaresVariablePorts(node.type)) renamePortOn(graph, node, from, to);
+export function renameVariablePorts(project: Project, behaviorId: string, from: string, to: string): void {
+ for (const asset of project.assets) {
+ if (asset.type !== 'behavior') continue;
+ for (const node of asset.graph.nodes)
+ if (namesVariablesOf(node, asset.id, behaviorId)) renamePortOn(asset.graph, node, from, to);
+ }
+}
+
+function namesVariablesOf(node: GraphNode, graphOwner: string, behaviorId: string): boolean {
+ if (declaresVariablePorts(node.type)) return graphOwner === behaviorId;
+ return (
+ declaresPropertyPorts(node.type) &&
+ propertyTargetOf(node.params) === 'behavior' &&
+ node.params[OBJECT_BEHAVIOR_PARAM] === behaviorId
+ );
}
/** "in1", "in2", … – the first port name the terminal node does not use yet. */
diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts
index a37f60a5..1dd8ede2 100644
--- a/src/core/commands/variableCommands.ts
+++ b/src/core/commands/variableCommands.ts
@@ -138,7 +138,7 @@ export class RenameVariableCommand implements Command {
bag[to] = bag[from]!;
delete bag[from];
}
- renameVariablePorts(asset.graph, from, to);
+ renameVariablePorts(project, this.assetId, from, to);
}
}
diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts
index e14a9f7d..e64dd4c8 100644
--- a/src/core/graph/GraphInterpreter.ts
+++ b/src/core/graph/GraphInterpreter.ts
@@ -2,7 +2,14 @@ import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
import { type BehaviorLookup, NO_BEHAVIORS } from './lookup';
import { BEHAVIOR_END, BEHAVIOR_START, resolveDef, terminalNode } from './nested';
import type { NodeContext } from './NodeContext';
-import { type CallResult, hasExecInput, inlineValue, isEventNode, type RepeatResult } from './NodeRegistry';
+import {
+ type CallResult,
+ hasExecInput,
+ inlineValue,
+ isEventNode,
+ type PortDef,
+ type RepeatResult,
+} from './NodeRegistry';
import type { Value } from './values';
/** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */
@@ -150,15 +157,16 @@ export class GraphInterpreter {
/** Resolves every data input: connected → pulled from the source, else the inline param. */
private collectInputs(node: GraphNode, ctx: NodeContext): Record {
const inputs: Record = {};
- for (const port of this.def(node)?.inputs ?? []) {
- if (port.type === 'exec') continue;
- const edge = edgesInto(this.frame.graph, node.id, port.key)[0];
- if (edge) inputs[port.key] = this.readOutput(edge.from.node, edge.from.port, ctx);
- else inputs[port.key] = inlineValue(node, port);
- }
+ for (const port of this.def(node)?.inputs ?? [])
+ if (port.type !== 'exec') inputs[port.key] = this.inputValue(node, port, ctx);
return inputs;
}
+ private inputValue(node: GraphNode, port: PortDef, ctx: NodeContext): Value {
+ const edge = edgesInto(this.frame.graph, node.id, port.key)[0];
+ return edge ? this.readOutput(edge.from.node, edge.from.port, ctx) : inlineValue(node, port);
+ }
+
/** Value of one output port: from the pulse cache, or by evaluating a pure data node. */
private readOutput(nodeId: string, port: string, ctx: NodeContext): Value {
const cached = this.frame.outputCache.get(nodeId) ?? this.frame.dataCache.get(nodeId);
@@ -169,7 +177,11 @@ export class GraphInterpreter {
if (!def) return null;
if (def.read) {
this.trace?.onNode(nodeId);
- return def.read(ctx, source.params)[port] ?? null;
+ const input = (key: string): Value => {
+ const found = def.inputs.find((p) => p.key === key);
+ return found ? this.inputValue(source, found, ctx) : null;
+ };
+ return def.read(ctx, source.params, input)[port] ?? null;
}
// Exec nodes that have not run in this pulse have no value yet.
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts
index 21ed1ced..744c3181 100644
--- a/src/core/graph/NodeRegistry.ts
+++ b/src/core/graph/NodeRegistry.ts
@@ -80,7 +80,7 @@ export interface NodeDef {
*/
declaresPorts?: 'inputs' | 'outputs' | 'both';
/** How a declared port becomes a port; without it, named after its key and typed by its kind. */
- declaredPort?(port: DeclaredPort): PortDef;
+ declaredPort?(port: DeclaredPort, node: GraphNode): PortDef;
/**
* Ports this node computes beyond the fixed and the declared ones – how many outputs a Sequence
* offers, which values the behavior a call node names takes. `resolveDef` applies it.
@@ -88,9 +88,10 @@ export interface NodeDef {
derivePorts?(source: PortSource): DerivedShape;
/**
* Outputs readable at any moment, even while the node's exec input has not fired in this pulse.
- * Never cached, so a read after a write sees the new value.
+ * Never cached, so a read after a write sees the new value. `input` pulls one input on demand:
+ * pulling all of them would loop through an edge that feeds the node's own output back in.
*/
- read?(ctx: NodeContext, params: PropertyBag): Record;
+ read?(ctx: NodeContext, params: PropertyBag, input: (key: string) => Value): Record;
/**
* Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at
* hand, the only way a node with derived ports knows how many it has.
diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts
index 2e836eef..01016c95 100644
--- a/src/core/graph/nested.ts
+++ b/src/core/graph/nested.ts
@@ -8,8 +8,7 @@ export const BEHAVIOR_START = 'BehaviorStart';
export const BEHAVIOR_END = 'BehaviorEnd';
export const RUN_BEHAVIOR = 'RunBehavior';
export const VARIABLES = 'Variables';
-export const GET_PROPERTIES = 'GetProperties';
-export const SET_PROPERTIES = 'SetProperties';
+export const PROPERTIES = 'Properties';
export const BEHAVIOR_PARAM = 'behavior';
@@ -24,7 +23,7 @@ export function declaresVariablePorts(type: string): boolean {
/** Nodes whose declared ports are properties of the object they are handed. */
export function declaresPropertyPorts(type: string): boolean {
- return type === GET_PROPERTIES || type === SET_PROPERTIES;
+ return type === PROPERTIES;
}
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
@@ -32,12 +31,15 @@ export function isValidPortName(name: string): boolean {
return isValidFieldName(name) && name !== 'exec';
}
-/** Keys a declared port cannot take: the node's fixed ports on the side it declares (`object`). */
+/**
+ * Keys a declared port cannot take: the node's fixed ports on the side it declares (`object`), and
+ * its settings, which share the params bag with the typed-in values of its inputs.
+ */
export function fixedPortKeys(def: NodeDef): string[] {
const side = def.declaresPorts;
const fixed =
side === 'inputs' ? def.inputs : side === 'outputs' ? def.outputs : [...def.inputs, ...def.outputs];
- return fixed.map((p) => p.key);
+ return [...fixed.map((p) => p.key), ...def.params.map((p) => p.key)];
}
export function declaredOn(
@@ -81,16 +83,16 @@ export function resolveDef(node: GraphNode, graph: Graph, lookup: BehaviorLookup
if (!def || (!def.declaresPorts && !def.derivePorts)) return def;
const own = (side: 'inputs' | 'outputs') =>
declaredOn(def, node.ports ?? [], side).map((p) =>
- def.declaredPort ? def.declaredPort(p) : portDefOf(p),
+ def.declaredPort ? def.declaredPort(p, node) : portDefOf(p),
);
const grown = def.derivePorts?.({ node, graph, behaviors: lookup }) ?? {};
- // Derived before declared: a derived exec pin belongs at the top.
- return {
- ...def,
- label: grown.label ?? def.label,
- inputs: [...def.inputs, ...(grown.inputs ?? []), ...own('inputs')],
- outputs: [...def.outputs, ...(grown.outputs ?? []), ...own('outputs')],
- };
+ const side = (key: 'inputs' | 'outputs') => execFirst([...def[key], ...(grown[key] ?? []), ...own(key)]);
+ return { ...def, label: grown.label ?? def.label, inputs: side('inputs'), outputs: side('outputs') };
+}
+
+/** A derived exec pin belongs at the top, above the fixed data ports too. */
+function execFirst(ports: PortDef[]): PortDef[] {
+ return [...ports.filter((p) => p.type === 'exec'), ...ports.filter((p) => p.type !== 'exec')];
}
/**
diff --git a/src/core/graph/nodes/action/SetEnabled.ts b/src/core/graph/nodes/action/SetEnabled.ts
deleted file mode 100644
index 24a19f8a..00000000
--- a/src/core/graph/nodes/action/SetEnabled.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { NodeDef } from '../../NodeRegistry';
-import { asBoolean, asEntity } from '../../values';
-import { bool, exec, obj, withDefault } from '../shared';
-
-export const SetEnabled: NodeDef = {
- type: 'SetEnabled',
- label: 'Set enabled',
- category: 'action',
- description:
- 'Switches an object on or off. A disabled object is not drawn, does not collide and its behavior stops – ' +
- 'and so does everything parented below it. This is how screens (title, win, lose) are shown and hidden.',
- inputs: [exec(), obj('object'), withDefault(bool('enabled'), true)],
- outputs: [exec()],
- params: [],
- run: (_ctx, inputs) => {
- const target = asEntity(inputs.object);
- if (target) target.props.enabled = asBoolean(inputs.enabled);
- return { next: 'exec' };
- },
-};
diff --git a/src/core/graph/nodes/action/SetProperties.ts b/src/core/graph/nodes/action/SetProperties.ts
deleted file mode 100644
index 0f9c71da..00000000
--- a/src/core/graph/nodes/action/SetProperties.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { SET_PROPERTIES } from '../../nested';
-import type { NodeDef } from '../../NodeRegistry';
-import { propertyPort } from '../../propertyPorts';
-import { asEntity, writeTyped } from '../../values';
-import { exec, obj } from '../shared';
-
-/**
- * The plural of Set property: one input per property picked. Like Set variables it writes every
- * property it lists each time it runs – an unconnected input writes the value typed on the node.
- */
-export const SetProperties: NodeDef = {
- type: SET_PROPERTIES,
- label: 'Set properties',
- category: 'action',
- description: 'Writes several properties (width, color, …) of an object at once, one input per property.',
- inputs: [exec(), obj('object')],
- outputs: [exec()],
- params: [],
- declaresPorts: 'inputs',
- declaredPort: propertyPort,
- run: (_ctx, inputs, _params, def) => {
- const target = asEntity(inputs.object);
- if (target)
- for (const port of def.inputs.slice(SetProperties.inputs.length))
- writeTyped(target.props, port.key, inputs[port.key]);
- return { next: 'exec' };
- },
-};
diff --git a/src/core/graph/nodes/action/SetProperty.ts b/src/core/graph/nodes/action/SetProperty.ts
deleted file mode 100644
index 0947f51e..00000000
--- a/src/core/graph/nodes/action/SetProperty.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { textOf } from '../../../model/Properties';
-import type { NodeDef } from '../../NodeRegistry';
-import { asEntity, writeTyped } from '../../values';
-import { any, exec, obj, PROPERTY_PARAM } from '../shared';
-
-export const SetProperty: NodeDef = {
- type: 'SetProperty',
- label: 'Set property',
- category: 'action',
- description: 'Writes a property (width, color, …) of an object.',
- inputs: [exec(), obj('object'), any('value')],
- outputs: [exec()],
- params: [PROPERTY_PARAM],
- run: (_ctx, inputs, params) => {
- const target = asEntity(inputs.object);
- const key = textOf(params.property ?? '');
- if (target && key) writeTyped(target.props, key, inputs.value);
- return { next: 'exec' };
- },
-};
diff --git a/src/core/graph/nodes/action/SetText.ts b/src/core/graph/nodes/action/SetText.ts
deleted file mode 100644
index d305d93b..00000000
--- a/src/core/graph/nodes/action/SetText.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { NodeDef } from '../../NodeRegistry';
-import { asEntity, asString } from '../../values';
-import { any, exec, obj } from '../shared';
-
-export const SetText: NodeDef = {
- type: 'SetText',
- label: 'Set text',
- category: 'action',
- description: 'Writes the content of a text object (same as Set property "text").',
- inputs: [exec(), obj('object'), any('text')],
- outputs: [exec()],
- params: [],
- run: (_ctx, inputs) => {
- const target = asEntity(inputs.object);
- if (target) target.props.text = asString(inputs.text);
- return { next: 'exec' };
- },
-};
diff --git a/src/core/graph/nodes/data/GetProperties.ts b/src/core/graph/nodes/data/GetProperties.ts
deleted file mode 100644
index 963854c4..00000000
--- a/src/core/graph/nodes/data/GetProperties.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { GET_PROPERTIES } from '../../nested';
-import type { NodeDef } from '../../NodeRegistry';
-import { propertyPort } from '../../propertyPorts';
-import { asEntity, type Value } from '../../values';
-import { obj } from '../shared';
-
-/** The plural of Get property: one output per property picked, the way Get variables reads. */
-export const GetProperties: NodeDef = {
- type: GET_PROPERTIES,
- label: 'Get properties',
- category: 'data',
- description: 'Reads several properties (width, color, …) of an object at once, one output per property.',
- inputs: [obj('object')],
- outputs: [],
- params: [],
- declaresPorts: 'outputs',
- declaredPort: propertyPort,
- run: (_ctx, inputs, _params, def) => {
- const target = asEntity(inputs.object);
- const outputs: Record = {};
- for (const port of def.outputs) outputs[port.key] = target?.props[port.key] ?? null;
- return { outputs };
- },
-};
diff --git a/src/core/graph/nodes/data/GetProperty.ts b/src/core/graph/nodes/data/GetProperty.ts
deleted file mode 100644
index 5650e7dd..00000000
--- a/src/core/graph/nodes/data/GetProperty.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { textOf } from '../../../model/Properties';
-import type { NodeDef } from '../../NodeRegistry';
-import { asEntity } from '../../values';
-import { any, obj, PROPERTY_PARAM } from '../shared';
-
-export const GetProperty: NodeDef = {
- type: 'GetProperty',
- label: 'Get property',
- category: 'data',
- description: 'Reads a property (width, color, …) of an object.',
- inputs: [obj('object')],
- outputs: [any('value')],
- params: [PROPERTY_PARAM],
- run: (_ctx, inputs, params) => ({
- outputs: { value: asEntity(inputs.object)?.props[textOf(params.property ?? '')] ?? null },
- }),
-};
diff --git a/src/core/graph/nodes/data/Properties.ts b/src/core/graph/nodes/data/Properties.ts
new file mode 100644
index 00000000..4e38e017
--- /dev/null
+++ b/src/core/graph/nodes/data/Properties.ts
@@ -0,0 +1,38 @@
+import { PROPERTIES } from '../../nested';
+import type { NodeDef } from '../../NodeRegistry';
+import {
+ OBJECT_BEHAVIOR_SETTING,
+ OBJECT_TYPE_SETTING,
+ propertiesTitle,
+ propertyPort,
+} from '../../propertyPorts';
+import { asEntity, writeTyped } from '../../values';
+import { exec, obj } from '../shared';
+
+export const Properties: NodeDef = {
+ type: PROPERTIES,
+ label: 'Properties',
+ category: 'data',
+ description:
+ 'Reads properties (width, color, a variable, …) of the object it is handed, at any time. Those ' +
+ 'switched to Set are written together when the flow arrives.',
+ inputs: [obj('object')],
+ outputs: [],
+ params: [OBJECT_TYPE_SETTING, OBJECT_BEHAVIOR_SETTING],
+ // The title names both ("Sprite properties", "Game properties").
+ hiddenParams: () => [OBJECT_TYPE_SETTING.key, OBJECT_BEHAVIOR_SETTING.key],
+ declaresPorts: 'both',
+ declaredPort: (port, node) => propertyPort(port, node.params),
+ derivePorts: ({ node, behaviors }) => {
+ const label = propertiesTitle(node, behaviors);
+ return (node.ports ?? []).some((p) => p.write) ? { label, inputs: [exec()], outputs: [exec()] } : { label };
+ },
+ read: (_ctx, _params, input) => asEntity(input('object'))?.props ?? {},
+ run: (_ctx, inputs, _params, def) => {
+ const target = asEntity(inputs.object);
+ if (target)
+ for (const port of def.inputs)
+ if (port.type !== 'exec' && port.key !== 'object') writeTyped(target.props, port.key, inputs[port.key]);
+ return { next: 'exec' };
+ },
+};
diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts
index 40b19ef0..30fe61a0 100644
--- a/src/core/graph/nodes/index.ts
+++ b/src/core/graph/nodes/index.ts
@@ -3,11 +3,7 @@ import { Destroy } from './action/Destroy';
import { Log } from './action/Log';
import { Move } from './action/Move';
import { RestartGame } from './action/RestartGame';
-import { SetEnabled } from './action/SetEnabled';
import { SetPosition } from './action/SetPosition';
-import { SetProperties } from './action/SetProperties';
-import { SetProperty } from './action/SetProperty';
-import { SetText } from './action/SetText';
import { Spawn } from './action/Spawn';
import { StopGame } from './action/StopGame';
import { Abs } from './data/Abs';
@@ -24,8 +20,6 @@ import { Equals } from './data/Equals';
import { FindText } from './data/FindText';
import { Floor } from './data/Floor';
import { GetPosition } from './data/GetPosition';
-import { GetProperties } from './data/GetProperties';
-import { GetProperty } from './data/GetProperty';
import { IsAsset } from './data/IsAsset';
import { IsEnabled } from './data/IsEnabled';
import { IsHovered } from './data/IsHovered';
@@ -47,6 +41,7 @@ import { ObjectByName } from './data/ObjectByName';
import { Or } from './data/Or';
import { Owner } from './data/Owner';
import { PackStruct } from './data/PackStruct';
+import { Properties } from './data/Properties';
import { Parent } from './data/Parent';
import { Random } from './data/Random';
import { Round } from './data/Round';
@@ -118,8 +113,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
NameOf,
IsAsset,
IsEnabled,
- GetProperty,
- GetProperties,
+ Properties,
GetPosition,
// data: literals and math
NumberLiteral,
@@ -176,10 +170,6 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
// actions
Move,
SetPosition,
- SetProperty,
- SetProperties,
- SetEnabled,
- SetText,
Spawn,
Destroy,
Log,
diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts
index 0b817278..43a5bd8a 100644
--- a/src/core/graph/nodes/shared.ts
+++ b/src/core/graph/nodes/shared.ts
@@ -1,17 +1,8 @@
import type { PropertyValue } from '../../model/Properties';
-import { OBJECT_PROPERTY_SUGGESTIONS, type PropertyDef } from '../../properties/PropertySchema';
+import type { PropertyDef } from '../../properties/PropertySchema';
import type { NodeDef, PortDef } from '../NodeRegistry';
import { asBoolean, asNumber } from '../values';
-/** The property a Get/Set property node names: typed freely, or picked from the built-in ones. */
-export const PROPERTY_PARAM: PropertyDef = {
- key: 'property',
- label: 'Property',
- kind: 'text',
- default: 'width',
- suggestions: OBJECT_PROPERTY_SUGGESTIONS,
-};
-
export const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' });
export const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' });
export const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' });
diff --git a/src/core/graph/propertyPorts.ts b/src/core/graph/propertyPorts.ts
index 67f6ea13..18bdb5e9 100644
--- a/src/core/graph/propertyPorts.ts
+++ b/src/core/graph/propertyPorts.ts
@@ -1,24 +1,72 @@
-import { OBJECT_PROPERTIES, type PropertyDef } from '../properties/PropertySchema';
-import type { DeclaredPort } from './Graph';
+import { ASSET_TYPE_LABELS } from '../model/AssetTypes';
+import { type PropertyBag, textOf } from '../model/Properties';
+import { type PropertyDef, type PropertySchema, schemaFor } from '../properties/PropertySchema';
+import type { DeclaredPort, GraphNode } from './Graph';
+import type { BehaviorLookup } from './lookup';
import { portDefOf } from './nested';
import { type PortDef, withInlineDefault } from './NodeRegistry';
import { portKindOf } from './variablePorts';
-export function objectPropertyDef(key: string): PropertyDef | undefined {
- return OBJECT_PROPERTIES.find((def) => def.key === key);
-}
-
-export function propertyPortOf(def: PropertyDef): DeclaredPort {
- return { key: def.key, kind: portKindOf(def) };
-}
+/** Keys no behavior variable is likely to have: a port cannot take a setting's key (`fixedPortKeys`). */
+export const OBJECT_TYPE_PARAM = 'objectType';
+export const OBJECT_BEHAVIOR_PARAM = 'objectBehavior';
/**
- * A port naming a built-in property carries its def: a typed-in value starts at its default and is
- * edited with its field. A custom name, or a kind the property does not travel as, stays plain.
+ * What a Properties node offers ports from. Textures and scenes are not objects in a running game,
+ * so there is nothing to hand the node for them.
*/
-export function propertyPort(port: DeclaredPort): PortDef {
+export const PROPERTY_TARGETS = [
+ 'any',
+ 'sprite',
+ 'text',
+ 'collider',
+ 'camera',
+ 'container',
+ 'behavior',
+] as const;
+export type PropertyTarget = (typeof PROPERTY_TARGETS)[number];
+
+export function targetLabel(target: PropertyTarget): string {
+ return target === 'any' ? 'Any' : ASSET_TYPE_LABELS[target];
+}
+
+export const OBJECT_TYPE_SETTING: PropertyDef = {
+ key: OBJECT_TYPE_PARAM,
+ label: 'Type',
+ kind: 'enum',
+ default: 'any',
+ options: PROPERTY_TARGETS.map((value) => ({ value, label: targetLabel(value) })),
+};
+
+export const OBJECT_BEHAVIOR_SETTING: PropertyDef = {
+ key: OBJECT_BEHAVIOR_PARAM,
+ label: 'Behavior',
+ kind: 'asset',
+ default: '',
+ assetTypes: ['behavior'],
+};
+
+export function propertyTargetOf(params: PropertyBag): PropertyTarget {
+ const value = textOf(params[OBJECT_TYPE_PARAM] ?? '');
+ return (PROPERTY_TARGETS as readonly string[]).includes(value) ? (value as PropertyTarget) : 'any';
+}
+
+/** A behavior's own variables come on top of these, from its asset. */
+export function targetProperties(target: PropertyTarget): PropertySchema {
+ return target === 'any' ? [] : schemaFor(target);
+}
+
+/** Carries the def its type has under that key (default, field); under Any or at another kind, none. */
+export function propertyPort(port: DeclaredPort, params: PropertyBag): PortDef {
const plain = withInlineDefault(portDefOf(port));
- const field = objectPropertyDef(port.key);
+ const field = targetProperties(propertyTargetOf(params)).find((def) => def.key === port.key);
if (!field || portKindOf(field) !== port.kind) return plain;
return { ...plain, field, default: field.default };
}
+
+export function propertiesTitle(node: GraphNode, behaviors: BehaviorLookup): string {
+ const target = propertyTargetOf(node.params);
+ if (target === 'any') return 'Properties';
+ const behavior = target === 'behavior' ? behaviors(textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '')) : null;
+ return `${behavior?.name ?? targetLabel(target)} properties`;
+}
diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts
index 7df6e888..cf5127d0 100644
--- a/src/core/properties/PropertySchema.ts
+++ b/src/core/properties/PropertySchema.ts
@@ -1,5 +1,5 @@
import type { Asset } from '../model/Asset';
-import { ASSET_TYPE_LABELS, type AssetType, isPlaceableType } from '../model/AssetTypes';
+import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import {
type FieldValue,
isList,
@@ -13,12 +13,6 @@ import {
colorOf,
} from '../model/Properties';
-/** A value a text field offers to fill in; the text stays free to type. */
-export interface TextSuggestion {
- value: string;
- label: string;
-}
-
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
/**
@@ -36,7 +30,7 @@ export type PropertyDef =
step?: number;
decimals?: number;
}
- | { key: string; label: string; kind: 'text'; default: string; suggestions?: readonly TextSuggestion[] }
+ | { key: string; label: string; kind: 'text'; default: string }
| { key: string; label: string; kind: 'color'; default: number }
| { key: string; label: string; kind: 'boolean'; default: boolean }
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
@@ -203,32 +197,6 @@ const FULL_SCHEMAS: Record = Object.fromEntries(
]),
) as Record;
-const OBJECT_TYPES = ['sprite', 'text', 'collider', 'camera'] as const;
-
-/** A built-in property with the type it belongs to ("Sprite width"); a shared key appears once per type. */
-export const OBJECT_PROPERTY_CHOICES: readonly { label: string; def: PropertyDef }[] = [
- ...COMMON_PROPS.map((def) => ({ label: `Object ${def.key}`, def })),
- ...OBJECT_TYPES.flatMap((type) =>
- ASSET_SCHEMAS[type].map((def) => ({ label: `${ASSET_TYPE_LABELS[type]} ${def.key}`, def })),
- ),
-];
-
-/**
- * Every built-in property once per key – what a Get/Set properties port resolves to. Sprite comes
- * first so a shared key (`width`) keeps its def, not the camera's whole-pixel view width.
- */
-export const OBJECT_PROPERTIES: PropertySchema = firstPerKey(OBJECT_PROPERTY_CHOICES.map((c) => c.def));
-
-export const OBJECT_PROPERTY_SUGGESTIONS: readonly TextSuggestion[] = OBJECT_PROPERTY_CHOICES.map((c) => ({
- value: c.def.key,
- label: c.label,
-}));
-
-function firstPerKey(defs: readonly PropertyDef[]): PropertyDef[] {
- const seen = new Set();
- return defs.filter((def) => !seen.has(def.key) && seen.add(def.key));
-}
-
export function schemaFor(type: AssetType): PropertySchema {
return FULL_SCHEMAS[type];
}
diff --git a/src/core/store/legacyNodes.ts b/src/core/store/legacyNodes.ts
new file mode 100644
index 00000000..594ae122
--- /dev/null
+++ b/src/core/store/legacyNodes.ts
@@ -0,0 +1,167 @@
+import { PROPERTIES, VARIABLES } from '../graph/nested';
+import { OBJECT_BEHAVIOR_PARAM, OBJECT_TYPE_PARAM, targetProperties } from '../graph/propertyPorts';
+import { portKindOf } from '../graph/variablePorts';
+import type { PropertyBag } from '../model/Properties';
+import { COMMON_PROPS, type PropertyDef, type PropertySchema } from '../properties/PropertySchema';
+
+/** The nodes `Variables` replaced, and whether they wrote. */
+const SINGLE: Record = { GetVariable: false, SetVariable: true, Variable: true };
+const PLURAL: Record = { GetVariables: false, SetVariables: true };
+
+type Raw = Record;
+const record = (value: unknown): Raw =>
+ value && typeof value === 'object' && !Array.isArray(value) ? (value as Raw) : {};
+
+/**
+ * Rewrites the variable nodes of an older file into `Variables`, on the raw graph before it is
+ * normalized. A single node's `value` port is renamed to the variable, which is the key there; one
+ * without a variable has no pins left and loses its edges, flow included.
+ */
+export function upgradeVariableNodes(raw: unknown, variables: readonly PropertyDef[]): unknown {
+ const graph = record(raw);
+ if (!Array.isArray(graph.nodes)) return raw;
+ const renamed = new Map();
+ const nodes = (graph.nodes as unknown[]).map((item) => {
+ const node = record(item);
+ const type = String(node.type);
+ if (type in PLURAL) {
+ const write = PLURAL[type];
+ const ports = Array.isArray(node.ports)
+ ? (node.ports as unknown[]).map((p) => (write ? { ...record(p), write } : p))
+ : [];
+ return { ...node, type: VARIABLES, ports };
+ }
+ if (!(type in SINGLE)) return item;
+ const write = SINGLE[type];
+ const { variable, value, ...params } = record(node.params);
+ const key = typeof variable === 'string' ? variable : '';
+ renamed.set(String(node.id), key);
+ if (!key) return { ...node, type: VARIABLES, params, ports: [] };
+ const declared = variables.find((v) => v.key === key);
+ const kind = declared ? portKindOf(declared) : 'text';
+ if (write && value !== undefined) params[key] = value;
+ return { ...node, type: VARIABLES, params, ports: [write ? { key, kind, write } : { key, kind }] };
+ });
+ const edges = (Array.isArray(graph.edges) ? graph.edges : []).flatMap((item) => {
+ const edge = record(item);
+ const end = (side: unknown) => {
+ const at = record(side);
+ const key = renamed.get(String(at.node));
+ if (key === '') return null;
+ return key !== undefined && at.port === 'value' ? { ...at, port: key } : at;
+ };
+ const from = end(edge.from);
+ const to = end(edge.to);
+ return from && to ? [{ ...edge, from, to }] : [];
+ });
+ return { ...graph, nodes, edges };
+}
+
+const SINGLE_PROPERTY: Record = { GetProperty: false, SetProperty: true };
+const PLURAL_PROPERTY: Record = { GetProperties: false, SetProperties: true };
+const FIXED_TARGETS = ['sprite', 'text', 'collider', 'camera', 'container'] as const;
+
+interface Pinned {
+ params: PropertyBag;
+ defs: PropertySchema;
+}
+
+/**
+ * The type an old node's ports fit: the first object type holding every key, else the one behavior
+ * declaring them all as variables, else Any.
+ */
+export function pinPropertyTarget(
+ keys: readonly string[],
+ behaviors: ReadonlyMap,
+): Pinned {
+ const holds = (defs: PropertySchema) => keys.every((key) => defs.some((d) => d.key === key));
+ if (keys.length === 0) return { params: {}, defs: [] };
+ // Every object has these, so they say nothing about which type the node was meant for.
+ if (holds(COMMON_PROPS)) return { params: {}, defs: COMMON_PROPS };
+ for (const target of FIXED_TARGETS) {
+ const defs = targetProperties(target);
+ if (holds(defs)) return { params: { [OBJECT_TYPE_PARAM]: target }, defs };
+ }
+ const owners = [...behaviors].filter(([, defs]) => holds(defs));
+ const [owner] = owners;
+ if (owners.length !== 1 || !owner) return { params: {}, defs: [] };
+ return { params: { [OBJECT_TYPE_PARAM]: 'behavior', [OBJECT_BEHAVIOR_PARAM]: owner[0] }, defs: owner[1] };
+}
+
+/**
+ * Nodes that were a `Set property` of one key, with what their unconnected input wrote. It has to
+ * be spelled out: the new port would write the type's default instead (a text object's is "Text").
+ */
+const SETTERS: Record = {
+ SetEnabled: { key: 'enabled', unset: true },
+ SetText: { key: 'text', unset: '' },
+};
+
+function fromSetter(node: Raw): Raw {
+ const setter = SETTERS[String(node.type)];
+ if (!setter) return node;
+ const { [setter.key]: value = setter.unset } = record(node.params);
+ return { ...node, type: 'SetProperty', params: { property: setter.key, value } };
+}
+
+/**
+ * Rewrites Get/Set property(ies), Set enabled and Set text of an older file into `Properties`, on
+ * the raw graph before it is normalized. A single node's `value` port is renamed to the property; one
+ * without a property keeps only its object input and loses its other edges.
+ */
+export function upgradePropertyNodes(
+ raw: unknown,
+ behaviors: ReadonlyMap,
+): unknown {
+ const graph = record(raw);
+ if (!Array.isArray(graph.nodes)) return raw;
+ const renamed = new Map();
+ const nodes = (graph.nodes as unknown[]).map((item) => {
+ const node = fromSetter(record(item));
+ const type = String(node.type);
+ if (type in PLURAL_PROPERTY) {
+ const write = PLURAL_PROPERTY[type];
+ const ports = (Array.isArray(node.ports) ? (node.ports as unknown[]) : []).map(record);
+ const pinned = pinPropertyTarget(
+ ports.map((p) => String(p.key)),
+ behaviors,
+ );
+ return {
+ ...node,
+ type: PROPERTIES,
+ params: { ...record(node.params), ...pinned.params },
+ ports: ports.map((p) => (write ? { ...p, write } : p)),
+ };
+ }
+ if (!(type in SINGLE_PROPERTY)) return node;
+ const write = SINGLE_PROPERTY[type];
+ const { property, value, ...params } = record(node.params);
+ const key = typeof property === 'string' ? property : '';
+ renamed.set(String(node.id), key);
+ if (!key) return { ...node, type: PROPERTIES, params, ports: [] };
+ const pinned = pinPropertyTarget([key], behaviors);
+ const def = pinned.defs.find((d) => d.key === key);
+ const kind = def ? portKindOf(def) : 'text';
+ if (write && value !== undefined) params[key] = value;
+ return {
+ ...node,
+ type: PROPERTIES,
+ params: { ...params, ...pinned.params },
+ ports: [write ? { key, kind, write } : { key, kind }],
+ };
+ });
+ const edges = (Array.isArray(graph.edges) ? graph.edges : []).flatMap((item) => {
+ const edge = record(item);
+ const end = (side: unknown) => {
+ const at = record(side);
+ const key = renamed.get(String(at.node));
+ if (key === undefined || at.port === 'object') return at;
+ if (key === '') return null;
+ return at.port === 'value' ? { ...at, port: key } : at;
+ };
+ const from = end(edge.from);
+ const to = end(edge.to);
+ return from && to ? [{ ...edge, from, to }] : [];
+ });
+ return { ...graph, nodes, edges };
+}
diff --git a/src/core/store/legacyVariableNodes.ts b/src/core/store/legacyVariableNodes.ts
deleted file mode 100644
index 1b3c19a1..00000000
--- a/src/core/store/legacyVariableNodes.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { VARIABLES } from '../graph/nested';
-import { portKindOf } from '../graph/variablePorts';
-import type { PropertyDef } from '../properties/PropertySchema';
-
-/** The nodes `Variables` replaced, and whether they wrote. */
-const SINGLE: Record = { GetVariable: false, SetVariable: true, Variable: true };
-const PLURAL: Record = { GetVariables: false, SetVariables: true };
-
-type Raw = Record;
-const record = (value: unknown): Raw =>
- value && typeof value === 'object' && !Array.isArray(value) ? (value as Raw) : {};
-
-/**
- * Rewrites the variable nodes of an older file into `Variables`, on the raw graph before it is
- * normalized. A single node's `value` port is renamed to the variable, which is the key there; one
- * without a variable has no pins left and loses its edges, flow included.
- */
-export function upgradeVariableNodes(raw: unknown, variables: readonly PropertyDef[]): unknown {
- const graph = record(raw);
- if (!Array.isArray(graph.nodes)) return raw;
- const renamed = new Map();
- const nodes = (graph.nodes as unknown[]).map((item) => {
- const node = record(item);
- const type = String(node.type);
- if (type in PLURAL) {
- const write = PLURAL[type];
- const ports = Array.isArray(node.ports)
- ? (node.ports as unknown[]).map((p) => (write ? { ...record(p), write } : p))
- : [];
- return { ...node, type: VARIABLES, ports };
- }
- if (!(type in SINGLE)) return item;
- const write = SINGLE[type];
- const { variable, value, ...params } = record(node.params);
- const key = typeof variable === 'string' ? variable : '';
- renamed.set(String(node.id), key);
- if (!key) return { ...node, type: VARIABLES, params, ports: [] };
- const declared = variables.find((v) => v.key === key);
- const kind = declared ? portKindOf(declared) : 'text';
- if (write && value !== undefined) params[key] = value;
- return { ...node, type: VARIABLES, params, ports: [write ? { key, kind, write } : { key, kind }] };
- });
- const edges = (Array.isArray(graph.edges) ? graph.edges : []).flatMap((item) => {
- const edge = record(item);
- const end = (side: unknown) => {
- const at = record(side);
- const key = renamed.get(String(at.node));
- if (key === '') return null;
- return key !== undefined && at.port === 'value' ? { ...at, port: key } : at;
- };
- const from = end(edge.from);
- const to = end(edge.to);
- return from && to ? [{ ...edge, from, to }] : [];
- });
- return { ...graph, nodes, edges };
-}
diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts
index fa6b1682..71b5c4a7 100644
--- a/src/core/store/serialize.ts
+++ b/src/core/store/serialize.ts
@@ -19,7 +19,7 @@ import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { isTextureFile, normalizeFrameRect } from '../model/texture';
import { hasParentCycle } from '../model/walk';
-import { upgradeVariableNodes } from './legacyVariableNodes';
+import { upgradePropertyNodes, upgradeVariableNodes } from './legacyNodes';
import {
coerceValue,
COMMON_PROPS,
@@ -80,7 +80,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
name,
props,
variables: variables.get(id) ?? [],
- graph: normalizeGraph(upgradeVariableNodes(a.graph, variables.get(id) ?? [])),
+ graph: normalizeGraph(
+ upgradePropertyNodes(upgradeVariableNodes(a.graph, variables.get(id) ?? []), variables),
+ ),
});
} else if (type === 'texture') {
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts
index 3d46c5b1..62fed7e2 100644
--- a/src/editor/inspector/fieldFactory.ts
+++ b/src/editor/inspector/fieldFactory.ts
@@ -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 });
diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts
index c83db224..2879eaee 100644
--- a/src/editor/inspector/nodeSection.ts
+++ b/src/editor/inspector/nodeSection.ts
@@ -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);
diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts
index 45716767..70aad860 100644
--- a/src/editor/inspector/portSection.ts
+++ b/src/editor/inspector/portSection.ts
@@ -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
diff --git a/tests/core/enabled.test.ts b/tests/core/enabled.test.ts
index a5862153..74468b31 100644
--- a/tests/core/enabled.test.ts
+++ b/tests/core/enabled.test.ts
@@ -111,7 +111,7 @@ describe('the enabled switch', () => {
world.tick(0.1);
expect(entity.props.ticks).toBe(0); // off: no On start, no ticks
- entity.props.enabled = true; // what "Set enabled" does
+ entity.props.enabled = true; // what Properties with `enabled` set does
world.tick(0.1);
expect(entity.props.ticks).toBe(2); // On start plus this frame's On update
world.tick(0.1);
diff --git a/tests/core/legacy-property-nodes.test.ts b/tests/core/legacy-property-nodes.test.ts
new file mode 100644
index 00000000..a0f747f7
--- /dev/null
+++ b/tests/core/legacy-property-nodes.test.ts
@@ -0,0 +1,222 @@
+import { describe, expect, it } from 'vitest';
+import { GraphInterpreter, OBJECT_BEHAVIOR_PARAM, OBJECT_TYPE_PARAM } from '../../src/core/graph';
+import type { BehaviorAsset } from '../../src/core/model/Asset';
+import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
+import { findAsset } from '../../src/core/model/Project';
+import type { PropertyDef } from '../../src/core/properties/PropertySchema';
+import { pinPropertyTarget } from '../../src/core/store/legacyNodes';
+import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
+import { fakeContext, fakeEntity, makeBehavior, makeProject } from '../helpers';
+
+type RawNode = { id: string; type: string; params?: Record; ports?: unknown[] };
+type RawEdge = [from: string, fromPort: string, to: string, toPort: string];
+
+/** A saved project whose behavior "B" holds `nodes` and `edges` exactly as an older editor wrote them. */
+function loadOld(nodes: RawNode[], edges: RawEdge[]) {
+ const fixture = makeProject();
+ const behavior = makeBehavior(fixture, 'B', ['seen']);
+ const game = makeBehavior(fixture, 'Game', ['coins']);
+ const raw = serializeProject(fixture.project) as { assets: Record[] };
+ raw.assets.find((a) => a.id === behavior.id)!.graph = {
+ nodes: nodes.map((n) => ({ x: 0, y: 0, params: {}, ...n })),
+ edges: edges.map(([from, fromPort, to, toPort], i) => ({
+ id: `e${i}`,
+ from: { node: from, port: fromPort },
+ to: { node: to, port: toPort },
+ })),
+ };
+ const project = deserializeProject(raw);
+ return { project, game, behavior: findAsset(project, behavior.id) as BehaviorAsset };
+}
+
+const node = (behavior: BehaviorAsset, id: string) => behavior.graph.nodes.find((n) => n.id === id)!;
+const edges = (behavior: BehaviorAsset) =>
+ behavior.graph.edges.map((e) => `${e.from.node}.${e.from.port}>${e.to.node}.${e.to.port}`);
+
+describe('property nodes of an older file', () => {
+ it('load as Properties nodes that compute what the old graph computed', () => {
+ const { project, behavior } = loadOld(
+ [
+ { id: 'start', type: 'OnStart' },
+ { id: 'self', type: 'Self' },
+ { id: 'paint', type: 'SetProperty', params: { property: 'color', value: 0xff0000 } },
+ {
+ id: 'size',
+ type: 'SetProperties',
+ params: { width: 40 },
+ ports: [{ key: 'width', kind: 'number' }],
+ },
+ { id: 'get', type: 'GetProperty', params: { property: 'width' } },
+ { id: 'store', type: 'Variables', ports: [{ key: 'seen', kind: 'number', write: true }] },
+ ],
+ [
+ ['start', 'exec', 'paint', 'exec'],
+ ['self', 'object', 'paint', 'object'],
+ ['paint', 'exec', 'size', 'exec'],
+ ['self', 'object', 'size', 'object'],
+ ['size', 'exec', 'store', 'exec'],
+ ['self', 'object', 'get', 'object'],
+ ['get', 'value', 'store', 'seen'],
+ ],
+ );
+ expect(['paint', 'size', 'get'].map((id) => node(behavior, id).type)).toEqual([
+ 'Properties',
+ 'Properties',
+ 'Properties',
+ ]);
+ expect(edges(behavior)).toContain('get.width>store.seen');
+ const self = { ...fakeEntity('self'), props: { width: 10, color: 0 } };
+ const ctx = fakeContext({ self, variables: { seen: 0 } });
+ new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx);
+ expect({ ...self.props, seen: ctx.variables.seen }).toEqual({ width: 40, color: 0xff0000, seen: 40 });
+ });
+
+ it('keep whether they wrote and their typed-in value, under the kind of the property', () => {
+ const { behavior, game } = loadOld(
+ [
+ { id: 'paint', type: 'SetProperty', params: { property: 'color', value: 7 } },
+ { id: 'coins', type: 'SetProperty', params: { property: 'coins', value: 0 } },
+ { id: 'speed', type: 'GetProperty', params: { property: 'speed' } },
+ ],
+ [],
+ );
+ expect(node(behavior, 'paint')).toMatchObject({
+ params: { [OBJECT_TYPE_PARAM]: 'sprite', color: 7 },
+ ports: [{ key: 'color', kind: 'number', write: true }],
+ });
+ expect(node(behavior, 'coins')).toMatchObject({
+ params: { [OBJECT_TYPE_PARAM]: 'behavior', [OBJECT_BEHAVIOR_PARAM]: game.id, coins: 0 },
+ ports: [{ key: 'coins', kind: 'number', write: true }],
+ });
+ // Nothing declares `speed`, so nothing says its kind.
+ expect(node(behavior, 'speed').ports).toEqual([{ key: 'speed', kind: 'text' }]);
+ expect(node(behavior, 'speed').params).toEqual({});
+ });
+
+ it('turn Set enabled into Properties writing enabled, on unless told otherwise', () => {
+ const { project, behavior } = loadOld(
+ [
+ { id: 'start', type: 'OnStart' },
+ { id: 'self', type: 'Self' },
+ { id: 'screen', type: 'ObjectByName', params: { name: 'screen' } },
+ { id: 'no', type: 'Boolean', params: { value: false } },
+ { id: 'hide', type: 'SetEnabled' },
+ { id: 'show', type: 'SetEnabled', params: { enabled: false } },
+ ],
+ [
+ ['start', 'exec', 'hide', 'exec'],
+ ['self', 'object', 'hide', 'object'],
+ ['no', 'value', 'hide', 'enabled'],
+ ['hide', 'exec', 'show', 'exec'],
+ ['screen', 'object', 'show', 'object'],
+ ],
+ );
+ expect(node(behavior, 'show')).toMatchObject({
+ type: 'Properties',
+ params: { enabled: false },
+ ports: [{ key: 'enabled', kind: 'boolean', write: true }],
+ });
+ expect(node(behavior, 'hide').params).toEqual({ enabled: true });
+ expect(edges(behavior)).toEqual([
+ 'start.exec>hide.exec',
+ 'self.object>hide.object',
+ 'no.value>hide.enabled',
+ 'hide.exec>show.exec',
+ 'screen.object>show.object',
+ ]);
+ const self = { ...fakeEntity('self'), props: { enabled: true } };
+ const screen = { ...fakeEntity('screen'), props: { enabled: true } };
+ const ctx = fakeContext({ self, findByName: (name) => (name === 'screen' ? screen : null) });
+ new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx);
+ expect([self.props.enabled, screen.props.enabled]).toEqual([false, false]);
+ });
+
+ it('turn Set text into Text properties writing text, empty unless told otherwise', () => {
+ const { project, behavior } = loadOld(
+ [
+ { id: 'start', type: 'OnStart' },
+ { id: 'a', type: 'ObjectByName', params: { name: 'a' } },
+ { id: 'b', type: 'ObjectByName', params: { name: 'b' } },
+ { id: 'c', type: 'ObjectByName', params: { name: 'c' } },
+ { id: 'num', type: 'Number', params: { value: 12 } },
+ { id: 'blank', type: 'SetText' },
+ { id: 'typed', type: 'SetText', params: { text: 'Go' } },
+ { id: 'fed', type: 'SetText' },
+ ],
+ [
+ ['start', 'exec', 'blank', 'exec'],
+ ['a', 'object', 'blank', 'object'],
+ ['blank', 'exec', 'typed', 'exec'],
+ ['b', 'object', 'typed', 'object'],
+ ['typed', 'exec', 'fed', 'exec'],
+ ['c', 'object', 'fed', 'object'],
+ ['num', 'value', 'fed', 'text'],
+ ],
+ );
+ expect(node(behavior, 'typed')).toMatchObject({
+ type: 'Properties',
+ params: { [OBJECT_TYPE_PARAM]: 'text', text: 'Go' },
+ ports: [{ key: 'text', kind: 'text', write: true }],
+ });
+ expect(node(behavior, 'blank').params).toEqual({ [OBJECT_TYPE_PARAM]: 'text', text: '' });
+ expect(edges(behavior)).toContain('num.value>fed.text');
+ const texts = Object.fromEntries(
+ ['a', 'b', 'c'].map((name) => [name, { ...fakeEntity(name), props: { text: 'old' } }]),
+ );
+ const ctx = fakeContext({ findByName: (name) => texts[name] ?? null });
+ new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx);
+ expect(['a', 'b', 'c'].map((name) => texts[name]!.props.text)).toEqual(['', 'Go', '12']);
+ });
+
+ it('keep only the object input of one that named no property', () => {
+ const { behavior } = loadOld(
+ [
+ { id: 'start', type: 'OnStart' },
+ { id: 'self', type: 'Self' },
+ { id: 'num', type: 'Number' },
+ { id: 'blank', type: 'SetProperty', params: { value: 5 } },
+ ],
+ [
+ ['start', 'exec', 'blank', 'exec'],
+ ['self', 'object', 'blank', 'object'],
+ ['num', 'value', 'blank', 'value'],
+ ],
+ );
+ expect(node(behavior, 'blank')).toMatchObject({ type: 'Properties', params: {} });
+ expect(node(behavior, 'blank').ports).toBeUndefined();
+ expect(edges(behavior)).toEqual(['self.object>blank.object']);
+ });
+});
+
+describe('the type an old node is pinned to', () => {
+ const variable = (key: string): PropertyDef => ({ key, label: key, kind: 'number', default: 0 });
+ const behaviors = new Map([
+ ['game', [variable('coins'), variable('lives')]],
+ ['hud', [variable('lives')]],
+ ]);
+ const pinned = (keys: string[]) => pinPropertyTarget(keys, behaviors).params;
+
+ it('is the first object type holding every key', () => {
+ expect(pinned(['color'])).toEqual({ [OBJECT_TYPE_PARAM]: 'sprite' });
+ expect(pinned(['color', 'align'])).toEqual({ [OBJECT_TYPE_PARAM]: 'text' });
+ expect(pinned(['width', 'zoom'])).toEqual({ [OBJECT_TYPE_PARAM]: 'camera' });
+ });
+
+ it('else the one behavior declaring them all', () => {
+ expect(pinned(['coins', 'lives'])).toEqual({
+ [OBJECT_TYPE_PARAM]: 'behavior',
+ [OBJECT_BEHAVIOR_PARAM]: 'game',
+ });
+ });
+
+ it('else Any: every object has it, nothing declares it, two behaviors do, or nothing to go by', () => {
+ expect(pinPropertyTarget(['enabled'], behaviors)).toMatchObject({
+ params: {},
+ defs: [{ kind: 'boolean' }],
+ });
+ expect(pinned(['enabled', 'color'])).toEqual({ [OBJECT_TYPE_PARAM]: 'sprite' });
+ expect(pinned(['speed'])).toEqual({});
+ expect(pinned(['lives'])).toEqual({});
+ expect(pinned([])).toEqual({});
+ });
+});
diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts
index 162af8e8..52525880 100644
--- a/tests/core/node-catalog.test.ts
+++ b/tests/core/node-catalog.test.ts
@@ -125,6 +125,25 @@ const CASES: Record = {
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
},
],
+ Properties: [
+ {
+ name: 'writes the properties switched to Set, each keeping the type it already had',
+ ports: [
+ { key: 'width', kind: 'number', write: true },
+ { key: 'text', kind: 'text', write: true },
+ { key: 'height', kind: 'number' },
+ ],
+ 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', write: true }],
+ inputs: { object: null, width: 5 },
+ next: 'exec',
+ },
+ ],
// references and properties
@@ -180,43 +199,6 @@ const CASES: Record = {
},
{ 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',
- inputs: { object: fakeHandle({ width: 42 }) },
- params: { property: 'width' },
- outputs: { value: 42 },
- },
- {
- name: 'an absent property is null',
- inputs: { object: fakeHandle({}) },
- params: { property: 'width' },
- outputs: { value: null },
- },
- ],
GetPosition: [
{
name: 'local and world position of a top-level object are the same',
@@ -599,24 +581,6 @@ const CASES: Record = {
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',
@@ -625,34 +589,6 @@ const CASES: Record = {
},
{ name: 'no object is a no-op', inputs: { object: null, x: 1, y: 1 }, next: 'exec' },
],
- SetProperty: [
- {
- name: 'writes a property, keeping the existing type',
- inputs: { object: fakeHandle({ width: 100 }), value: '250' },
- params: { property: 'width' },
- next: 'exec',
- },
- {
- name: 'no property name chosen writes nothing',
- inputs: { object: fakeHandle({ width: 100 }), value: 5 },
- next: 'exec',
- },
- ],
- SetEnabled: [
- {
- name: 'switches an object off',
- inputs: { object: fakeHandle({ enabled: true }), enabled: false },
- next: 'exec',
- },
- { name: 'no object is a no-op', inputs: { object: null, enabled: false }, next: 'exec' },
- ],
- SetText: [
- {
- name: 'writes the content of a text object',
- inputs: { object: fakeHandle({ text: 'a' }), text: 12 },
- next: 'exec',
- },
- ],
Spawn: [
{
name: 'no asset chosen logs a hint and continues with no object',
@@ -773,30 +709,6 @@ describe('action nodes write through to the object they are given', () => {
expect({ x: target.x, y: target.y }).toEqual({ x: 30, y: -40 });
});
- it('SetProperty keeps the type the property already had', () => {
- const target = fakeHandle({ width: 100 });
- runDef('SetProperty', fakeContext(), { object: target, value: '250' }, { property: 'width' });
- expect(target.props.width).toBe(250);
- });
-
- it('SetProperty without a property name writes nothing', () => {
- const target = fakeHandle({ width: 100 });
- runDef('SetProperty', fakeContext(), { object: target, value: 5 }, {});
- expect(target.props).toEqual({ width: 100 });
- });
-
- it('SetEnabled switches the object off', () => {
- const target = fakeHandle({ enabled: true });
- runDef('SetEnabled', fakeContext(), { object: target, enabled: false }, {});
- expect(target.props.enabled).toBe(false);
- });
-
- it('SetText writes the text property, stringifying numbers', () => {
- const target = fakeHandle({ text: 'a' });
- runDef('SetText', fakeContext(), { object: target, text: 12 }, {});
- expect(target.props.text).toBe('12');
- });
-
it('Destroy hands the object to the context', () => {
const removed: string[] = [];
const ctx = fakeContext({ destroy: (target) => removed.push(target.name) });
diff --git a/tests/core/property-ports.test.ts b/tests/core/property-ports.test.ts
index ca8cf557..19f627f7 100644
--- a/tests/core/property-ports.test.ts
+++ b/tests/core/property-ports.test.ts
@@ -1,146 +1,219 @@
/**
- * 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.
+ * Properties: the Variables node for the object it is handed. Its type decides what a port carries:
+ * a property of that type keeps its own default and field, Any names plain ports of a picked kind.
*/
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,
+ type DeclaredPort,
GraphInterpreter,
inlineInputDef,
+ NO_BEHAVIORS,
+ OBJECT_BEHAVIOR_PARAM,
+ OBJECT_TYPE_PARAM,
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 type { PropertyBag } from '../../src/core/model/Properties';
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 = {}) {
+const SPRITE = { [OBJECT_TYPE_PARAM]: 'sprite' };
+const set = (key: string): DeclaredPort => ({ key, kind: 'number', write: true });
+
+/** `On start → Properties{…} on self`, its `width` output then stored in `seen`. */
+function onSelf(ports: DeclaredPort[], params: PropertyBag = SPRITE) {
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 props = edit.properties(ports, params);
const store = edit.variables(['seen'], { write: true });
- 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, 'seen'],
+ [start, 'exec', props, 'exec'],
+ [self, 'object', props, 'object'],
+ [props, 'exec', store, 'exec'],
+ [props, 'width', store, 'seen'],
]);
- return { fixture, behavior, set, get };
+ return { fixture, behavior, props };
}
-function run(behavior: BehaviorAsset, fixture: ReturnType) {
- const self = { ...fakeEntity('self'), props: { width: 10, color: 0 } };
+function run(behavior: BehaviorAsset, fixture: ReturnType, props: PropertyBag) {
+ const self = { ...fakeEntity('self'), props: { ...props } };
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 });
+describe('a node that reads and writes properties of an object', () => {
+ it("writes an unconnected input as its property's default, and reads it back after", () => {
+ const { fixture, behavior } = onSelf([set('width'), set('color')]);
+ expect(run(behavior, fixture, { width: 10, color: 0 })).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);
+ const { fixture, behavior, props } = onSelf([set('width')]);
+ props.params.width = 64;
+ expect(run(behavior, fixture, { width: 10 }).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('gives a custom name the zero it writes untyped, so the node shows a value', () => {
- expect(propertyPort({ key: 'speed', kind: 'number' }).default).toBe(0);
- expect(propertyPort({ key: 'ready', kind: 'boolean' }).default).toBe(false);
- });
-
- 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', () => {
+ it('reads a property at the moment it is asked, so one node counts it up', () => {
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']);
+ const behavior = makeBehavior(fixture, 'B');
+ const edit = graphEditor(fixture, behavior);
+ const start = edit.addNode('OnStart');
+ const self = edit.addNode('Self');
+ const coins = edit.properties([set('coins')]);
+ const add = edit.addNode('Add', { b: 1 });
+ edit.wire([
+ [start, 'exec', coins, 'exec'],
+ [self, 'object', coins, 'object'],
+ [coins, 'coins', add, 'a'],
+ [add, 'result', coins, 'coins'],
+ ]);
+ const self_ = { ...fakeEntity('self'), props: { coins: 5 } };
+ const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
+ for (let i = 0; i < 2; i++) interpreter.fire('OnStart', fakeContext({ self: self_ }));
+ expect(self_.props.coins).toBe(7);
+ });
+
+ it('switches an object off and, left unconnected, on – what Set enabled did', () => {
+ const switched = (typed: PropertyBag, before: boolean) => {
+ const fixture = makeProject();
+ const behavior = makeBehavior(fixture, 'B');
+ const edit = graphEditor(fixture, behavior);
+ const start = edit.addNode('OnStart');
+ const self = edit.addNode('Self');
+ const node = edit.properties([{ key: 'enabled', kind: 'boolean', write: true }], {
+ [OBJECT_TYPE_PARAM]: 'container',
+ });
+ Object.assign(node.params, typed);
+ edit.wire([
+ [start, 'exec', node, 'exec'],
+ [self, 'object', node, 'object'],
+ ]);
+ return run(behavior, fixture, { enabled: before }).props.enabled;
+ };
+ expect([switched({ enabled: false }, true), switched({}, false)]).toEqual([false, true]);
+ });
+
+ it('has flow pins only while a property is set, and they sit above the object input', () => {
+ const { behavior, props } = onSelf([set('width'), { key: 'height', kind: 'number' }]);
+ const keys = () => {
+ const def = resolveDef(props, behavior.graph, NO_BEHAVIORS)!;
+ return { inputs: def.inputs.map((p) => p.key), outputs: def.outputs.map((p) => p.key) };
+ };
+ expect(keys()).toEqual({ inputs: ['exec', 'object', 'width'], outputs: ['exec', 'width', 'height'] });
+ props.ports = props.ports!.map(({ write: _, ...p }) => p);
+ expect(keys()).toEqual({ inputs: ['object'], outputs: ['width', 'height'] });
});
});
-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');
+describe('what a port carries, by the type of the node', () => {
+ const width: DeclaredPort = { key: 'width', kind: 'number' };
+
+ it("takes the def of its own type, not another type's that shares the key", () => {
+ expect(propertyPort(width, SPRITE)).toMatchObject({ default: 100, field: { min: 1 } });
+ expect(propertyPort(width, { [OBJECT_TYPE_PARAM]: 'camera' })).toMatchObject({
+ default: 640,
+ field: { decimals: 0 },
+ });
});
- 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',
- ]),
+ it('edits a typed-in value with the field of that property', () => {
+ const text = { [OBJECT_TYPE_PARAM]: 'text' };
+ expect(inlineInputDef(propertyPort({ key: 'color', kind: 'number' }, text)).kind).toBe('color');
+ expect(inlineInputDef(propertyPort({ key: 'align', kind: 'text' }, text)).kind).toBe('enum');
+ // A kind the property does not travel as (a hand-edited file) is left a plain port.
+ expect(propertyPort({ key: 'width', kind: 'text' }, SPRITE).field).toBeUndefined();
+ });
+
+ it('keeps a port under Any plain, at the zero of its kind, whatever its name', () => {
+ expect(propertyPort(width, {})).toMatchObject({ default: 0 });
+ expect(propertyPort(width, {}).field).toBeUndefined();
+ expect(propertyPort({ key: 'ready', kind: 'boolean' }, {}).default).toBe(false);
+ });
+
+ it('names the node after its type, or after the behavior it points at', () => {
+ const fixture = makeProject();
+ const game = makeBehavior(fixture, 'Game');
+ const edit = graphEditor(fixture, makeBehavior(fixture, 'Player'));
+ const title = (params: PropertyBag) =>
+ resolveDef(edit.properties([], params), game.graph, behaviorLookup(fixture.project))!.label;
+ expect(title({})).toBe('Properties');
+ expect(title(SPRITE)).toBe('Sprite properties');
+ expect(title({ [OBJECT_TYPE_PARAM]: 'behavior', [OBJECT_BEHAVIOR_PARAM]: game.id })).toBe(
+ 'Game properties',
);
- expect(new Set(labels).size).toBe(labels.length);
- expect(OBJECT_PROPERTY_CHOICES.find((c) => c.label === 'Camera width')?.def.key).toBe('width');
+ expect(title({ [OBJECT_TYPE_PARAM]: 'behavior' })).toBe('Behavior properties');
});
+});
- 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',
- });
+describe('the keys a port cannot take', () => {
+ it('keeps the object input and the settings their own', () => {
+ const { fixture, behavior, props } = onSelf([set('width')]);
+ for (const key of ['object', OBJECT_TYPE_PARAM, OBJECT_BEHAVIOR_PARAM]) {
+ expect(() =>
+ fixture.stack.execute(new AddPortCommand(behavior.id, props.id, { key, kind: 'number' })),
+ ).toThrow(CommandError);
+ expect(() => fixture.stack.execute(new RenamePortCommand(behavior.id, props.id, 'width', key))).toThrow(
+ CommandError,
+ );
}
});
+
+ it('drops a port from a file that would take one of them', () => {
+ const { fixture, behavior, props } = onSelf([set('width')]);
+ props.ports = [
+ ...props.ports!,
+ { key: 'object', kind: 'number' },
+ { key: OBJECT_TYPE_PARAM, kind: 'text' },
+ ];
+ 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 === props.id)!;
+ expect((node.ports ?? []).map((p) => p.key)).toEqual(['width']);
+ });
+});
+
+describe('a renamed variable', () => {
+ it('pulls along the ports of Properties nodes pointing at its behavior, in every graph', () => {
+ const fixture = makeProject();
+ const game = makeBehavior(fixture, 'Game', ['coins']);
+ const player = makeBehavior(fixture, 'Player', ['coins']);
+ const edit = graphEditor(fixture, player);
+ const pointing = { [OBJECT_TYPE_PARAM]: 'behavior', [OBJECT_BEHAVIOR_PARAM]: game.id };
+ const ofGame = edit.properties([set('coins')], { ...pointing, coins: 3 });
+ const any = edit.properties([set('coins')]);
+ const ofPlayer = edit.properties([set('coins')], { ...pointing, [OBJECT_BEHAVIOR_PARAM]: player.id });
+ const log = edit.addNode('Log');
+ edit.connect(ofGame, 'coins', log, 'message');
+ const keys = () => [ofGame, any, ofPlayer].map((n) => n.ports!.map((p) => p.key).join());
+
+ fixture.stack.execute(new RenameVariableCommand(game.id, 'coins', 'gold'));
+ expect(keys()).toEqual(['gold', 'coins', 'coins']);
+ expect(ofGame.params).toMatchObject({ gold: 3 });
+ expect(player.graph.edges.map((e) => `${e.from.port}>${e.to.port}`)).toEqual(['gold>message']);
+
+ fixture.stack.undo();
+ expect(keys()).toEqual(['coins', 'coins', 'coins']);
+ expect(ofGame.params).toMatchObject({ coins: 3 });
+ });
+
+ it('leaves a port alone whose new key the node already has', () => {
+ const fixture = makeProject();
+ const game = makeBehavior(fixture, 'Game', ['coins']);
+ const pointing = { [OBJECT_TYPE_PARAM]: 'behavior', [OBJECT_BEHAVIOR_PARAM]: game.id };
+ // `gold` is the port of a variable deleted since.
+ const node = graphEditor(fixture, game).properties([set('coins'), set('gold')], pointing);
+ fixture.stack.execute(new RenameVariableCommand(game.id, 'coins', 'gold'));
+ expect(node.ports!.map((p) => p.key)).toEqual(['coins', 'gold']);
+ });
});
diff --git a/tests/core/text-and-pointer.test.ts b/tests/core/text-and-pointer.test.ts
index 0e59ca6e..9c7e74c6 100644
--- a/tests/core/text-and-pointer.test.ts
+++ b/tests/core/text-and-pointer.test.ts
@@ -3,14 +3,13 @@ import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { footprintOf, textSize } from '../../src/core/properties/bounds';
-import { fakeHandle, makeSpriteScene, place, runNode, startWorld } from '../helpers';
+import { makeSpriteScene, place, startWorld } from '../helpers';
describe('text objects', () => {
it('measures a text footprint from its content and font size', () => {
expect(textSize({ text: 'ab', fontSize: 100 }).w).toBeCloseTo(110, 5);
expect(textSize({ text: 'ab', fontSize: 100 }).h).toBeCloseTo(125, 5);
expect(footprintOf('text', { text: 'abcd', fontSize: 100 }).w).toBeCloseTo(220, 5);
- expect(runNode('SetText', { object: fakeHandle({ text: 'old' }), text: 42 })).toEqual({});
});
});
diff --git a/tests/helpers.ts b/tests/helpers.ts
index 9d3520bc..53b5497a 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -15,6 +15,7 @@ import {
requireNodeDef,
type RunResult,
portKindOf,
+ PROPERTIES,
VARIABLES,
} from '../src/core/graph';
import type { BehaviorAsset, SceneAsset } from '../src/core/model/Asset';
@@ -107,6 +108,8 @@ export interface GraphEditor {
keys: readonly string[],
options?: { write?: boolean | readonly string[]; params?: PropertyBag },
): GraphNode;
+ /** A Properties node with `params` (its type) declaring `ports`. */
+ properties(ports: readonly DeclaredPort[], params?: PropertyBag): GraphNode;
connect(from: GraphNode, fromPort: string, to: GraphNode, toPort: string): void;
wire(edges: readonly Edge[]): void;
}
@@ -137,6 +140,12 @@ export function graphEditor(fixture: ProjectFixture, behavior: BehaviorAsset): G
if (ports.length > 0) stack.execute(new AddPortsCommand(behavior.id, command.node.id, ports));
return command.node;
},
+ properties(ports, params = {}) {
+ const command = new AddNodeCommand(behavior.id, PROPERTIES, 0, 0, params);
+ stack.execute(command);
+ if (ports.length > 0) stack.execute(new AddPortsCommand(behavior.id, command.node.id, ports));
+ return command.node;
+ },
connect,
wire(edges) {
for (const [from, fromPort, to, toPort] of edges) connect(from, fromPort, to, toPort);
diff --git a/todo-notes/variables-node-title.md b/todo-notes/variables-node-title.md
new file mode 100644
index 00000000..0956875e
--- /dev/null
+++ b/todo-notes/variables-node-title.md
@@ -0,0 +1,8 @@
+ok, title bei variables node NUR wenn zusammengeklappt ändern:
+- mehr als 1 set => "Set {n} variables"
+- ansonsten bei mehr als 1 get => "Get {n} variables"
+- ansonsten bei 1 set => "Set {name}"
+- ansonsten bei 1 get => "Get {name}"
+- ansonsten => "No variables"
+
+dasselbe bei properties node
diff --git a/tools/e2e/24-property-nodes.mjs b/tools/e2e/24-property-nodes.mjs
index 43536f2b..273818cf 100644
--- a/tools/e2e/24-property-nodes.mjs
+++ b/tools/e2e/24-property-nodes.mjs
@@ -1,31 +1,30 @@
-import { edge, node, port, variable, written } from '../lib/builders.mjs';
+import { edge, node, propertiesOf, variable, written } 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
- * the Variables node picks its variables. 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.
+ * 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 → Set properties{width 40, color red} on the owner`, then its width read back into `seen`. */
+/** `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', 'SetProperties', 260, 0, { width: 40, color: 0xff0000 }, [
- port('width', 'number'),
- port('color', 'number'),
+ node('pp-set', 'Properties', 260, 0, { ...propertiesOf('sprite'), width: 40, color: 0xff0000 }, [
+ written('width', 'number'),
+ written('color', 'number'),
]),
- node('pp-get', 'GetProperties', 260, 300, {}, [port('width', 'number')]),
node('pp-store', 'Variables', 560, 0, {}, [written('seen', 'number')]),
- node('pp-single', 'SetProperty', 560, 300, { property: 'width' }),
+ 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-owner', 'object', 'pp-get', 'object'),
- edge('pp-e5', 'pp-get', 'width', 'pp-store', 'seen'),
+ edge('pp-e4', 'pp-set', 'width', 'pp-store', 'seen'),
],
};
@@ -34,8 +33,9 @@ export async function propertyNodes(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) => `${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)}] })`,
@@ -48,6 +48,13 @@ export async function propertyNodes(t) {
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);
@@ -55,6 +62,13 @@ export async function propertyNodes(t) {
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()');
@@ -84,7 +98,7 @@ export async function propertyNodes(t) {
`);
await sleep(400);
- // 24b. The run: one node writes both properties, another reads one of them back.
+ // 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;
@@ -92,117 +106,125 @@ export async function propertyNodes(t) {
await stop();
await sleep(300);
check(
- 'one node writes several properties, another reads them back',
+ '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. A typed-in value reads in the words of its property: a color as its hex.
+ // 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.nodeLabel('pp-set')`)),
+ JSON.stringify(await json(`__gm.graph.nodeInputs('pp-set')`)),
);
- await shot('01-property-ports');
+ await shot('01-sprite-properties');
- // 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);
+ // 24d. A Sprite offers the fields of a sprite, and nothing of the other types.
+ const sprite = await offered();
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(' | '),
+ "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 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.
+ // 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 a port',
- grown.join(',') === 'width:number,color:number,height:number',
+ '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(
- "and its typed-in value starts at the property's own default",
- (await find('height 100')) !== null,
- JSON.stringify(await find('height 0')),
+ "switched to Set, its typed-in value starts at the property's own default",
+ inputs.includes('height 100'),
+ JSON.stringify(inputs),
);
- await shot('02-property-added');
+ await shot('02-height-set');
+ await undo();
await undo();
await sleep(300);
- check('undo takes it back', (await portsOf('pp-set')).length === 2);
+ check('undo takes both back', (await portsOf('pp-set')).length === 2, (await portsOf('pp-set')).join());
- // 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');
+ // 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(
- 'and renamed by typing',
- (await portsOf('pp-set'))[2] === 'speed:text',
+ '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 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 shot('03-camera');
+ await undo();
+ await sleep(300);
+ check('undo switches back', (await title('pp-set')) === 'Sprite properties', await title('pp-set'));
- await addProperty('custom number');
+ // 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('property1', 'object');
+ 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-set'))[3] === 'property1:number' && notice.includes('"object" is taken'),
- JSON.stringify({ ports: await portsOf('pp-set'), notice }),
+ (await portsOf('pp-any'))[0] === 'width:text' && notice.includes('"object" is taken'),
+ JSON.stringify({ ports: await portsOf('pp-any'), notice }),
);
+ await shot('04-any');
- // 24g. Get properties grows outputs from the same list.
- await select('pp-get');
- await addProperty('color');
+ // 24h. A Behavior offers the variables of the behavior picked.
+ await select('pp-beh');
+ const before = await offered();
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(','),
+ 'a Behavior offers nothing until one is picked',
+ before.length === 0 && (await find('Pick a behavior first.')) !== null,
+ JSON.stringify(before),
);
-
- // 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()');
+ await choose('Behavior', 'Reshape (behavior)');
+ const vars = await offered();
check(
- 'the property setting suggests the built-in properties, the current one marked',
- suggested.includes('● Sprite width') && suggested.includes('Text align'),
- suggested.join(' | '),
+ '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 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 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;
diff --git a/tools/lib/builders.mjs b/tools/lib/builders.mjs
index 1d29d9b0..579e8b72 100644
--- a/tools/lib/builders.mjs
+++ b/tools/lib/builders.mjs
@@ -48,6 +48,12 @@ export const port = (key, kind) => ({ key, kind });
/** A variable a `Variables` node writes as well as reads. */
export const written = (key, kind) => ({ key, kind, write: true });
+/** The settings of a `Properties` node: the object type it offers ports from, and for `behavior` which one. */
+export const propertiesOf = (objectType, objectBehavior) => ({
+ objectType,
+ ...(objectBehavior ? { objectBehavior } : {}),
+});
+
/**
* A behavior variable declaration. `items` is the item kind of a `list`, `fields` the shape of a
* `struct` – or of the items of a `list` whose items are structs.
diff --git a/tools/seed-jumprun.mjs b/tools/seed-jumprun.mjs
index e7e92d02..fde5218c 100644
--- a/tools/seed-jumprun.mjs
+++ b/tools/seed-jumprun.mjs
@@ -14,7 +14,7 @@
*
* Usage: node tools/seed-jumprun.mjs [projectId] [baseUrl]
*/
-import { edge, node, port, sceneObject, variable, written } from './lib/builders.mjs';
+import { edge, node, port, propertiesOf, sceneObject, variable, written } from './lib/builders.mjs';
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('seed-jumprun.mjs', PROJECTS.jumpRun);
@@ -161,9 +161,8 @@ const playerGraph = {
node('p-brc', 'Branch', 480, 1060),
node('p-seqc', 'Sequence', 680, 1060),
node('p-game', 'ObjectByName', 680, 1280, { name: 'Game' }),
- node('p-coins', 'GetProperty', 880, 1280, { property: 'coins' }),
+ node('p-coins', 'Properties', 1080, 1060, propertiesOf('behavior', 'game'), [written('coins', 'number')]),
node('p-add1', 'Add', 1080, 1280, { b: 1 }),
- node('p-setcoins', 'SetProperty', 1280, 1060, { property: 'coins' }),
node('p-coinroot', 'Parent', 1080, 1420),
node('p-destroy', 'Destroy', 1280, 1200),
],
@@ -200,17 +199,20 @@ const playerGraph = {
edge('pe28', 'p-col', 'other', 'p-iscoin', 'object'),
edge('pe29', 'p-iscoin', 'result', 'p-brc', 'condition'),
edge('pe30', 'p-brc', 'then', 'p-seqc', 'exec'),
- edge('pe31', 'p-seqc', 'a', 'p-setcoins', 'exec'),
+ edge('pe31', 'p-seqc', 'a', 'p-coins', 'exec'),
edge('pe32', 'p-game', 'object', 'p-coins', 'object'),
- edge('pe33', 'p-coins', 'value', 'p-add1', 'a'),
- edge('pe34', 'p-add1', 'result', 'p-setcoins', 'value'),
- edge('pe35', 'p-game', 'object', 'p-setcoins', 'object'),
+ edge('pe33', 'p-coins', 'coins', 'p-add1', 'a'),
+ edge('pe34', 'p-add1', 'result', 'p-coins', 'coins'),
edge('pe36', 'p-seqc', 'b', 'p-destroy', 'exec'),
edge('pe37', 'p-col', 'other', 'p-coinroot', 'object'),
edge('pe38', 'p-coinroot', 'parent', 'p-destroy', 'object'),
],
};
+/** Switches a screen (a container) on or off. */
+const screen = (id, x, y, enabled) =>
+ node(id, 'Properties', x, y, { ...propertiesOf('container'), enabled }, [written('enabled', 'boolean')]);
+
/**
* "Game": owns the run. On start it shows the title and hides everything else; while `running`
* it counts the clock down, writes both HUD labels and decides win (coins reached) or loss (time
@@ -223,10 +225,10 @@ const gameGraph = {
node('g-level', 'ObjectByName', 0, 220, { name: 'Level' }),
node('g-win', 'ObjectByName', 0, 300, { name: 'Win' }),
node('g-lose', 'ObjectByName', 0, 380, { name: 'Lose' }),
- node('g-on1', 'SetEnabled', 260, 0, { enabled: true }),
- node('g-off1', 'SetEnabled', 480, 0, { enabled: false }),
- node('g-off2', 'SetEnabled', 700, 0, { enabled: false }),
- node('g-off3', 'SetEnabled', 920, 0, { enabled: false }),
+ screen('g-on1', 260, 0, true),
+ screen('g-off1', 480, 0, false),
+ screen('g-off2', 700, 0, false),
+ screen('g-off3', 920, 0, false),
node('g-upd', 'OnUpdate', 0, 520),
node('g-running', 'Variables', 0, 640, {}, [port('running', 'boolean')]),
@@ -240,27 +242,27 @@ const gameGraph = {
node('g-time2', 'Variables', 440, 940, {}, [port('timeLeft', 'number')]),
node('g-ceil', 'Ceil', 640, 940),
node('g-join', 'Join', 840, 940, { a: 'Time ' }),
- node('g-settext', 'SetText', 700, 520),
+ node('g-settext', 'Properties', 700, 520, propertiesOf('text'), [written('text', 'text')]),
node('g-coinlabel', 'ObjectByName', 700, 1080, { name: 'CoinLabel' }),
node('g-coins', 'Variables', 700, 1160, {}, [port('coins', 'number')]),
node('g-totext2', 'ToText', 900, 1160, { decimals: 0 }),
node('g-join2', 'Join', 1080, 1160, { a: 'Coins ' }),
node('g-join3', 'Join', 1260, 1160, { b: '/10' }),
- node('g-settext2', 'SetText', 960, 520),
+ node('g-settext2', 'Properties', 960, 520, propertiesOf('text'), [written('text', 'text')]),
node('g-needed', 'Variables', 960, 700, {}, [port('needed', 'number')]),
node('g-cmpwin', 'Compare', 1160, 700, { op: '>=' }),
node('g-brwin', 'Branch', 1220, 520),
node('g-stop1', 'Variables', 1420, 440, { running: false }, [written('running', 'boolean')]),
- node('g-leveloff1', 'SetEnabled', 1620, 440, { enabled: false }),
- node('g-winon', 'SetEnabled', 1820, 440, { enabled: true }),
+ screen('g-leveloff1', 1620, 440, false),
+ screen('g-winon', 1820, 440, true),
node('g-cmplose', 'Compare', 1160, 860, { op: '<=', b: 0 }),
node('g-brlose', 'Branch', 1420, 640),
node('g-stop2', 'Variables', 1620, 640, { running: false }, [written('running', 'boolean')]),
- node('g-leveloff2', 'SetEnabled', 1820, 640, { enabled: false }),
- node('g-loseon', 'SetEnabled', 2020, 640, { enabled: true }),
+ screen('g-leveloff2', 1820, 640, false),
+ screen('g-loseon', 2020, 640, true),
],
edges: [
edge('ge1', 'g-start', 'exec', 'g-on1', 'exec'),
@@ -321,20 +323,21 @@ const startButtonGraph = {
node('s-game', 'ObjectByName', 0, 160, { name: 'Game' }),
node('s-title', 'ObjectByName', 0, 240, { name: 'Title' }),
node('s-level', 'ObjectByName', 0, 320, { name: 'Level' }),
- node('s-coins', 'SetProperty', 240, 0, { property: 'coins', value: 0 }),
- node('s-time', 'SetProperty', 460, 0, { property: 'timeLeft', value: 30 }),
- node('s-run', 'SetProperty', 680, 0, { property: 'running', value: true }),
- node('s-hide', 'SetEnabled', 900, 0, { enabled: false }),
- node('s-show', 'SetEnabled', 1120, 0, { enabled: true }),
+ node(
+ 's-reset',
+ 'Properties',
+ 240,
+ 0,
+ { ...propertiesOf('behavior', 'game'), coins: 0, timeLeft: 30, running: true },
+ [written('coins', 'number'), written('timeLeft', 'number'), written('running', 'boolean')],
+ ),
+ screen('s-hide', 900, 0, false),
+ screen('s-show', 1120, 0, true),
],
edges: [
- edge('se1', 's-click', 'exec', 's-coins', 'exec'),
- edge('se2', 's-game', 'object', 's-coins', 'object'),
- edge('se3', 's-coins', 'exec', 's-time', 'exec'),
- edge('se4', 's-game', 'object', 's-time', 'object'),
- edge('se5', 's-time', 'exec', 's-run', 'exec'),
- edge('se6', 's-game', 'object', 's-run', 'object'),
- edge('se7', 's-run', 'exec', 's-hide', 'exec'),
+ edge('se1', 's-click', 'exec', 's-reset', 'exec'),
+ edge('se2', 's-game', 'object', 's-reset', 'object'),
+ edge('se7', 's-reset', 'exec', 's-hide', 'exec'),
edge('se8', 's-title', 'object', 's-hide', 'object'),
edge('se9', 's-hide', 'exec', 's-show', 'exec'),
edge('se10', 's-level', 'object', 's-show', 'object'),
diff --git a/tools/seed-tetris.mjs b/tools/seed-tetris.mjs
index 25254536..a8c4b83e 100644
--- a/tools/seed-tetris.mjs
+++ b/tools/seed-tetris.mjs
@@ -7,7 +7,7 @@
*
* Usage: node tools/seed-tetris.mjs [projectId] [baseUrl]
*/
-import { edge, field, node, port, sceneObject, variable, written } from './lib/builders.mjs';
+import { edge, field, node, port, propertiesOf, sceneObject, variable, written } from './lib/builders.mjs';
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris);
@@ -427,10 +427,10 @@ function drawBoardGraph() {
g.at('cell', 'ItemAt', 2, 4);
g.at('filled', 'Compare', 3, 4, { op: '>=', b: 0 });
const object = objectOfRound(g, 'c', 'loop', 'c', 2, 6);
- g.at('show', 'SetEnabled', 6, 0);
+ g.at('show', 'Properties', 6, 0, propertiesOf('sprite'), [written('enabled', 'boolean')]);
g.at('br', 'Branch', 7, 0);
g.at('color', 'ItemAt', 7, 4);
- g.at('paint', 'SetProperty', 8, 0, { property: 'color' });
+ g.at('paint', 'Properties', 8, 0, propertiesOf('sprite'), [written('color', 'number')]);
g.link('start', 'grid', 'cell', 'list');
g.link('loop', 'index', 'cell', 'index');
g.link('cell', 'item', 'filled', 'a');
@@ -441,7 +441,7 @@ function drawBoardGraph() {
g.link('start', 'colors', 'color', 'list');
g.link('cell', 'item', 'color', 'index');
g.link(object, 'object', 'paint', 'object');
- g.link('color', 'item', 'paint', 'value');
+ g.link('color', 'item', 'paint', 'color');
g.flow('show', 'br');
g.link('br', 'then', 'paint', 'exec');
return g.done();
@@ -643,15 +643,17 @@ function drawPieceGraph() {
link('start', 'colors', 'color', 'list');
link('of', 'shape', 'color', 'index');
- at('show', 'SetEnabled', 6, 0, { enabled: true });
- at('place', 'SetPosition', 7, 0);
- at('paint', 'SetProperty', 8, 0, { property: 'color' });
- link('cell', 'exec', 'show', 'exec');
- for (const id of ['show', 'place', 'paint']) link(object, 'object', id, 'object');
+ at('place', 'SetPosition', 6, 0);
+ at('paint', 'Properties', 7, 0, { ...propertiesOf('sprite'), enabled: true }, [
+ written('enabled', 'boolean'),
+ written('color', 'number'),
+ ]);
+ link('cell', 'exec', 'place', 'exec');
+ for (const id of ['place', 'paint']) link(object, 'object', id, 'object');
link('atx', 'result', 'place', 'x');
link('aty', 'result', 'place', 'y');
- link('color', 'item', 'paint', 'value');
- flow('show', 'place', 'paint');
+ link('color', 'item', 'paint', 'color');
+ flow('place', 'paint');
return g.done();
}
@@ -712,7 +714,7 @@ function showLinesGraph() {
g.at('number', 'ToText', 1, 3, { decimals: 0 });
g.at('caption', 'Join', 2, 3, { a: 'Lines ' });
g.at('label', 'ObjectByName', 1, 5, { name: 'LinesLabel' });
- g.at('write', 'SetText', 3, 0);
+ g.at('write', 'Properties', 3, 0, propertiesOf('text'), [written('text', 'text')]);
g.link('start', 'lines', 'number', 'value');
g.link('number', 'text', 'caption', 'b');
g.link('caption', 'text', 'write', 'text');
diff --git a/tools/smoke-jumprun.mjs b/tools/smoke-jumprun.mjs
index 4876ec06..55b75597 100644
--- a/tools/smoke-jumprun.mjs
+++ b/tools/smoke-jumprun.mjs
@@ -27,7 +27,7 @@ const STORED_COUNT = 6;
const PLAYER = instanceChild('L', 'player');
const START_BUTTON = instanceChild('T', 'title-button');
const AGAIN_BUTTON = instanceChild('W', 'win-button');
-const GRAPHS = { player: [35, 38], game: [38, 43], 'start-button': [9, 10], 'restart-button': [2, 1] };
+const GRAPHS = { player: [34, 37], game: [38, 43], 'start-button': [7, 6], 'restart-button': [2, 1] };
const GROUND_Y = 130;
await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (t) => {
diff --git a/tools/smoke-tetris.mjs b/tools/smoke-tetris.mjs
index 272b9380..30b6e600 100644
--- a/tools/smoke-tetris.mjs
+++ b/tools/smoke-tetris.mjs
@@ -31,7 +31,7 @@ const GRAPHS = {
'spawn-piece': { nodes: 6, edges: 7 },
'next-move': { nodes: 30, edges: 51 },
fits: { nodes: 18, edges: 26 },
- 'draw-piece': { nodes: 15, edges: 24 },
+ 'draw-piece': { nodes: 14, edges: 22 },
'lock-piece': { nodes: 20, edges: 23 },
'clear-lines': { nodes: 23, edges: 33 },
'show-lines': { nodes: 5, edges: 5 },
@@ -52,7 +52,7 @@ const STEPS = [
];
/** Node types that would mean the master is still doing a step's work itself. */
-const WORK_NODES = ['Repeat', 'ItemAt', 'SetItem', 'GetPosition', 'SetText', 'SetPosition'];
+const WORK_NODES = ['Repeat', 'ItemAt', 'SetItem', 'GetPosition', 'Properties', 'SetPosition'];
const PIECE = (i) => instanceChild('oboard', `piece${i}`);