Describe every value kind in one table instead of spreading it over the code

A kind like colour or texture used to be spelled out in five parallel
lists, its port type, its empty value, its glyph, its label and its list
tag - 22 literal places for texture alone. core/properties/kinds.ts now
holds one row per kind and the rest is derived from it, so adding a kind
is one row. The five literal nodes collapse into literals.ts, the six
kind menus into one helper.

Number, Text, Boolean is now the one order everywhere; the variable and
port menus used to list Boolean before Text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-12 14:47:17 +02:00
1 parent 8a7185948d
commit 3ed8b68ecf
39 files changed
+699 -565

No files matched your search

+5 -16
View File
@@ -1,4 +1,7 @@
import type { PropertyBag } from '../model/Properties';
import { type DeclaredPortKind, KIND } from '../properties/kinds';
export type { DeclaredPortKind } from '../properties/kinds';
/** `exec` carries control flow, the others values; `any` accepts every value type. */
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'any';
@@ -28,7 +31,7 @@ export interface GraphNode {
collapsed?: boolean;
}
/** A port a start/end node declares. `kind` maps to the PortType of the same name (`text` → string). */
/** A port a start/end node declares. `kind` travels as the kind table says (`text` → string). */
export interface DeclaredPort {
key: string;
kind: DeclaredPortKind;
@@ -36,22 +39,8 @@ export interface DeclaredPort {
write?: true;
}
/** A color travels as a number and a texture as text; the kind says which field edits the value. */
export const DECLARED_PORT_KINDS = [
'number',
'boolean',
'text',
'color',
'texture',
'object',
'list',
'struct',
] as const;
export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number];
export function declaredPortType(kind: DeclaredPortKind): PortType {
if (kind === 'text' || kind === 'texture') return 'string';
return kind === 'color' ? 'number' : kind;
return KIND[kind].travels;
}
export interface GraphEdge {
+8 -19
View File
@@ -1,9 +1,10 @@
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { PropertyDef } from '../properties/PropertySchema';
import { isKindWith, plainKindOf } from '../properties/kinds';
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
import type { BehaviorLookup } from './lookup';
import type { NodeContext } from './NodeContext';
import { asBoolean, asList, asNumber, asString, type Value } from './values';
import { asList, type Value } from './values';
export interface PortDef {
key: string;
@@ -142,23 +143,11 @@ export function withInlineDefault(port: PortDef): PortDef {
export function inlineInputDef(port: PortDef): PropertyDef {
if (port.field) return { ...port.field, key: port.key, label: port.label };
switch (port.type) {
case 'boolean':
return { key: port.key, label: port.label, kind: 'boolean', default: asBoolean(port.default ?? false) };
case 'string':
case 'any':
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
case 'list':
return { key: port.key, label: port.label, kind: 'list', default: asList(port.default) };
default:
return {
key: port.key,
label: port.label,
kind: 'number',
default: asNumber(port.default ?? 0),
step: 1,
};
}
const kind = port.type === 'any' ? 'text' : plainKindOf(port.type);
if (kind === 'list') return { key: port.key, label: port.label, kind, default: asList(port.default) };
const flat = kind !== undefined && isKindWith('field', kind) ? kind : 'number';
const def = flatDef(port.key, flat, { label: port.label, value: port.default });
return def.kind === 'number' ? { ...def, step: 1 } : def;
}
/** The value an unconnected input carries: the node's own inline value, else the port default. */
+4 -7
View File
@@ -1,5 +1,6 @@
import { textOf } from '../model/Properties';
import { isValidFieldName } from '../properties/PropertySchema';
import { isTaggedKind } from '../properties/kinds';
import { flatDef, isValidFieldName } from '../properties/PropertySchema';
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
import type { BehaviorLookup } from './lookup';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
@@ -75,14 +76,10 @@ export function isTerminalNode(node: GraphNode): boolean {
return isTerminalType(node.type);
}
/** A color or texture port carries the field that edits it; the graph itself sees a number or a text. */
/** A tagged kind is one the value cannot show, so its port carries the field that edits it. */
export function portDefOf(port: DeclaredPort): PortDef {
const def: PortDef = { key: port.key, label: port.key, type: declaredPortType(port.kind) };
if (port.kind === 'color')
return { ...def, field: { key: port.key, label: port.key, kind: 'color', default: 0 } };
if (port.kind === 'texture')
return { ...def, field: { key: port.key, label: port.key, kind: 'texture', default: '' } };
return def;
return isTaggedKind(port.kind) ? { ...def, field: flatDef(port.key, port.kind) } : def;
}
/** The ports the start (or end) node of `graph` declares, as seen from a node calling it. */
-14
View File
@@ -1,14 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asBoolean } from '../../values';
import { bool } from '../shared';
export const BooleanLiteral: NodeDef = {
type: 'Boolean',
label: 'Boolean',
category: 'data',
description: 'A constant true/false.',
inputs: [],
outputs: [bool('value')],
params: [{ key: 'value', label: 'Value', kind: 'boolean', default: false }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asBoolean(params.value) } }),
};
+3 -4
View File
@@ -1,5 +1,4 @@
import { sameValue } from '../../../model/Properties';
import { itemValue } from '../../../properties/listItems';
import { sameValue, untag } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asListItem } from '../../values';
import { any, list, num } from '../shared';
@@ -15,9 +14,9 @@ export const IndexOf: NodeDef = {
// Compared by content, not by identity: two equal structs are never the same object. A color item
// is compared as its number, or searching a list of colors with one would never find it.
run: (_ctx, inputs) => {
const wanted = itemValue(asListItem(inputs.value));
const wanted = untag(asListItem(inputs.value));
return {
outputs: { index: asList(inputs.list).findIndex((item) => sameValue(itemValue(item), wanted)) },
outputs: { index: asList(inputs.list).findIndex((item) => sameValue(untag(item), wanted)) },
};
},
};
+2 -2
View File
@@ -1,4 +1,4 @@
import { itemValue } from '../../../properties/listItems';
import { untag } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber } from '../../values';
import { any, int, list } from '../shared';
@@ -14,7 +14,7 @@ export const ItemAt: NodeDef = {
run: (_ctx, inputs) => {
const items = asList(inputs.list);
const i = Math.floor(asNumber(inputs.index));
return { outputs: { item: itemValue(items[i]) ?? 0 } };
return { outputs: { item: untag(items[i]) ?? 0 } };
},
params: [],
};
-14
View File
@@ -1,14 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asList } from '../../values';
import { list } from '../shared';
export const ListLiteral: NodeDef = {
type: 'List',
label: 'List',
category: 'data',
description: 'A constant list, edited item by item in the List tab – each item with its own kind.',
inputs: [],
outputs: [list('value')],
params: [{ key: 'value', label: 'Value', kind: 'list', default: [] }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asList(params.value) } }),
};
-14
View File
@@ -1,14 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { num } from '../shared';
export const NumberLiteral: NodeDef = {
type: 'Number',
label: 'Number',
category: 'data',
description: 'A constant number.',
inputs: [],
outputs: [num('value')],
params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: 1 }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asNumber(params.value) } }),
};
-14
View File
@@ -1,14 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asString } from '../../values';
import { str } from '../shared';
export const StringLiteral: NodeDef = {
type: 'String',
label: 'Text',
category: 'data',
description: 'A constant text.',
inputs: [],
outputs: [str('value')],
params: [{ key: 'value', label: 'Value', kind: 'text', default: '' }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asString(params.value) } }),
};
-15
View File
@@ -1,15 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asString } from '../../values';
import { str } from '../shared';
/** The picture is its id on the edge: a whole texture, one frame of a sheet or an animation. */
export const TextureLiteral: NodeDef = {
type: 'Texture',
label: 'Texture',
category: 'data',
description: 'A constant picture: a texture, one of its frames or an animation, as a sprite wears it.',
inputs: [],
outputs: [str('value')],
params: [{ key: 'value', label: 'Value', kind: 'texture', default: '' }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asString(params.value) } }),
};
+37
View File
@@ -0,0 +1,37 @@
import { KIND, kindLabel, type VariableKind } from '../../../properties/kinds';
import { coerceValue, flatDef, type PropertyDef } from '../../../properties/PropertySchema';
import type { NodeDef } from '../../NodeRegistry';
/** A node handing out one constant of a kind: its one setting, edited as that kind, is its output. */
function literal(type: string, kind: Exclude<VariableKind, 'struct'>, description: string): NodeDef {
const value: PropertyDef =
kind === 'list'
? { key: 'value', label: 'Value', kind, default: [] }
: kind === 'number'
? { ...flatDef('value', kind, { label: 'Value' }), step: 1 }
: flatDef('value', kind, { label: 'Value' });
return {
type,
label: kindLabel(kind),
category: 'data',
description,
inputs: [],
outputs: [{ key: 'value', label: 'value', type: KIND[kind].travels }],
params: [value],
run: (_ctx, _inputs, params) => ({ outputs: { value: coerceValue(value, params.value ?? value.default) } }),
};
}
export const NumberLiteral = literal('Number', 'number', 'A constant number.');
export const BooleanLiteral = literal('Boolean', 'boolean', 'A constant true/false.');
export const StringLiteral = literal('String', 'text', 'A constant text.');
export const TextureLiteral = literal(
'Texture',
'texture',
'A constant picture: a texture, one of its frames or an animation, as a sprite wears it.',
);
export const ListLiteral = literal(
'List',
'list',
'A constant list, edited item by item in the List tab – each item with its own kind.',
);
+1 -5
View File
@@ -10,7 +10,6 @@ import { Abs } from './data/Abs';
import { Add } from './data/Add';
import { And } from './data/And';
import { AxisInput } from './data/AxisInput';
import { BooleanLiteral } from './data/Boolean';
import { Ceil } from './data/Ceil';
import { Clamp } from './data/Clamp';
import { Compare } from './data/Compare';
@@ -27,6 +26,7 @@ import { IsTouching } from './data/IsTouching';
import { Join } from './data/Join';
import { KeyIsDown } from './data/KeyIsDown';
import { Length } from './data/Length';
import { BooleanLiteral, ListLiteral, NumberLiteral, StringLiteral, TextureLiteral } from './data/literals';
import { Max } from './data/Max';
import { Min } from './data/Min';
import { Modulo } from './data/Modulo';
@@ -36,7 +36,6 @@ import { Multiply } from './data/Multiply';
import { NameOf } from './data/NameOf';
import { Normalize } from './data/Normalize';
import { Not } from './data/Not';
import { NumberLiteral } from './data/Number';
import { ObjectByName } from './data/ObjectByName';
import { Or } from './data/Or';
import { Owner } from './data/Owner';
@@ -47,10 +46,8 @@ import { Random } from './data/Random';
import { Round } from './data/Round';
import { Self } from './data/Self';
import { Sqrt } from './data/Sqrt';
import { StringLiteral } from './data/String';
import { Substring } from './data/Substring';
import { Subtract } from './data/Subtract';
import { TextureLiteral } from './data/Texture';
import { Time } from './data/Time';
import { ToNumber } from './data/ToNumber';
import { ToText } from './data/ToText';
@@ -60,7 +57,6 @@ import { ExpandStruct } from './data/ExpandStruct';
import { Fill } from './data/Fill';
import { IndexOf } from './data/IndexOf';
import { ItemAt } from './data/ItemAt';
import { ListLiteral } from './data/ListLiteral';
import { SetItem } from './data/SetItem';
import { SubList } from './data/SubList';
import { OnAnimationFinished } from './events/OnAnimationFinished';
+6 -5
View File
@@ -1,12 +1,13 @@
import { type DeclaredPortKind, isKindWith, KIND, plainKindOf } from '../properties/kinds';
import { itemKindOf, structItemFields } from '../properties/listItems';
import type { PropertyDef, StructFieldDef } from '../properties/PropertySchema';
import { DECLARED_PORT_KINDS, type DeclaredPort, type DeclaredPortKind, type GraphNode } from './Graph';
import type { DeclaredPort, GraphNode } from './Graph';
/** The port a variable or property travels on. An enum, a key or an asset is text. */
/** The port a variable or property travels on. A kind no port declares (an enum, a key) is what it travels as. */
export function portKindOf(def: PropertyDef): DeclaredPortKind {
return (DECLARED_PORT_KINDS as readonly string[]).includes(def.kind)
? (def.kind as DeclaredPortKind)
: 'text';
if (isKindWith('port', def.kind)) return def.kind;
const plain = plainKindOf(KIND[def.kind].travels);
return plain !== undefined && isKindWith('port', plain) ? plain : 'text';
}
/**