diff --git a/README.md b/README.md index 859d5b69..f1a404ce 100644 --- a/README.md +++ b/README.md @@ -108,9 +108,8 @@ shown on the node and editable in the inspector. of its kind (0, false, empty), and the node shows that value beside the name. All of them are written together, their inputs read first, so one node swaps two variables. `count = count + 1` is one `Variables` node feeding its own output through an `Add` back into its input. The ports are picked from the behavior's - variables rather than named, they take their type from the declaration, and renaming a variable carries them (and their edges) along. Files - from before this node still load: their `Get/Set variable(s)` and `Variable` nodes become - `Variables` nodes that read or write the same variables. `Properties` is the same node for the + variables rather than named, they take their type from the declaration, and renaming a variable carries them (and their edges) along. + `Properties` is the same node for the object it is handed (fixed input `object`). Its **Type** setting, above the list, decides what `+ Property` offers: *Sprite*, *Text*, *Collider*, *Camera* and *Container* offer their fields, *Behavior* the variables of the behavior picked below it (renaming such a variable carries the @@ -119,12 +118,9 @@ shown on the node and editable in the inspector. starting white, shown on the node as `#ffffff`, and a camera's `width` is its whole-pixel view width. Switching the type keeps the ports; one the new type lacks is marked. The node shows its type as a setting row (*Type: Sprite*, plus *Behavior: Game* under that type). Textures and - scenes are no type here: they are not objects in a running game. Older files load their `Get/Set property(ies)`, `Set enabled` and `Set text` as - `Properties` nodes, typed by the first object type holding every key, else the one behavior - declaring them, else Any – which is also what a node only about `enabled` gets, since every - object has it. A variable is a - **number, a yes/no, a text, a list or a struct**; a list declares **nothing** about its items – - each one is a number, a text, a yes/no, a colour or a struct and says so itself, so one list may + scenes are no type here: they are not objects in a running game. A variable is a + **number, a yes/no, a text, a colour, a texture, a list or a struct**; a list declares **nothing** about its items – + each one is a number, a text, a yes/no, a colour, a texture or a struct and says so itself, so one list may hold several kinds at once. A list is compared by its items, never by identity – otherwise every reload would look like an override. There is no list *of objects*: a list is stored in the project file and an object is a live @@ -149,11 +145,13 @@ shown on the node and editable in the inspector. frame of a sheet, or an animation). A field name cannot start with `$` (`isValidFieldName`), so a tag can never collide with a struct item of one field. Either reads as what it wraps on a data edge (`Item at`), the graph having neither colours nor pictures of its own, and `Set item` leaves - the slot the kind it was. A texture is a **list item only**: in a flat position – a struct item's - field, or a list a struct field holds – it keeps its id as plain text, and the tab does not offer - it there. A file from before this carried the item kind on the list (`items: 'color'`); on load - every such value is converted once, which is the only thing that keeps a colour written under the - old rule a colour. A **struct** variable is the other way round: few fields, so they fit under + the slot the kind it was. A **texture is a kind wherever a kind is picked** – a variable, a struct + field, a list item, an Any port of `Properties`, a field of `Pack` – and the `Texture` node hands + one into the graph, as `Number` and `Text` do a number and a text. Declared (a variable, a struct + field of one, a port) it needs no tag: the declaration says what it is, and the value is the id – + the way a colour field is a number. A declared port of kind colour or texture carries the field + that edits it and travels as a number or a text, since the graph has neither; a port declared as + the number a colour travels as still names the property (`isPortOf`). A **struct** variable is the other way round: few fields, so they fit under each other right in the inspector. Its head row renames it and adds fields (`+ Field`), each field row below carries its own name, its value and a remove – the port editor `Pack` already has, one level down and with a value column – and a field may itself be a **list**, which is @@ -738,7 +736,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru # code is lost (tail's is reported), so a red run reads as green npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # optional: run the browser elsewhere; see below -node tools/seed-e2e.mjs && node tools/e2e.mjs # 631 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 633 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step ./e2e.sh start [GM_STEPS] # the same run detached, see below ./e2e.sh status # live progress, or the last run's result diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index 52806806..be797c47 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -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 { diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 2ea23c84..69e42d37 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -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. */ diff --git a/src/core/graph/nodes/data/Fill.ts b/src/core/graph/nodes/data/Fill.ts index b95f4abd..352c24b3 100644 --- a/src/core/graph/nodes/data/Fill.ts +++ b/src/core/graph/nodes/data/Fill.ts @@ -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) => { diff --git a/src/core/graph/nodes/data/ItemAt.ts b/src/core/graph/nodes/data/ItemAt.ts index d2ae948f..33cd0f48 100644 --- a/src/core/graph/nodes/data/ItemAt.ts +++ b/src/core/graph/nodes/data/ItemAt.ts @@ -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) => { diff --git a/src/core/graph/nodes/data/SetItem.ts b/src/core/graph/nodes/data/SetItem.ts index 50e731fd..f837c184 100644 --- a/src/core/graph/nodes/data/SetItem.ts +++ b/src/core/graph/nodes/data/SetItem.ts @@ -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); diff --git a/src/core/graph/nodes/data/String.ts b/src/core/graph/nodes/data/String.ts index ed365e51..c0310005 100644 --- a/src/core/graph/nodes/data/String.ts +++ b/src/core/graph/nodes/data/String.ts @@ -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: [], diff --git a/src/core/graph/nodes/data/SubList.ts b/src/core/graph/nodes/data/SubList.ts index 7c139549..f3d7e40a 100644 --- a/src/core/graph/nodes/data/SubList.ts +++ b/src/core/graph/nodes/data/SubList.ts @@ -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) => { diff --git a/src/core/graph/nodes/data/Substring.ts b/src/core/graph/nodes/data/Substring.ts index 422bac87..93e1693e 100644 --- a/src/core/graph/nodes/data/Substring.ts +++ b/src/core/graph/nodes/data/Substring.ts @@ -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) => { diff --git a/src/core/graph/nodes/data/Texture.ts b/src/core/graph/nodes/data/Texture.ts new file mode 100644 index 00000000..80efccc7 --- /dev/null +++ b/src/core/graph/nodes/data/Texture.ts @@ -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) } }), +}; diff --git a/src/core/graph/nodes/data/ToText.ts b/src/core/graph/nodes/data/ToText.ts index 3157beec..337bceac 100644 --- a/src/core/graph/nodes/data/ToText.ts +++ b/src/core/graph/nodes/data/ToText.ts @@ -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) => { diff --git a/src/core/graph/nodes/flow/Repeat.ts b/src/core/graph/nodes/flow/Repeat.ts index 1ff03659..521d8671 100644 --- a/src/core/graph/nodes/flow/Repeat.ts +++ b/src/core/graph/nodes/flow/Repeat.ts @@ -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) => ({ diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 3766c6a0..e5364dad 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index 43a5bd8a..1e3bcf18 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -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(), diff --git a/src/core/graph/propertyPorts.ts b/src/core/graph/propertyPorts.ts index b31f441a..549c41b8 100644 --- a/src/core/graph/propertyPorts.ts +++ b/src/core/graph/propertyPorts.ts @@ -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 }; } diff --git a/src/core/graph/variablePorts.ts b/src/core/graph/variablePorts.ts index af85efb3..0bcd07f2 100644 --- a/src/core/graph/variablePorts.ts +++ b/src/core/graph/variablePorts.ts @@ -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 })); } diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts index 5fcbf422..54e326af 100644 --- a/src/core/model/Properties.ts +++ b/src/core/model/Properties.ts @@ -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 }; diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 082e4b5f..0042946a 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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; 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 = { text: '', boolean: false, color: 0, + texture: '', }; export function emptyFieldValue(field: StructFieldDef): FieldValue { diff --git a/src/core/properties/listItems.ts b/src/core/properties/listItems.ts index 94454e33..4d4ac74d 100644 --- a/src/core/properties/listItems.ts +++ b/src/core/properties/listItems.ts @@ -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); diff --git a/src/core/store/legacyNodes.ts b/src/core/store/legacyNodes.ts deleted file mode 100644 index 594ae122..00000000 --- a/src/core/store/legacyNodes.ts +++ /dev/null @@ -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 = { 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/serialize.ts b/src/core/store/serialize.ts index 1ec1f794..e792a869 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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(); const variables = new Map(); - const legacyLists = new Map>(); for (const a of rawAssets) { if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue; if (a.type === 'behavior') { - const declared = new Map(); - 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>): 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): 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 { return edits; } -/** `legacy` collects what a list variable still declares about its items – see `migrateDeclaredLists`. */ -function normalizeVariables(raw: unknown, legacy?: Map): PropertyDef[] { +function normalizeVariables(raw: unknown): PropertyDef[] { const defs: PropertyDef[] = []; const seen = new Set(); for (const item of Array.isArray(raw) ? raw : []) { @@ -247,8 +199,6 @@ function normalizeVariables(raw: unknown, legacy?: Map): 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): 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 => diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index dfec0c51..4a901724 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -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, 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: '' }; } } diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index 70aad860..1a83d80a 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -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'); } } diff --git a/src/editor/list/ListEditor.ts b/src/editor/list/ListEditor.ts index 09d45f22..db4ba390 100644 --- a/src/editor/list/ListEditor.ts +++ b/src/editor/list/ListEditor.ts @@ -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), ); diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts index df0bfcf4..e338c397 100644 --- a/tests/core/hierarchy.test.ts +++ b/tests/core/hierarchy.test.ts @@ -156,14 +156,11 @@ describe('scene hierarchy', () => { expect(findObject(findScene(deserializeProject(raw), sceneId)!, logic)!.parentId).toBe(''); }); - it('the loader accepts legacy ownerId, drops dangling parents and breaks cycles', () => { + it('the loader drops dangling parents and breaks cycles', () => { const { project, stack, sceneId, a, b, c } = threeBoxes(); stack.execute(new SetParentCommand(sceneId, b, a)); const raw = serializeProject(project) as { assets: { objects?: Record[] }[] }; const objects = raw.assets[0]!.objects!; - // Legacy documents stored the link as `ownerId`. - delete objects[1]!.parentId; - objects[1]!.ownerId = a; // A cycle a → c → a and a dangling reference are both cleared. objects[0]!.parentId = c; objects[2]!.parentId = a; @@ -172,7 +169,6 @@ describe('scene hierarchy', () => { expect(parentOf(b)).toBe(a); expect([parentOf(a), parentOf(c)].filter(Boolean)).toHaveLength(1); // one link of the cycle survives objects[1]!.parentId = 'ghost'; - delete objects[1]!.ownerId; expect(findScene(deserializeProject(raw), sceneId)!.objects[1]!.parentId).toBe(''); }); }); diff --git a/tests/core/legacy-property-nodes.test.ts b/tests/core/legacy-property-nodes.test.ts deleted file mode 100644 index a0f747f7..00000000 --- a/tests/core/legacy-property-nodes.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -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/legacy-variable-nodes.test.ts b/tests/core/legacy-variable-nodes.test.ts deleted file mode 100644 index cced5268..00000000 --- a/tests/core/legacy-variable-nodes.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { AddVariableCommand } from '../../src/core/commands/variableCommands'; -import { GraphInterpreter } 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 { deserializeProject, serializeProject } from '../../src/core/store/serialize'; -import { fakeContext, 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 holds `nodes` and `edges` exactly as an older editor wrote them. */ -function loadOld(nodes: RawNode[], edges: RawEdge[], variables = ['px', 'py', 'sum']) { - const fixture = makeProject(); - const behavior = makeBehavior(fixture, 'B', variables); - fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct')); - fixture.stack.execute(new AddVariableCommand(behavior.id, 'flag', 'boolean')); - 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, 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('variable nodes of an older file', () => { - it('load as Variables nodes that compute what the old graph computed', () => { - const { project, behavior } = loadOld( - [ - { id: 'start', type: 'OnStart' }, - { id: 'set', type: 'SetVariables', params: { px: 7 }, ports: [{ key: 'px', kind: 'number' }] }, - { id: 'y', type: 'SetVariable', params: { variable: 'py', value: 9 } }, - { id: 'get', type: 'GetVariable', params: { variable: 'px' } }, - { id: 'both', type: 'GetVariables', ports: [{ key: 'py', kind: 'number' }] }, - { id: 'add', type: 'Add' }, - { id: 'sum', type: 'Variable', params: { variable: 'sum' } }, - ], - [ - ['start', 'exec', 'set', 'exec'], - ['set', 'exec', 'y', 'exec'], - ['y', 'exec', 'sum', 'exec'], - ['get', 'value', 'add', 'a'], - ['both', 'py', 'add', 'b'], - ['add', 'result', 'sum', 'value'], - ], - ); - expect(behavior.graph.nodes.map((n) => n.type)).toEqual([ - 'OnStart', - 'Variables', - 'Variables', - 'Variables', - 'Variables', - 'Add', - 'Variables', - ]); - expect(edges(behavior)).toEqual([ - 'start.exec>set.exec', - 'set.exec>y.exec', - 'y.exec>sum.exec', - 'get.px>add.a', - 'both.py>add.b', - 'add.result>sum.sum', - ]); - const ctx = fakeContext({ variables: { px: 0, py: 0, sum: 0 } }); - new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx); - expect(ctx.variables).toEqual({ px: 7, py: 9, sum: 16 }); - }); - - it('keep whether they wrote, their typed-in value, and the kind of the variable', () => { - const { behavior } = loadOld( - [ - { id: 'get', type: 'GetVariable', params: { variable: 'flag' } }, - { id: 'set', type: 'SetVariable', params: { variable: 'px', value: 3 } }, - { id: 'plural', type: 'SetVariables', params: { py: 4 }, ports: [{ key: 'py', kind: 'number' }] }, - { id: 'gone', type: 'Variable', params: { variable: 'nope' } }, - ], - [], - ); - expect(node(behavior, 'get').ports).toEqual([{ key: 'flag', kind: 'boolean' }]); - expect(node(behavior, 'set')).toMatchObject({ - params: { px: 3 }, - ports: [{ key: 'px', kind: 'number', write: true }], - }); - expect(node(behavior, 'plural')).toMatchObject({ - params: { py: 4 }, - ports: [{ key: 'py', kind: 'number', write: true }], - }); - // A variable the behavior does not declare keeps its port, as text; the inspector marks it. - expect(node(behavior, 'gone').ports).toEqual([{ key: 'nope', kind: 'text', write: true }]); - expect(node(behavior, 'get').params).toEqual({}); - }); - - it('give a struct variable a struct port, so an Expand can still take it apart', () => { - const { behavior } = loadOld([{ id: 'get', type: 'GetVariable', params: { variable: 'piece' } }], []); - expect(node(behavior, 'get').ports).toEqual([{ key: 'piece', kind: 'struct' }]); - }); - - it('drop every edge of a node that never had a variable picked, flow included', () => { - const { behavior } = loadOld( - [ - { id: 'start', type: 'OnStart' }, - { id: 'empty', type: 'SetVariable' }, - { id: 'add', type: 'Add' }, - ], - [ - ['start', 'exec', 'empty', 'exec'], - ['add', 'result', 'empty', 'value'], - ], - ); - expect(node(behavior, 'empty').ports).toBeUndefined(); - expect(edges(behavior)).toEqual([]); - }); - - it('are left alone once they are Variables nodes', () => { - const { project, behavior } = loadOld( - [{ id: 'get', type: 'GetVariable', params: { variable: 'px' } }], - [], - ); - const again = findAsset(deserializeProject(serializeProject(project)), behavior.id) as BehaviorAsset; - expect(again.graph).toEqual(behavior.graph); - }); -}); diff --git a/tests/core/list-item-kind.test.ts b/tests/core/list-item-kind.test.ts index a41e2e3c..a7f07ae0 100644 --- a/tests/core/list-item-kind.test.ts +++ b/tests/core/list-item-kind.test.ts @@ -1,23 +1,16 @@ /** - * A list declares nothing about what it holds: every item carries its own kind. Two things matter – - * the kind is read back off the value (and a color says so, or it would be a plain number), and a - * project written under the old rule, where the list declared one kind for all of them, still loads - * with the values it had. + * A list declares nothing about what it holds: every item carries its own kind, read back off the + * value – and a color or a texture says so, or it would be a plain number or text. */ import { describe, expect, it } from 'vitest'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { colorItem, type PropertyValue, textureItem } from '../../src/core/model/Properties'; -import { findAsset, objectHolders, type Project } from '../../src/core/model/Project'; +import { findAsset } from '../../src/core/model/Project'; import { deserializeProject } from '../../src/core/store/serialize'; import { itemKindOf, itemValue, makeItem, structItemFields } from '../../src/core/properties/listItems'; import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; -const findObject = (project: Project, id: string) => - objectHolders(project) - .flatMap((holder) => holder.objects) - .find((o) => o.id === id); - /** A behavior with a list variable `stops`, placed once with an override of its own. */ function withList(value: unknown = [1, 2]) { const f = makeSpriteScene(); @@ -71,13 +64,13 @@ describe('the kind of one list item', () => { expect(makeItem('text', textureItem('frame-26'))).toBe('frame-26'); }); - /** A texture is a list item only: a flat position keeps the id, but as the text it looks like. */ - it('degrades to its id inside a struct item, where no texture kind exists', () => { + /** A struct item's field is undeclared too, so a texture there keeps its tag, like a color does. */ + it('stays a texture inside a struct item', () => { const { f, behaviorId } = withList([{ pic: textureItem('frame-26'), n: 1 }]); const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[]; - expect(back).toEqual([{ pic: 'frame-26', n: 1 }]); + expect(back).toEqual([{ pic: textureItem('frame-26'), n: 1 }]); expect(structItemFields(back[0]).map((field) => `${field.key}:${field.kind}`)).toEqual([ - 'pic:text', + 'pic:texture', 'n:number', ]); }); @@ -131,62 +124,9 @@ describe('a list in a node parameter, where nothing is declared', () => { expect(paramOf([textureItem('frame-26'), 'frame-26'])).toEqual([textureItem('frame-26'), 'frame-26']); }); - it('but degrades one inside a struct item to its id, where no texture kind exists', () => { - expect(paramOf([{ pic: textureItem('frame-26'), n: 1 }])).toEqual([{ pic: 'frame-26', n: 1 }]); - }); -}); - -describe('a list that still declares its item kind', () => { - /** A project file from before the change: the declaration said `color`, the items were numbers. */ - const loadDeclared = (items: string, values: unknown, fields?: unknown) => - deserializeProject({ - id: 'p', - assets: [ - { - id: 'b', - type: 'behavior', - name: 'B', - props: { stops: values }, - variables: [{ key: 'stops', label: 'stops', kind: 'list', default: values, items, fields }], - }, - { - id: 's', - type: 'scene', - name: 'S', - objects: [{ id: 'o', assetId: 'b', overrides: { stops: values } }], - }, - ], - activeSceneId: 's', - }); - - it('tags the colors it held, in the default, the own value and the override', () => { - const project = loadDeclared('color', [0xff0000, '#00ff00']); - const asset = findAsset(project, 'b'); - const tagged = [colorItem(0xff0000), colorItem(0x00ff00)]; - expect(asset?.props.stops).toEqual(tagged); - expect(asset?.type === 'behavior' && asset.variables[0]?.default).toEqual(tagged); - expect(findObject(project, 'o')?.overrides.stops).toEqual(tagged); - }); - - it('converts the other kinds the same way, so a value stays what it was meant to be', () => { - expect(findAsset(loadDeclared('text', [1, true]), 'b')?.props.stops).toEqual(['1', 'true']); - expect(findAsset(loadDeclared('number', ['1', '2']), 'b')?.props.stops).toEqual([1, 2]); - expect(findAsset(loadDeclared('boolean', ['false', 'yes']), 'b')?.props.stops).toEqual([false, true]); - }); - - /** An item could be missing a declared field, and the declaration was what filled it in. */ - it('fills a struct item from the shape the list declared', () => { - const fields = [ - { key: 'x', kind: 'number' }, - { key: 'y', kind: 'number' }, - ]; - expect(findAsset(loadDeclared('struct', [{ x: 3 }], fields), 'b')?.props.stops).toEqual([{ x: 3, y: 0 }]); - }); - - it('and says nothing about its items once it has been read back out', () => { - const written = roundTrip(loadDeclared('color', [0xff0000])); - const asset = findAsset(written, 'b'); - expect(asset?.type === 'behavior' && asset.variables[0]).not.toHaveProperty('items'); - expect(asset?.props.stops).toEqual([colorItem(0xff0000)]); + it('and one inside a struct item as well', () => { + expect(paramOf([{ pic: textureItem('frame-26'), n: 1 }])).toEqual([ + { pic: textureItem('frame-26'), n: 1 }, + ]); }); }); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index a84f4652..3007bbd2 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -231,6 +231,10 @@ const CASES: Record = { { name: 'outputs its setting', params: { value: 'hello' }, outputs: { value: 'hello' } }, { name: 'unset is empty', outputs: { value: '' } }, ], + Texture: [ + { name: 'outputs the id it was set to', params: { value: 'fr-0-1' }, outputs: { value: 'fr-0-1' } }, + { name: 'unset is no picture', outputs: { value: '' } }, + ], Add: [ { name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } }, { name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } }, diff --git a/tests/core/struct-shape-ports.test.ts b/tests/core/struct-shape-ports.test.ts index e0bac113..b527dce4 100644 --- a/tests/core/struct-shape-ports.test.ts +++ b/tests/core/struct-shape-ports.test.ts @@ -6,13 +6,7 @@ import { describe, expect, it } from 'vitest'; import { AddPortCommand, AddPortsCommand } from '../../src/core/commands/portCommands'; import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands'; -import { - type DeclaredPortKind, - fieldPortKind, - missingFieldPorts, - resolveDef, - structShapeOf, -} from '../../src/core/graph'; +import { declaredPortType, missingFieldPorts, resolveDef, structShapeOf } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { behaviorLookup } from '../../src/core/model/behaviorPrograms'; import type { StructFieldDef } from '../../src/core/properties/PropertySchema'; @@ -65,11 +59,19 @@ describe('the shape a variable declares', () => { expect(structShapeOf(varOf(behavior, 'names'))).toBeUndefined(); }); - /** A color is stored as a number and the graph has no port kind for it – every other kind is itself. */ - it('reads a color field as a number port and a list field as a list port', () => { - expect(PIECE.map(fieldPortKind)).toEqual([ + /** A field's kind is its port kind; a color port then travels as a number, since the graph has none. */ + it('gives each field a port of its own kind, a color travelling as a number', () => { + const ports = missingFieldPorts({ id: 'n', type: 'PackStruct', x: 0, y: 0, params: {} }, PIECE); + expect(ports.map((p) => `${p.key}:${p.kind}`)).toEqual([ + 'shape:number', + 'name:text', + 'held:boolean', + 'tint:color', + 'cells:list', + ]); + expect(ports.map((p) => declaredPortType(p.kind))).toEqual([ 'number', - 'text', + 'string', 'boolean', 'number', 'list', diff --git a/tests/core/texture-kind.test.ts b/tests/core/texture-kind.test.ts new file mode 100644 index 00000000..cd83d752 --- /dev/null +++ b/tests/core/texture-kind.test.ts @@ -0,0 +1,151 @@ +/** + * A color and a texture are kinds of their own wherever a kind is picked – of a variable, of a struct + * field, of a declared port – while on a data edge either is still the number or the text it wraps. + */ +import { describe, expect, it } from 'vitest'; +import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { + declaredPortType, + inlineInputDef, + isPortOf, + OBJECT_TYPE_PARAM, + portDefOf, + portKindOf, + propertyPort, + withInlineDefault, +} from '../../src/core/graph'; +import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; +import { textureItem } from '../../src/core/model/Properties'; +import { findAsset } from '../../src/core/model/Project'; +import { type PropertyDef, structOf, variableDef } from '../../src/core/properties/PropertySchema'; +import { makeBehavior, makeProject, roundTrip } from '../helpers'; + +const COLOR: PropertyDef = { key: 'color', label: 'Color', kind: 'color', default: 0 }; +const SPRITE = { [OBJECT_TYPE_PARAM]: 'sprite' }; + +describe('a variable of kind color or texture', () => { + it('declares its default as the number or the id, empty when nothing is given', () => { + expect(variableDef('pic', 'texture', 'fr-0-1')).toEqual({ + key: 'pic', + label: 'pic', + kind: 'texture', + default: 'fr-0-1', + }); + expect(variableDef('tint', 'color', '#ff0000').default).toBe(0xff0000); + expect(variableDef('pic', 'texture').default).toBe(''); + expect(variableDef('tint', 'color').default).toBe(0); + }); + + it('survives a save and load with its kind', () => { + const f = makeProject(); + const behavior = makeBehavior(f, 'B'); + f.stack.execute(new AddVariableCommand(behavior.id, 'pic', 'texture')); + f.stack.execute(new AddVariableCommand(behavior.id, 'tint', 'color')); + findAsset(f.project, behavior.id)!.props.pic = 'fr-0-1'; + const back = findAsset(roundTrip(f.project), behavior.id); + expect(back?.type === 'behavior' && back.variables.map((v) => `${v.key}:${v.kind}`)).toEqual([ + 'pic:texture', + 'tint:color', + ]); + expect(back?.props).toMatchObject({ pic: 'fr-0-1', tint: 0 }); + }); +}); + +describe('a struct field of kind texture', () => { + /** Declared, so the value needs no tag: the field says what it is, as a color field does. */ + it('holds the plain id, and starts empty', () => { + expect(structOf([{ key: 'pic', kind: 'texture' }], { pic: textureItem('fr-0-1') })).toEqual({ + pic: 'fr-0-1', + }); + expect(structOf([{ key: 'pic', kind: 'texture' }], undefined)).toEqual({ pic: '' }); + }); + + it('is added to a struct variable like any other field', () => { + const f = makeProject(); + const behavior = makeBehavior(f, 'B'); + f.stack.execute(new AddVariableCommand(behavior.id, 'look', 'struct')); + f.stack.execute(new AddStructFieldCommand(behavior.id, 'look', { key: 'pic', kind: 'texture' })); + findAsset(f.project, behavior.id)!.props.look = { pic: 'fr-0-1' }; + expect(findAsset(roundTrip(f.project), behavior.id)?.props.look).toEqual({ pic: 'fr-0-1' }); + }); +}); + +describe('a declared port of kind color or texture', () => { + it('is the kind of the property it names, travelling as a number or a text', () => { + expect(portKindOf(COLOR)).toBe('color'); + expect(portKindOf({ key: 'pic', label: 'pic', kind: 'texture', default: '' })).toBe('texture'); + expect(portKindOf({ key: 'align', label: 'align', kind: 'enum', default: 'left', options: [] })).toBe( + 'text', + ); + expect(declaredPortType('color')).toBe('number'); + expect(declaredPortType('texture')).toBe('string'); + }); + + it('carries the field that edits it, so a typed-in value gets the picker', () => { + const pic = withInlineDefault(portDefOf({ key: 'pic', kind: 'texture' })); + expect(pic).toMatchObject({ type: 'string', default: '' }); + expect(inlineInputDef(pic).kind).toBe('texture'); + const tint = withInlineDefault(portDefOf({ key: 'tint', kind: 'color' })); + expect(tint).toMatchObject({ type: 'number', default: 0 }); + expect(inlineInputDef(tint).kind).toBe('color'); + expect(portDefOf({ key: 'n', kind: 'number' }).field).toBeUndefined(); + }); + + /** A file from before the kind existed declares a sprite's `color` port as a number; it still is one. */ + it('names a property when it travels the way the property does', () => { + expect(isPortOf({ key: 'color', kind: 'color' }, COLOR)).toBe(true); + expect(isPortOf({ key: 'color', kind: 'number' }, COLOR)).toBe(true); + expect(isPortOf({ key: 'color', kind: 'text' }, COLOR)).toBe(false); + expect(propertyPort({ key: 'color', kind: 'color' }, SPRITE).field?.kind).toBe('color'); + }); +}); + +describe('the literal nodes', () => { + /** The palette and the kind picker say the same word: a text is "Text", nowhere "String". */ + it('are labelled by the kind they hand out', () => { + const label = (type: string) => BUILT_IN_NODES.find((d) => d.type === type)?.label; + expect(['Number', 'Boolean', 'String', 'Texture', 'List'].map(label)).toEqual([ + 'Number', + 'Boolean', + 'Text', + 'Texture', + 'List', + ]); + }); +}); + +describe('the whole-number inputs', () => { + const input = (type: string, key: string) => + BUILT_IN_NODES.find((d) => d.type === type)!.inputs.find((p) => p.key === key)!; + + it('show no decimals, since the node floors what arrives', () => { + const whole = [ + ['ItemAt', 'index'], + ['SetItem', 'index'], + ['SubList', 'start'], + ['SubList', 'count'], + ['Fill', 'count'], + ['Repeat', 'times'], + ['Substring', 'start'], + ['Substring', 'count'], + ['ToText', 'decimals'], + ] as const; + for (const [type, key] of whole) { + const def = inlineInputDef(input(type, key)); + expect(def.kind === 'number' && def.decimals, `${type}.${key}`).toBe(0); + } + }); + + it('keep their default on the port and in the field alike', () => { + for (const [type, key, value] of [ + ['SubList', 'count', 1], + ['Fill', 'count', 1], + ['Repeat', 'times', 1], + ['Substring', 'count', -1], + ['ItemAt', 'index', 0], + ] as const) { + const port = input(type, key); + expect([port.default, inlineInputDef(port).default], `${type}.${key}`).toEqual([value, value]); + } + }); +}); diff --git a/tests/editor/list-slot.test.ts b/tests/editor/list-slot.test.ts index 8bd5ae55..51dd5b5f 100644 --- a/tests/editor/list-slot.test.ts +++ b/tests/editor/list-slot.test.ts @@ -257,11 +257,10 @@ describe('a list a struct field holds', () => { /** * And because they are dropped, the tab must not offer them: an item added here would vanish on - * the next write, which reads as the menu entry doing nothing at all. A texture goes the same - * way – flat, it would keep only its id and stop being a picture. + * the next write, which reads as the menu entry doing nothing at all. */ - it('offers the flat kinds only, where the whole list has all six', () => { - expect(list().kinds).toEqual(['number', 'text', 'boolean', 'color']); + it('offers the flat kinds only, where the whole list has struct as well', () => { + expect(list().kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture']); const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!; expect(whole.kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture', 'struct']); }); diff --git a/todo-notes/reseed-because-of-list.md b/todo-notes/done/reseed-because-of-list.md similarity index 51% rename from todo-notes/reseed-because-of-list.md rename to todo-notes/done/reseed-because-of-list.md index 4a9d35f2..c699018c 100644 --- a/todo-notes/reseed-because-of-list.md +++ b/todo-notes/done/reseed-because-of-list.md @@ -6,3 +6,13 @@ 5. checken ob es projects gibt (ohne seed) die auch noch eine migration erfordern. 6. migration code entfernen 7. unnötige comments auch wieder entfernen + +--- + +Umgesetzt. Entscheidungen dabei: +- Nur das Label des `String`-Nodes heisst "Text", Typ-Id und Porttyp bleiben. +- `Texture` (und `Color`) sind Variablen-Arten, Struct-Feld-Arten und Port-Arten; deklariert ist eine + Textur die blanke Id, der `$texture`-Tag gilt nur in Listen. Neuer Node `Texture` mit Picker. +- Jump-Run und Tetris wurden mit den aktuellen Seedern ueberschrieben; nur die fuenf Projekte auf + dem Server zaehlen. Danach sind `legacyNodes.ts`, `migrateDeclaredLists` und der + `ownerId`-Fallback samt Tests geloescht. diff --git a/todo-notes/refactor-types.md b/todo-notes/refactor-types.md new file mode 100644 index 00000000..48599f6a --- /dev/null +++ b/todo-notes/refactor-types.md @@ -0,0 +1,14 @@ +## Eine Refactoring-Runde speziell für Typen: + +Typen (ob jetzt "String" oder "Color" oder "Texture") werden vertreut eingesetzt: +Geh alles durch, und refactore so, dass +- wenn ein neuer Typ in Zukunft irgendwann hinzugefügt wird +- oder ein Typ geändert wird +- oder wieder wegkommt +dass es nicht ewig viele Stellen beachtet werden müssen. + +Reduziere dass auf ein Minimum, wir brauchen das so "modular" wie möglich. + +Falls es schon so passt wie es ist, dann braucht man natürlich auch nichts machen, aber ich will nur auf nummer sicher gehen. + +Falls dir bei der Arbeit noch andere Dinge auffallen die man besser/konstruktiver/schöner bauen kann, dann kannst du das natürlich gleich mitnehmen. diff --git a/tools/e2e/23-variable-nodes.mjs b/tools/e2e/23-variable-nodes.mjs index df990057..47d8f93d 100644 --- a/tools/e2e/23-variable-nodes.mjs +++ b/tools/e2e/23-variable-nodes.mjs @@ -262,9 +262,9 @@ export async function variableNodes(t) { await sleep(300); const seeded = await portsOf('vp-pack'); check( - 'picking a struct variable declares every field at once, a color among them as a number', + 'picking a struct variable declares every field at once, each of its own kind', JSON.stringify(seeded) === - '[{"key":"shape","kind":"number"},{"key":"tint","kind":"number"},{"key":"cells","kind":"list"}]', + '[{"key":"shape","kind":"number"},{"key":"tint","kind":"color"},{"key":"cells","kind":"list"}]', JSON.stringify(seeded), ); check('and the node grew those inputs', Boolean(await portAt('vp-pack', 'cells', false))); diff --git a/tools/e2e/24-property-nodes.mjs b/tools/e2e/24-property-nodes.mjs index b162ec02..7abadbfa 100644 --- a/tools/e2e/24-property-nodes.mjs +++ b/tools/e2e/24-property-nodes.mjs @@ -14,7 +14,7 @@ const logicGraph = { node('pp-owner', 'Owner', 0, 240), node('pp-set', 'Properties', 260, 0, { ...propertiesOf('sprite'), width: 40, color: 0xff0000 }, [ written('width', 'number'), - written('color', 'number'), + written('color', 'color'), ]), node('pp-store', 'Variables', 560, 0, {}, [written('seen', 'number')]), node('pp-any', 'Properties', 260, 420), @@ -152,7 +152,7 @@ export async function propertyNodes(t) { const grown = await portsOf('pp-set'); check( 'picking a property adds it as an output', - grown.join(',') === 'width:number:set,color:number:set,height:number', + grown.join(',') === 'width:number:set,color:color:set,height:number', grown.join(','), ); const toggle = await json(`__gm.inspectorField('Set', true)`); @@ -196,7 +196,7 @@ export async function propertyNodes(t) { 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)) && + ['Number', 'Text', 'Boolean', 'Color', 'Texture', 'List', 'Struct'].every((k) => kinds.includes(k)) && !kinds.includes('width'), kinds.join(' | '), ); diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index 3827a336..5fd6dcf5 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -46,7 +46,7 @@ const logicGraph = (walkId) => ({ node('an-add', 'Add', 300, 200, { b: 1 }), node('an-owner', 'Owner', 300, 360), node('an-wear', 'Properties', 600, 0, { ...propertiesOf('sprite'), texture: 'an-run' }, [ - written('texture', 'text'), + written('texture', 'texture'), ]), ], edges: [ @@ -398,6 +398,43 @@ export async function animation(t) { JSON.stringify({ texture: worn?.props.texture, clock: worn?.animation, after }), ); + // 25l'. A picture is a kind of its own: the Texture node hands one into the graph, and a + // variable may be one. Both are picked, never typed. + const pickPicture = async (label, name) => { + const field = await json(`__gm.inspectorField(${JSON.stringify(label)}, true)`); + await click(field.x, field.y); + await sleep(200); + await typeText(name); + await sleep(150); + await press('Enter'); + await sleep(300); + }; + await mutate(` + const logic = __gm.state.project.assets.find((a) => a.id === 'an-logic'); + logic.graph.nodes.push(${JSON.stringify(node('an-pic', 'Texture', 600, 300))}); + logic.variables.push(${JSON.stringify(variable('pic', 'texture', ''))}); + logic.props.pic = ''; + __gm.state.select({ kind: 'node', assetId: 'an-logic', nodeId: 'an-pic', nodeIds: ['an-pic'] }); + `); + await sleep(400); + await pickPicture('Value', walk.name); + const picNode = (await t.asset('an-logic')).graph.nodes.find((n) => n.id === 'an-pic'); + const picRows = (await json("__gm.graph.nodeSettings('an-pic')")) ?? []; + check( + 'the Texture node picks its picture and shows it by name', + picNode?.params.value === walk.id && picRows.some((r) => r.includes(walk.name)), + JSON.stringify({ params: picNode?.params, rows: picRows }), + ); + await evaluate("__gm.state.select({ kind: 'asset', assetId: 'an-logic' })"); + await sleep(400); + await pickPicture('pic', walk.name); + check( + 'a texture variable is picked with the same picker', + (await t.asset('an-logic')).props.pic === walk.id, + JSON.stringify((await t.asset('an-logic')).props), + ); + await shot('04b-texture-kind'); + // 25m. Deleting the texture counts the animations that play its frames. const again = await json(`__gm.assetRow(${JSON.stringify(strip)})`); await click(again.x, again.y, 'right'); diff --git a/tools/seed-actor.mjs b/tools/seed-actor.mjs index b77b8027..83300a10 100644 --- a/tools/seed-actor.mjs +++ b/tools/seed-actor.mjs @@ -148,8 +148,8 @@ const nodes = [ ]), node('pick-walk', 'ItemAt', 1680, 1430), node('pick-idle', 'ItemAt', 1680, 1640), - node('wear-walk', 'Properties', 1920, 1280, propertiesOf('sprite'), [written('texture', 'text')]), - node('wear-idle', 'Properties', 1920, 1510, propertiesOf('sprite'), [written('texture', 'text')]), + node('wear-walk', 'Properties', 1920, 1280, propertiesOf('sprite'), [written('texture', 'texture')]), + node('wear-idle', 'Properties', 1920, 1510, propertiesOf('sprite'), [written('texture', 'texture')]), ]; const edges = [ diff --git a/tools/seed-tetris.mjs b/tools/seed-tetris.mjs index 2beeefe9..bb7fcaa8 100644 --- a/tools/seed-tetris.mjs +++ b/tools/seed-tetris.mjs @@ -440,7 +440,7 @@ function drawBoardGraph() { 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', 'Properties', 8, 0, propertiesOf('sprite'), [written('color', 'number')]); + g.at('paint', 'Properties', 8, 0, propertiesOf('sprite'), [written('color', 'color')]); g.link('start', 'grid', 'cell', 'list'); g.link('loop', 'index', 'cell', 'index'); g.link('cell', 'item', 'filled', 'a'); @@ -656,7 +656,7 @@ function drawPieceGraph() { at('place', 'SetPosition', 6, 0); at('paint', 'Properties', 7, 0, { ...propertiesOf('sprite'), enabled: true }, [ written('enabled', 'boolean'), - written('color', 'number'), + written('color', 'color'), ]); link('cell', 'exec', 'place', 'exec'); for (const id of ['place', 'paint']) link(object, 'object', id, 'object');