Make texture and colour full kinds and drop the load-time migrations
- `Texture` and `Color` can be picked as a variable kind, a struct field kind and a declared port kind; a new `Texture` node hands a picture id into the graph. Declared, a texture is its bare id – the `$texture` tag stays a list-only thing. - The `String` node is labelled "Text" like its kind; the type id stays. - Whole-number inputs (`Item at`, `Set item`, `Sub list`, `Fill`, `Repeat`, `Substring`, `To text`) show no decimals (`int()` in shared.ts). - All server projects are reseeded, so `legacyNodes.ts`, `migrateDeclaredLists` and the `ownerId` fallback go, with their tests. - Note `reseed-because-of-list` moved to done/; new note `refactor-types`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
11c900c998
commit
8a7185948d
39 files changed
+378
-765
No files matched your search
+13
-2
@@ -36,11 +36,22 @@ export interface DeclaredPort {
|
||||
write?: true;
|
||||
}
|
||||
|
||||
export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object', 'list', 'struct'] as const;
|
||||
/** 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 {
|
||||
return kind === 'text' ? 'string' : kind;
|
||||
if (kind === 'text' || kind === 'texture') return 'string';
|
||||
return kind === 'color' ? 'number' : kind;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
|
||||
@@ -75,8 +75,14 @@ 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. */
|
||||
export function portDefOf(port: DeclaredPort): PortDef {
|
||||
return { key: port.key, label: port.key, type: declaredPortType(port.kind) };
|
||||
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;
|
||||
}
|
||||
|
||||
/** The ports the start (or end) node of `graph` declares, as seen from a node calling it. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asListItem, asNumber } from '../../values';
|
||||
import { any, list, num, withDefault } from '../shared';
|
||||
import { any, int, list } from '../shared';
|
||||
|
||||
/** Same order of magnitude as the interpreter's step limit – a typo in `count` must not eat memory. */
|
||||
const MAX_ITEMS = 100_000;
|
||||
@@ -10,7 +10,7 @@ export const Fill: NodeDef = {
|
||||
label: 'Fill',
|
||||
category: 'data',
|
||||
description: 'A new list of the same value repeated – how an empty board is made.',
|
||||
inputs: [any('value'), withDefault(num('count'), 1)],
|
||||
inputs: [any('value'), int('count', 1)],
|
||||
outputs: [list('result')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { itemValue } from '../../../properties/listItems';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asNumber } from '../../values';
|
||||
import { any, list, num } from '../shared';
|
||||
import { any, int, list } from '../shared';
|
||||
|
||||
export const ItemAt: NodeDef = {
|
||||
type: 'ItemAt',
|
||||
label: 'Item at',
|
||||
category: 'data',
|
||||
description: 'The item at an index, counting from 0. Out of range gives 0.',
|
||||
inputs: [list('list'), num('index')],
|
||||
inputs: [list('list'), int('index')],
|
||||
outputs: [any('item')],
|
||||
// A color item reads as its number: the graph has no color of its own (see `portKindOf`).
|
||||
run: (_ctx, inputs) => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { itemLike } from '../../../properties/listItems';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asListItem, asNumber } from '../../values';
|
||||
import { any, list, num } from '../shared';
|
||||
import { any, int, list } from '../shared';
|
||||
|
||||
export const SetItem: NodeDef = {
|
||||
type: 'SetItem',
|
||||
label: 'Set item',
|
||||
category: 'data',
|
||||
description: 'The same list with one item replaced. The list that came in is left alone.',
|
||||
inputs: [list('list'), num('index'), any('value')],
|
||||
inputs: [list('list'), int('index'), any('value')],
|
||||
outputs: [list('result')],
|
||||
run: (_ctx, inputs) => {
|
||||
const items = asList(inputs.list);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { str } from '../shared';
|
||||
|
||||
export const StringLiteral: NodeDef = {
|
||||
type: 'String',
|
||||
label: 'String',
|
||||
label: 'Text',
|
||||
category: 'data',
|
||||
description: 'A constant text.',
|
||||
inputs: [],
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asNumber } from '../../values';
|
||||
import { list, num, withDefault } from '../shared';
|
||||
import { int, list } from '../shared';
|
||||
|
||||
export const SubList: NodeDef = {
|
||||
type: 'SubList',
|
||||
label: 'Sub list',
|
||||
category: 'data',
|
||||
description: 'A part of a list: "count" items starting at "start".',
|
||||
inputs: [list('list'), num('start'), withDefault(num('count'), 1)],
|
||||
inputs: [list('list'), int('start'), int('count', 1)],
|
||||
outputs: [list('result')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber, asString } from '../../values';
|
||||
import { num, str, withDefault } from '../shared';
|
||||
import { int, str } from '../shared';
|
||||
|
||||
export const Substring: NodeDef = {
|
||||
type: 'Substring',
|
||||
@@ -9,7 +9,7 @@ export const Substring: NodeDef = {
|
||||
description:
|
||||
'A part of a text: "count" characters starting at "start" (the first is 0). ' +
|
||||
'A negative count reaches to the end.',
|
||||
inputs: [str('text'), num('start'), withDefault(num('count'), -1)],
|
||||
inputs: [str('text'), int('start'), int('count', -1)],
|
||||
outputs: [str('text')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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) } }),
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { any, num, str } from '../shared';
|
||||
import { any, int, str } from '../shared';
|
||||
|
||||
export const ToText: NodeDef = {
|
||||
type: 'ToText',
|
||||
label: 'To text',
|
||||
category: 'data',
|
||||
description: 'Turns a number into text with a fixed number of decimals.',
|
||||
inputs: [any('value'), num('decimals')],
|
||||
inputs: [any('value'), int('decimals')],
|
||||
outputs: [str('text')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { exec, num, withDefault } from '../shared';
|
||||
import { exec, int, num } from '../shared';
|
||||
|
||||
export const Repeat: NodeDef = {
|
||||
type: 'Repeat',
|
||||
@@ -9,7 +9,7 @@ export const Repeat: NodeDef = {
|
||||
description:
|
||||
'Runs "body" a number of times, counting "index" up from 0, and continues at "done" afterwards. ' +
|
||||
'Every round reads its data inputs anew, so an expression built on "index" changes with it.',
|
||||
inputs: [exec(), withDefault(num('times'), 1)],
|
||||
inputs: [exec(), int('times', 1)],
|
||||
outputs: [exec('body', 'body'), num('index'), exec('done', 'done')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => ({
|
||||
|
||||
@@ -50,6 +50,7 @@ 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';
|
||||
@@ -121,6 +122,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
NumberLiteral,
|
||||
BooleanLiteral,
|
||||
StringLiteral,
|
||||
TextureLiteral,
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
|
||||
@@ -14,6 +14,15 @@ export const any = (key: string, label = key): PortDef => ({ key, label, type: '
|
||||
|
||||
export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value });
|
||||
|
||||
/** A whole-number input (an index, a count): its field shows no decimals, the node floors it anyway. */
|
||||
export const int = (key: string, value = 0): PortDef => ({
|
||||
key,
|
||||
label: key,
|
||||
type: 'number',
|
||||
default: value,
|
||||
field: { key, label: key, kind: 'number', default: value, step: 1, decimals: 0 },
|
||||
});
|
||||
|
||||
/** Outputs of the three collision events: the other body, its collider and the own collider. */
|
||||
export const collisionOutputs = (): PortDef[] => [
|
||||
exec(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 } from './Graph';
|
||||
import { type DeclaredPort, declaredPortType } from './Graph';
|
||||
import { portDefOf } from './nested';
|
||||
import { type PortDef, withInlineDefault } from './NodeRegistry';
|
||||
import { portKindOf } from './variablePorts';
|
||||
@@ -55,10 +55,15 @@ 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. */
|
||||
/** A port names the property when it travels as that property does: a `color` port and a `number` port both do. */
|
||||
export function isPortOf(port: DeclaredPort, def: PropertyDef): boolean {
|
||||
return declaredPortType(portKindOf(def)) === declaredPortType(port.kind);
|
||||
}
|
||||
|
||||
/** Carries the def its type has under that key (default, field); under Any or at another type, none. */
|
||||
export function propertyPort(port: DeclaredPort, params: PropertyBag): PortDef {
|
||||
const plain = withInlineDefault(portDefOf(port));
|
||||
const field = targetProperties(propertyTargetOf(params)).find((def) => def.key === port.key);
|
||||
if (!field || portKindOf(field) !== port.kind) return plain;
|
||||
if (!field || !isPortOf(port, field)) return plain;
|
||||
return { ...plain, field, default: field.default };
|
||||
}
|
||||
@@ -2,19 +2,13 @@ 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';
|
||||
|
||||
/** The port a variable or property travels on. A color is a number; an enum or a texture is text. */
|
||||
/** The port a variable or property travels on. An enum, a key or an asset is text. */
|
||||
export function portKindOf(def: PropertyDef): DeclaredPortKind {
|
||||
if (def.kind === 'color') return 'number';
|
||||
return (DECLARED_PORT_KINDS as readonly string[]).includes(def.kind)
|
||||
? (def.kind as DeclaredPortKind)
|
||||
: 'text';
|
||||
}
|
||||
|
||||
/** A color is a number in the value model, and the graph has no port kind of its own for it. */
|
||||
export function fieldPortKind(field: StructFieldDef): DeclaredPortKind {
|
||||
return field.kind === 'color' ? 'number' : field.kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundle shape of a variable, if it has one: a struct variable declares it, a list takes it from
|
||||
* the first struct item its default holds – items declare nothing, so the value is the only source.
|
||||
@@ -33,5 +27,5 @@ export function structShapeOf(def: PropertyDef): readonly StructFieldDef[] | und
|
||||
*/
|
||||
export function missingFieldPorts(node: GraphNode, fields: readonly StructFieldDef[]): DeclaredPort[] {
|
||||
const taken = new Set((node.ports ?? []).map((p) => p.key));
|
||||
return fields.filter((f) => !taken.has(f.key)).map((f) => ({ key: f.key, kind: fieldPortKind(f) }));
|
||||
return fields.filter((f) => !taken.has(f.key)).map((f) => ({ key: f.key, kind: f.kind }));
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Depth is capped: `list → struct → primitive` is the deepest a stored value goes. */
|
||||
export type ListValue = readonly (number | string | boolean | StructValue)[];
|
||||
|
||||
/** What a struct field may hold. A field is never itself a struct – except a tagged colour. */
|
||||
export type FieldValue = number | string | boolean | ListValue | ColorItem;
|
||||
/** What a struct field may hold. A field is never itself a struct – except a tagged colour or texture. */
|
||||
export type FieldValue = number | string | boolean | ListValue | ColorItem | TextureItem;
|
||||
|
||||
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
|
||||
export type StructValue = { readonly [field: string]: FieldValue };
|
||||
|
||||
@@ -61,11 +61,11 @@ export type PropertyDef =
|
||||
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
|
||||
|
||||
/** The primitives. No `struct` anywhere below: that is the rule that bounds the nesting depth. */
|
||||
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color'] as const;
|
||||
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color', 'texture'] as const;
|
||||
export type FieldKind = (typeof FIELD_KINDS)[number];
|
||||
|
||||
/** What one item of a list may be – a primitive plus `struct`, so a bundle can sit in a list. */
|
||||
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'texture', 'struct'] as const;
|
||||
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
|
||||
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
|
||||
|
||||
/** What a field of a struct property holds – a primitive plus `list`. */
|
||||
@@ -89,7 +89,7 @@ export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
|
||||
|
||||
export type PropertySchema = readonly PropertyDef[];
|
||||
|
||||
export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'list', 'struct'] as const;
|
||||
export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'color', 'texture', 'list', 'struct'] as const;
|
||||
export type VariableKind = (typeof VARIABLE_KINDS)[number];
|
||||
|
||||
/**
|
||||
@@ -243,7 +243,10 @@ export function variableDef(
|
||||
case 'boolean':
|
||||
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
|
||||
case 'text':
|
||||
case 'texture':
|
||||
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
|
||||
case 'color':
|
||||
return { key, label: key, kind, default: colorOf(defaultValue ?? 0, 0) };
|
||||
case 'list':
|
||||
return { key, label: key, kind, default: (isList(defaultValue) ? defaultValue : []).map(listItemOf) };
|
||||
case 'struct': {
|
||||
@@ -259,6 +262,7 @@ const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
|
||||
text: '',
|
||||
boolean: false,
|
||||
color: 0,
|
||||
texture: '',
|
||||
};
|
||||
|
||||
export function emptyFieldValue(field: StructFieldDef): FieldValue {
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* texture need a tag (`COLOR_TAG`, `TEXTURE_TAG`) – which keeps every list ever stored readable.
|
||||
*
|
||||
* Depth is still capped the way the declarations used to cap it: a struct item holds flat items,
|
||||
* and so does a list inside a struct field. A texture is a list item only; in a flat position it
|
||||
* keeps its id as text rather than vanishing.
|
||||
* and so does a list inside a struct field.
|
||||
*/
|
||||
import {
|
||||
booleanOf,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
textOf,
|
||||
type TextureItem,
|
||||
textureItem,
|
||||
textureItemValue,
|
||||
} from '../model/Properties';
|
||||
@@ -29,8 +29,8 @@ import type { FieldKind, ListItemKind } from './PropertySchema';
|
||||
/** What a list holds. Identical to `ListValue[number]`, named for the places that mean one item. */
|
||||
export type ListItem = number | string | boolean | StructValue;
|
||||
|
||||
/** What a flat position holds: no struct except a tagged color, so the depth stays capped. */
|
||||
export type FlatItem = number | string | boolean | ColorItem;
|
||||
/** What a flat position holds: no struct except a tagged color or texture, so the depth stays capped. */
|
||||
export type FlatItem = number | string | boolean | ColorItem | TextureItem;
|
||||
|
||||
/** The kind an item is, taken from the item itself – there is nowhere else it could come from. */
|
||||
export function itemKindOf(item: PropertyValue | undefined): ListItemKind {
|
||||
@@ -42,10 +42,10 @@ export function itemKindOf(item: PropertyValue | undefined): ListItemKind {
|
||||
return 'number';
|
||||
}
|
||||
|
||||
/** Flat positions hold no struct and no texture, so either of them reads as its text. */
|
||||
/** Flat positions hold no struct, so one reads as its text. */
|
||||
export function fieldKindOf(value: PropertyValue | undefined): FieldKind {
|
||||
const kind = itemKindOf(value);
|
||||
return kind === 'struct' || kind === 'texture' ? 'text' : kind;
|
||||
return kind === 'struct' ? 'text' : kind;
|
||||
}
|
||||
|
||||
/** What the widgets and the graph see: a tag is what it wraps, everything else is itself. */
|
||||
@@ -77,9 +77,9 @@ export function itemLike(current: PropertyValue | undefined, value: PropertyValu
|
||||
|
||||
/** `value` as a flat item of `kind` – a struct item's field, where a struct itself cannot go. */
|
||||
export function makeFlatItem(kind: FieldKind, value: PropertyValue | undefined): FlatItem {
|
||||
return kind === 'color'
|
||||
? colorItem(colorOf(itemValue(value) ?? 0, 0))
|
||||
: coerceField(itemValue(value), kind);
|
||||
if (kind === 'color') return colorItem(colorOf(itemValue(value) ?? 0, 0));
|
||||
if (kind === 'texture') return textureItem(textOf(itemValue(value)));
|
||||
return coerceField(itemValue(value), kind);
|
||||
}
|
||||
|
||||
/** A flat slot (a struct item's field, an item of a list in a struct field) keeps its kind too. */
|
||||
@@ -93,12 +93,12 @@ export function listItemOf(value: PropertyValue | undefined): ListItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* A flat position holds a primitive or a tagged color. Anything deeper is dropped rather than
|
||||
* flattened – the same bound the declarations used to draw, and the one the store writes.
|
||||
* A flat position holds a primitive or a tagged color or texture. Anything deeper is dropped rather
|
||||
* than flattened – the same bound the declarations used to draw, and the one the store writes.
|
||||
*/
|
||||
export function flatItemOf(value: PropertyValue | undefined): FlatItem | undefined {
|
||||
if (isColorItem(value)) return colorItem(colorItemValue(value));
|
||||
if (isTextureItem(value)) return textureItemValue(value);
|
||||
if (isTextureItem(value)) return textureItem(textureItemValue(value));
|
||||
if (isList(value) || isStruct(value)) return undefined;
|
||||
return coerceField(value, fieldKindOf(value));
|
||||
}
|
||||
@@ -126,9 +126,9 @@ export function structItemFields(item: PropertyValue | undefined): { key: string
|
||||
return Object.entries(item).map(([key, value]) => ({ key, kind: fieldKindOf(value) }));
|
||||
}
|
||||
|
||||
/** A primitive as one of the four kinds. The spelling of a text counts, see `booleanOf`. */
|
||||
/** A primitive as one of the flat kinds. The spelling of a text counts, see `booleanOf`. */
|
||||
export function coerceField(value: PropertyValue | undefined, kind: FieldKind): number | string | boolean {
|
||||
if (kind === 'text') return textOf(value);
|
||||
if (kind === 'text' || kind === 'texture') return textOf(value);
|
||||
if (kind === 'boolean') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
|
||||
if (kind === 'color') return colorOf(value ?? 0, 0);
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
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<string, boolean> = { GetVariable: false, SetVariable: true, Variable: true };
|
||||
const PLURAL: Record<string, boolean> = { GetVariables: false, SetVariables: true };
|
||||
|
||||
type Raw = Record<string, unknown>;
|
||||
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<string, string>();
|
||||
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<string, boolean> = { GetProperty: false, SetProperty: true };
|
||||
const PLURAL_PROPERTY: Record<string, boolean> = { 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<string, readonly PropertyDef[]>,
|
||||
): 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<string, { key: string; unset: boolean | string }> = {
|
||||
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<string, readonly PropertyDef[]>,
|
||||
): unknown {
|
||||
const graph = record(raw);
|
||||
if (!Array.isArray(graph.nodes)) return raw;
|
||||
const renamed = new Map<string, string>();
|
||||
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 };
|
||||
}
|
||||
+11
-70
@@ -13,9 +13,8 @@ import {
|
||||
} from '../graph';
|
||||
import type { Asset, TextureFrame } from '../model/Asset';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
import { emptyEdit, type InstanceEdit, isEmptyEdit, overrideSites } from '../model/instance';
|
||||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||||
import {
|
||||
type ColorItem,
|
||||
colorItem,
|
||||
colorItemValue,
|
||||
type FieldValue,
|
||||
@@ -23,18 +22,15 @@ import {
|
||||
textureItem,
|
||||
textureItemValue,
|
||||
isColorItem,
|
||||
isList,
|
||||
type PropertyBag,
|
||||
type PropertyValue,
|
||||
setBagValue,
|
||||
type StructValue,
|
||||
} from '../model/Properties';
|
||||
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 { upgradePropertyNodes, upgradeVariableNodes } from './legacyNodes';
|
||||
import { makeItem } from '../properties/listItems';
|
||||
import type { FlatItem } from '../properties/listItems';
|
||||
import {
|
||||
coerceValue,
|
||||
COMMON_PROPS,
|
||||
@@ -42,12 +38,9 @@ import {
|
||||
type FieldKind,
|
||||
isValidFieldName,
|
||||
isValidVariableName,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
type PropertySchema,
|
||||
schemaFor,
|
||||
structOf,
|
||||
type StructFieldDef,
|
||||
VARIABLE_KINDS,
|
||||
variableDef,
|
||||
@@ -65,13 +58,10 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
// Pass 1: asset ids and their effective schemas, needed to coerce overrides in pass 2.
|
||||
const schemas = new Map<string, PropertySchema>();
|
||||
const variables = new Map<string, PropertyDef[]>();
|
||||
const legacyLists = new Map<string, Map<string, DeclaredItems>>();
|
||||
for (const a of rawAssets) {
|
||||
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
|
||||
if (a.type === 'behavior') {
|
||||
const declared = new Map<string, DeclaredItems>();
|
||||
const vars = normalizeVariables(a.variables, declared);
|
||||
if (declared.size > 0) legacyLists.set(a.id, declared);
|
||||
const vars = normalizeVariables(a.variables);
|
||||
variables.set(a.id, vars);
|
||||
schemas.set(a.id, [...COMMON_PROPS, ...vars]);
|
||||
} else {
|
||||
@@ -99,9 +89,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
name,
|
||||
props,
|
||||
variables: variables.get(id) ?? [],
|
||||
graph: normalizeGraph(
|
||||
upgradePropertyNodes(upgradeVariableNodes(a.graph, variables.get(id) ?? []), variables),
|
||||
),
|
||||
graph: normalizeGraph(a.graph),
|
||||
});
|
||||
} else if (type === 'texture') {
|
||||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||||
@@ -133,43 +121,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
assets,
|
||||
activeSceneId,
|
||||
};
|
||||
migrateDeclaredLists(project, legacyLists);
|
||||
return project;
|
||||
}
|
||||
|
||||
/** What a list variable declared before its items carried their own kind – see `migrateDeclaredLists`. */
|
||||
interface DeclaredItems {
|
||||
kind: ListItemKind;
|
||||
fields: readonly StructFieldDef[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A list used to declare what it held, so its stored items did not have to say: a `color` list held
|
||||
* plain numbers, and a list of structs could hold an item missing a declared field. Items carry
|
||||
* their own kind now, so every value of such a variable is converted here – the declaration is read
|
||||
* one last time and then gone.
|
||||
*/
|
||||
function migrateDeclaredLists(project: Project, legacy: Map<string, Map<string, DeclaredItems>>): void {
|
||||
const convert =
|
||||
({ kind, fields }: DeclaredItems) =>
|
||||
(value: PropertyValue | undefined): PropertyValue | undefined =>
|
||||
isList(value)
|
||||
? value.map((item) => (kind === 'struct' ? structOf(fields, item) : makeItem(kind, item)))
|
||||
: value;
|
||||
for (const [assetId, declared] of legacy) {
|
||||
const asset = project.assets.find((a) => a.id === assetId);
|
||||
if (asset?.type !== 'behavior') continue;
|
||||
for (const [key, items] of declared) {
|
||||
const as = convert(items);
|
||||
const def = asset.variables.find((v) => v.key === key);
|
||||
if (def) def.default = as(def.default) ?? def.default;
|
||||
setBagValue(asset.props, key, as(asset.props[key]));
|
||||
for (const site of overrideSites(project, (id) => id === assetId))
|
||||
setBagValue(site.bag, key, as(site.bag[key]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). */
|
||||
export function serializeProject(project: Project): unknown {
|
||||
return JSON.parse(JSON.stringify(project));
|
||||
@@ -191,8 +145,7 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
|
||||
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
|
||||
editorOffset: { x: toNumber(offset.x), y: toNumber(offset.y) },
|
||||
overrides: normalizeBag(o.overrides, schema, true),
|
||||
// `ownerId` is the pre-hierarchy name of the same link.
|
||||
parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '',
|
||||
parentId: typeof o.parentId === 'string' ? o.parentId : '',
|
||||
instanceEdits: normalizeInstanceEdits(o.instanceEdits),
|
||||
});
|
||||
}
|
||||
@@ -233,8 +186,7 @@ function normalizeInstanceEdits(raw: unknown): Record<string, InstanceEdit> {
|
||||
return edits;
|
||||
}
|
||||
|
||||
/** `legacy` collects what a list variable still declares about its items – see `migrateDeclaredLists`. */
|
||||
function normalizeVariables(raw: unknown, legacy?: Map<string, DeclaredItems>): PropertyDef[] {
|
||||
function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
const defs: PropertyDef[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
@@ -247,8 +199,6 @@ function normalizeVariables(raw: unknown, legacy?: Map<string, DeclaredItems>):
|
||||
if (!isValidVariableName(key) || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const fields = normalizeStructFields(v.fields);
|
||||
if (kind === 'list' && (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items)))
|
||||
legacy?.set(key, { kind: v.items as ListItemKind, fields: primitivesOf(fields) });
|
||||
const def = variableDef(key, kind, undefined, fields);
|
||||
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
|
||||
defs.push(def);
|
||||
@@ -256,10 +206,6 @@ function normalizeVariables(raw: unknown, legacy?: Map<string, DeclaredItems>):
|
||||
return defs;
|
||||
}
|
||||
|
||||
/** A declared item shape held primitives only; a list field of one would have been a level too deep. */
|
||||
const primitivesOf = (fields: readonly StructFieldDef[]): StructFieldDef[] =>
|
||||
fields.filter((f) => f.kind !== 'list');
|
||||
|
||||
/** An unreadable field is dropped rather than defaulted: an invented key would claim a value. */
|
||||
function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
||||
const fields: StructFieldDef[] = [];
|
||||
@@ -410,21 +356,16 @@ function asStoredValue(value: unknown): PropertyValue | undefined {
|
||||
const isPrimitive = (value: unknown): value is number | string | boolean =>
|
||||
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
|
||||
|
||||
/**
|
||||
* A primitive, or the one object a flat position may hold: a tagged color. A tagged texture is a
|
||||
* list item only, so here it keeps its id as text – the same degrade `flatItemOf` makes.
|
||||
*/
|
||||
const asFlatItem = (value: unknown): number | string | boolean | ColorItem | undefined => {
|
||||
/** A primitive, or the objects a flat position may hold: a tagged color or texture. */
|
||||
const asFlatItem = (value: unknown): FlatItem | undefined => {
|
||||
if (isPrimitive(value)) return value;
|
||||
if (isColorItem(value)) return colorItem(colorItemValue(value));
|
||||
return isTextureItem(value) ? textureItemValue(value) : undefined;
|
||||
return isTextureItem(value) ? textureItem(textureItemValue(value)) : undefined;
|
||||
};
|
||||
|
||||
/** A list item: a tagged texture, a flat one, or a struct of flat ones. */
|
||||
/** A list item: a flat one, or a struct of flat ones. */
|
||||
const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined =>
|
||||
isTextureItem(value)
|
||||
? textureItem(textureItemValue(value))
|
||||
: (asFlatItem(value) ?? asStoredStruct(value, asFlatItem));
|
||||
asFlatItem(value) ?? asStoredStruct(value, asFlatItem);
|
||||
|
||||
/** A struct field: a flat item or a flat list. A field that is itself a struct is dropped. */
|
||||
const asStoredField = (value: unknown): FieldValue | undefined =>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import {
|
||||
emptyFieldValue,
|
||||
type FieldKind,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
@@ -204,11 +203,6 @@ export function removeButton(onClick: () => void): Button {
|
||||
export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
|
||||
|
||||
/** A primitive as a property definition, so one widget set serves a list item and a struct field. */
|
||||
/** The def a list item draws with: the flat kinds plus `texture`, which only a list item may be. */
|
||||
export function itemDef(key: string, kind: Exclude<ListItemKind, 'struct'>, label = key): PropertyDef {
|
||||
return kind === 'texture' ? { key, label, kind: 'texture', default: '' } : primitiveDef(key, kind, label);
|
||||
}
|
||||
|
||||
export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef {
|
||||
switch (kind) {
|
||||
case 'number':
|
||||
@@ -219,6 +213,8 @@ export function primitiveDef(key: string, kind: FieldKind, label = key): Primiti
|
||||
return { key, label, kind: 'boolean', default: false };
|
||||
case 'color':
|
||||
return { key, label, kind: 'color', default: 0 };
|
||||
case 'texture':
|
||||
return { key, label, kind: 'texture', default: '' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DECLARED_PORT_KINDS,
|
||||
type DeclaredPort,
|
||||
type GraphNode,
|
||||
isPortOf,
|
||||
isTerminalNode,
|
||||
missingFieldPorts,
|
||||
OBJECT_BEHAVIOR_PARAM,
|
||||
@@ -155,7 +156,7 @@ function buildPickedPortSection(
|
||||
continue;
|
||||
}
|
||||
const def = choices.find((d) => d.key === port.key);
|
||||
const known = def !== undefined && portKindOf(def) === port.kind;
|
||||
const known = def !== undefined && isPortOf(port, def);
|
||||
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
|
||||
choose.onClick = () => {
|
||||
const items = pick(port.key, (next) =>
|
||||
@@ -163,7 +164,7 @@ function buildPickedPortSection(
|
||||
);
|
||||
new Menu(items, { searchable }).openBelow(choose);
|
||||
};
|
||||
portRow(host, assetId, node, port, choose, toggle);
|
||||
portRow(host, assetId, node, def && known ? { ...port, kind: portKindOf(def) } : port, choose, toggle);
|
||||
if (!known) addNote(host, `"${port.key}" is not a ${noun.toLowerCase()} of ${owner}.`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
createField,
|
||||
type FieldContext,
|
||||
NAME_WIDTH,
|
||||
itemDef,
|
||||
primitiveDef,
|
||||
READ_ONLY,
|
||||
removeButton,
|
||||
@@ -225,7 +224,7 @@ export class ListEditor extends VStack {
|
||||
else {
|
||||
const field = createField(
|
||||
this.ctx,
|
||||
itemDef(String(index), kind),
|
||||
primitiveDef(String(index), kind),
|
||||
itemValue(item) ?? '',
|
||||
access.itemHandlers(index),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user