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}`);