Add the Variable node and show the variable name in node titles
Get/Set variable nodes now title themselves "Get <name>" / "Set <name>", and
the new Variable node just "<name>"; the title is the same folded or open,
and a collapsed node shrinks to it. The redundant "Variable: <name>" row is
gone unless the variable was deleted ("<name>?" is the only hint then).
The Variable node sets its value when an exec flow arrives and exposes the
value at any time. Its output is read when a downstream node pulls it, not
frozen at the write, so count = count + 1 is one Variable node plus an Add.
Get variable keeps reading once per pulse: switching it would change the
behaviour of existing games.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f8a308d62
commit
e9fa592078
19 files changed
+354
-38
No files matched your search
@@ -96,7 +96,7 @@ export class GraphInterpreter {
|
||||
const inputs = this.collectInputs(node, ctx);
|
||||
const result = def.run(ctx, inputs, node.params, def);
|
||||
const returned = result.call ? this.call(result.call, ctx, depth) : {};
|
||||
this.frame.outputCache.set(node.id, { ...result.outputs, ...returned });
|
||||
if (!def.read) this.frame.outputCache.set(node.id, { ...result.outputs, ...returned });
|
||||
this.trace?.onNode(node.id);
|
||||
if (result.repeat) this.loop(node, result.repeat, ctx, depth);
|
||||
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
|
||||
@@ -167,6 +167,10 @@ export class GraphInterpreter {
|
||||
if (!source) return null;
|
||||
const def = this.def(source);
|
||||
if (!def) return null;
|
||||
if (def.read) {
|
||||
this.trace?.onNode(nodeId);
|
||||
return def.read(ctx, source.params)[port] ?? null;
|
||||
}
|
||||
// Exec nodes that have not run in this pulse have no value yet.
|
||||
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
|
||||
if (++this.steps > MAX_STEPS_PER_FIRE)
|
||||
|
||||
@@ -81,6 +81,11 @@ export interface NodeDef {
|
||||
* offers, which values the behavior a call node names takes. `resolveDef` applies it.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
read?(ctx: NodeContext, params: PropertyBag): Record<string, Value>;
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -85,6 +85,7 @@ import { GetVariable } from './variable/GetVariable';
|
||||
import { GetVariables } from './variable/GetVariables';
|
||||
import { SetVariable } from './variable/SetVariable';
|
||||
import { SetVariables } from './variable/SetVariables';
|
||||
import { Variable } from './variable/Variable';
|
||||
|
||||
/**
|
||||
* Importing this module registers the built-in nodes. The palette sorts by category and label, so
|
||||
@@ -110,6 +111,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
RunBehavior,
|
||||
BehaviorEnd,
|
||||
// variables
|
||||
Variable,
|
||||
GetVariable,
|
||||
GetVariables,
|
||||
SetVariable,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { PropertyValue } from '../../model/Properties';
|
||||
import { type PropertyBag, type PropertyValue, textOf } from '../../model/Properties';
|
||||
import type { PropertyDef } from '../../properties/PropertySchema';
|
||||
import type { NodeDef, PortDef } from '../NodeRegistry';
|
||||
import { asBoolean, asNumber } from '../values';
|
||||
import type { NodeContext } from '../NodeContext';
|
||||
import type { DerivedShape, NodeDef, PortDef, PortSource, RunResult } from '../NodeRegistry';
|
||||
import { asBoolean, asNumber, type Value, writeTyped } from '../values';
|
||||
|
||||
export const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' });
|
||||
export const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' });
|
||||
@@ -33,6 +34,31 @@ export const variableParam: PropertyDef = {
|
||||
options: [],
|
||||
};
|
||||
|
||||
const variableKey = (params: PropertyBag) => textOf(params.variable ?? '');
|
||||
|
||||
export const readVariable = (ctx: NodeContext, params: PropertyBag): Record<string, Value> => ({
|
||||
value: ctx.variables[variableKey(params)] ?? null,
|
||||
});
|
||||
|
||||
export function writeVariable(
|
||||
ctx: NodeContext,
|
||||
inputs: Record<string, Value>,
|
||||
params: PropertyBag,
|
||||
): RunResult {
|
||||
const key = variableKey(params);
|
||||
if (key) writeTyped(ctx.variables, key, inputs.value);
|
||||
return { next: 'exec' };
|
||||
}
|
||||
|
||||
/** Once a variable is picked, the title names it: "Get speed", or just "speed". */
|
||||
export const variableTitle =
|
||||
(verb: string) =>
|
||||
({ node }: PortSource): DerivedShape => {
|
||||
const name = variableKey(node.params);
|
||||
if (!name) return {};
|
||||
return { label: verb ? `${verb} ${name}` : name };
|
||||
};
|
||||
|
||||
/** No `assetTypes`: any placeable asset is a valid answer here. */
|
||||
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { any, variableParam } from '../shared';
|
||||
import { any, readVariable, variableParam, variableTitle } from '../shared';
|
||||
|
||||
import { textOf } from '../../../model/Properties';
|
||||
export const GetVariable: NodeDef = {
|
||||
type: 'GetVariable',
|
||||
label: 'Get variable',
|
||||
@@ -10,7 +9,6 @@ export const GetVariable: NodeDef = {
|
||||
inputs: [],
|
||||
outputs: [any('value')],
|
||||
params: [variableParam],
|
||||
run: (ctx, _inputs, params) => ({
|
||||
outputs: { value: ctx.variables[textOf(params.variable ?? '')] ?? null },
|
||||
}),
|
||||
derivePorts: variableTitle('Get'),
|
||||
run: (ctx, _inputs, params) => ({ outputs: readVariable(ctx, params) }),
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { writeTyped } from '../../values';
|
||||
import { any, exec, variableParam } from '../shared';
|
||||
import { any, exec, variableParam, variableTitle, writeVariable } from '../shared';
|
||||
|
||||
import { textOf } from '../../../model/Properties';
|
||||
export const SetVariable: NodeDef = {
|
||||
type: 'SetVariable',
|
||||
label: 'Set variable',
|
||||
@@ -11,9 +9,6 @@ export const SetVariable: NodeDef = {
|
||||
inputs: [exec(), any('value')],
|
||||
outputs: [exec()],
|
||||
params: [variableParam],
|
||||
run: (ctx, inputs, params) => {
|
||||
const key = textOf(params.variable ?? '');
|
||||
if (key) writeTyped(ctx.variables, key, inputs.value);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
derivePorts: variableTitle('Set'),
|
||||
run: writeVariable,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { any, exec, readVariable, variableParam, variableTitle, writeVariable } from '../shared';
|
||||
|
||||
/** Get and Set in one node: the output can be read at any time, an arriving flow writes the input. */
|
||||
export const Variable: NodeDef = {
|
||||
type: 'Variable',
|
||||
label: 'Variable',
|
||||
category: 'variable',
|
||||
description:
|
||||
"Reads one of this behavior's variables at any time, and writes it when the flow arrives. " +
|
||||
'A read after the write sees the new value.',
|
||||
inputs: [exec(), any('value')],
|
||||
outputs: [exec(), any('value')],
|
||||
params: [variableParam],
|
||||
derivePorts: variableTitle(''),
|
||||
read: readVariable,
|
||||
run: writeVariable,
|
||||
};
|
||||
@@ -56,10 +56,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
* *minimum* width, so a label wider than the column pushes its control right – which is why a
|
||||
* fixed offset from the label's own text is the wrong way to click one.
|
||||
*/
|
||||
const fieldOf = (label: string): { x: number; y: number; left: number } | null => {
|
||||
const fieldOf = (label: string, last = false): { x: number; y: number; left: number } | null => {
|
||||
let found: Text | null = null;
|
||||
const walk = (node: Container): void => {
|
||||
if (found !== null || !node.visible) return;
|
||||
if ((found !== null && !last) || !node.visible) return;
|
||||
if (node instanceof Text && node.text === label) {
|
||||
found = node;
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Graphics, Rectangle, Text } from 'pixi.js';
|
||||
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
|
||||
import {
|
||||
asString,
|
||||
edgesInto,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
inlineValue,
|
||||
type NodeCategory,
|
||||
type NodeDef,
|
||||
nodeDef,
|
||||
type PortDef,
|
||||
type PortType,
|
||||
type Value,
|
||||
@@ -19,6 +20,7 @@ import { assetRefLabel } from '../../core/properties/assetRef';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
|
||||
import { keyLabel } from '../inspector/fieldFactory';
|
||||
import { collapsedNodeWidth } from './folding';
|
||||
|
||||
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
|
||||
export const NODE_WIDTH = 176;
|
||||
@@ -30,6 +32,9 @@ export const PORT_HIT_RADIUS = 9;
|
||||
const PAD = 6;
|
||||
/** Room in front of the title for the collapse chevron, as wide as the hierarchy's. */
|
||||
const CHEVRON_SLOT = 14;
|
||||
const TITLE_X = PAD + CHEVRON_SLOT;
|
||||
/** Right of a collapsed title: the side's link dot sits centred on the edge. */
|
||||
const COLLAPSED_TITLE_END = PAD + PORT_RADIUS;
|
||||
|
||||
export function categoryColor(category: NodeCategory): number {
|
||||
const c = Theme.colors;
|
||||
@@ -126,15 +131,17 @@ export class NodeView extends Widget {
|
||||
this.def = def;
|
||||
this.problem = problem;
|
||||
const hidden = new Set(this.def.hiddenParams?.(node.params) ?? []);
|
||||
const title = this.def.label;
|
||||
const derived = title !== nodeDef(node.type)?.label;
|
||||
this.paramLines = this.def.params
|
||||
.filter((p) => !hidden.has(p.key))
|
||||
.map((p) => ({
|
||||
label: p.label,
|
||||
value: formatParam(p, node.params[p.key] ?? p.default, asset, project),
|
||||
}))
|
||||
// A setting the title already states is not worth a row: a nested-behavior node is titled
|
||||
// with the behavior it runs, so "Behavior: Double" under "Double" says nothing new.
|
||||
.filter((p) => p.value !== this.def.label)
|
||||
// A title made from a setting states it ("Behavior: Double" under "Double", "Variable: speed"
|
||||
// under "Get speed"). A dangling reference keeps its row: "speed?" is not in the title.
|
||||
.filter((p) => !derived || (p.value !== title && !title.endsWith(` ${p.value}`)))
|
||||
.map((p) => `${p.label}: ${p.value}`);
|
||||
this.connected = new Set(
|
||||
inlineInputs(this.def)
|
||||
@@ -146,17 +153,23 @@ export class NodeView extends Widget {
|
||||
outputs: graph.edges.some((e) => e.from.node === node.id),
|
||||
};
|
||||
const height = this.collapsed ? TITLE_HEIGHT : this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD;
|
||||
// Sized before the texts: output labels are placed against the right edge.
|
||||
this.resize(this.collapsed ? this.collapsedWidth() : NODE_WIDTH, height);
|
||||
this.rebuildTexts();
|
||||
this.resize(NODE_WIDTH, height);
|
||||
this.position.set(node.x, node.y);
|
||||
}
|
||||
|
||||
private collapsedWidth(): number {
|
||||
const title = CanvasTextMetrics.measureText(this.def.label, textStyle('accent')).width;
|
||||
return collapsedNodeWidth(title, TITLE_X + COLLAPSED_TITLE_END, NODE_WIDTH);
|
||||
}
|
||||
|
||||
get collapsed(): boolean {
|
||||
return this.node.collapsed === true;
|
||||
}
|
||||
|
||||
chevronAt(localX: number, localY: number): boolean {
|
||||
return localY >= 0 && localY < TITLE_HEIGHT && localX >= PAD - 2 && localX < PAD + CHEVRON_SLOT;
|
||||
return localY >= 0 && localY < TITLE_HEIGHT && localX >= PAD - 2 && localX < TITLE_X;
|
||||
}
|
||||
|
||||
chevronGlobal(): { x: number; y: number } {
|
||||
@@ -233,10 +246,10 @@ export class NodeView extends Widget {
|
||||
add(
|
||||
this.def.label,
|
||||
'accent',
|
||||
PAD + CHEVRON_SLOT,
|
||||
TITLE_X,
|
||||
(TITLE_HEIGHT - 16) / 2,
|
||||
'left',
|
||||
NODE_WIDTH - PAD * 2 - CHEVRON_SLOT,
|
||||
this.w - TITLE_X - (this.collapsed ? COLLAPSED_TITLE_END : PAD),
|
||||
);
|
||||
if (this.collapsed) return;
|
||||
let y = TITLE_HEIGHT + PAD;
|
||||
|
||||
@@ -21,6 +21,11 @@ export function foldMarkKey(
|
||||
return `${side.node}\0${collapsed ? '' : side.port}\0${isOutput ? 'out' : 'in'}`;
|
||||
}
|
||||
|
||||
/** A collapsed node is as wide as its title needs, but never wider than an open one. */
|
||||
export function collapsedNodeWidth(titleWidth: number, chrome: number, openWidth: number): number {
|
||||
return Math.min(openWidth, Math.ceil(titleWidth + chrome));
|
||||
}
|
||||
|
||||
/** One name plus a count: three names do not fit, and the count is what says "there is more here". */
|
||||
export function foldMarkLabel(names: readonly string[]): string {
|
||||
if (names.length === 0) return '';
|
||||
|
||||
Reference in new issue
Block a user