diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index 64960d8c..a66ea2d8 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -1,7 +1,7 @@ import type { PropertyBag } from '../model/Properties'; /** `exec` carries control flow, the others values; `any` accepts every value type. */ -export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'any'; +export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'any'; export interface PortRef { node: string; @@ -31,7 +31,7 @@ export interface DeclaredPort { kind: DeclaredPortKind; } -export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object'] as const; +export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object', 'list'] as const; export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number]; export function declaredPortType(kind: DeclaredPortKind): PortType { diff --git a/src/core/graph/nodes/data/Concat.ts b/src/core/graph/nodes/data/Concat.ts new file mode 100644 index 00000000..28c71a73 --- /dev/null +++ b/src/core/graph/nodes/data/Concat.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asList } from '../../values'; +import { list } from '../shared'; + +export const Concat: NodeDef = { + type: 'Concat', + label: 'Join lists', + category: 'data', + description: 'Two lists one after the other. A single value counts as a list of one.', + inputs: [list('a'), list('b')], + outputs: [list('result')], + params: [], + run: (_ctx, inputs) => ({ outputs: { result: [...asList(inputs.a), ...asList(inputs.b)] } }), +}; diff --git a/src/core/graph/nodes/data/Count.ts b/src/core/graph/nodes/data/Count.ts new file mode 100644 index 00000000..e16816ad --- /dev/null +++ b/src/core/graph/nodes/data/Count.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asList } from '../../values'; +import { list, num } from '../shared'; + +export const Count: NodeDef = { + type: 'Count', + label: 'Count', + category: 'data', + description: 'How many items a list holds.', + inputs: [list('list')], + outputs: [num('count')], + params: [], + run: (_ctx, inputs) => ({ outputs: { count: asList(inputs.list).length } }), +}; diff --git a/src/core/graph/nodes/data/Fill.ts b/src/core/graph/nodes/data/Fill.ts new file mode 100644 index 00000000..b95f4abd --- /dev/null +++ b/src/core/graph/nodes/data/Fill.ts @@ -0,0 +1,20 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asListItem, asNumber } from '../../values'; +import { any, list, num, withDefault } 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; + +export const Fill: NodeDef = { + type: 'Fill', + 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)], + outputs: [list('result')], + params: [], + run: (_ctx, inputs) => { + const count = Math.min(MAX_ITEMS, Math.max(0, Math.floor(asNumber(inputs.count)))); + return { outputs: { result: new Array(count).fill(asListItem(inputs.value)) } }; + }, +}; diff --git a/src/core/graph/nodes/data/IndexOf.ts b/src/core/graph/nodes/data/IndexOf.ts new file mode 100644 index 00000000..27752a35 --- /dev/null +++ b/src/core/graph/nodes/data/IndexOf.ts @@ -0,0 +1,16 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asList, asListItem } from '../../values'; +import { any, list, num } from '../shared'; + +export const IndexOf: NodeDef = { + type: 'IndexOf', + label: 'Index of', + category: 'data', + description: 'Where a value sits in a list, or -1 if it is not in there – how "is this row full" is asked.', + inputs: [list('list'), any('value')], + outputs: [num('index')], + params: [], + run: (_ctx, inputs) => ({ + outputs: { index: asList(inputs.list).indexOf(asListItem(inputs.value)) }, + }), +}; diff --git a/src/core/graph/nodes/data/ItemAt.ts b/src/core/graph/nodes/data/ItemAt.ts new file mode 100644 index 00000000..c56f8ae1 --- /dev/null +++ b/src/core/graph/nodes/data/ItemAt.ts @@ -0,0 +1,18 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asList, asNumber } from '../../values'; +import { any, list, num } 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')], + outputs: [any('item')], + run: (_ctx, inputs) => { + const items = asList(inputs.list); + const i = Math.floor(asNumber(inputs.index)); + return { outputs: { item: items[i] ?? 0 } }; + }, + params: [], +}; diff --git a/src/core/graph/nodes/data/ListLiteral.ts b/src/core/graph/nodes/data/ListLiteral.ts new file mode 100644 index 00000000..5b022b20 --- /dev/null +++ b/src/core/graph/nodes/data/ListLiteral.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asList } from '../../values'; +import { list } from '../shared'; + +export const ListLiteral: NodeDef = { + type: 'List', + label: 'List', + category: 'data', + description: 'A constant list, typed as one comma-separated line.', + inputs: [], + outputs: [list('value')], + params: [{ key: 'value', label: 'Value', kind: 'list', default: [], items: 'number' }], + run: (_ctx, _inputs, params) => ({ outputs: { value: asList(params.value) } }), +}; diff --git a/src/core/graph/nodes/data/SetItem.ts b/src/core/graph/nodes/data/SetItem.ts new file mode 100644 index 00000000..231865ab --- /dev/null +++ b/src/core/graph/nodes/data/SetItem.ts @@ -0,0 +1,21 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asList, asListItem, asNumber } from '../../values'; +import { any, list, num } 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')], + outputs: [list('result')], + run: (_ctx, inputs) => { + const items = asList(inputs.list); + const i = Math.floor(asNumber(inputs.index)); + if (i < 0 || i >= items.length) return { outputs: { result: items } }; + const next = [...items]; + next[i] = asListItem(inputs.value); + return { outputs: { result: next } }; + }, + params: [], +}; diff --git a/src/core/graph/nodes/data/SubList.ts b/src/core/graph/nodes/data/SubList.ts new file mode 100644 index 00000000..7c139549 --- /dev/null +++ b/src/core/graph/nodes/data/SubList.ts @@ -0,0 +1,19 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asList, asNumber } from '../../values'; +import { list, num, withDefault } 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)], + outputs: [list('result')], + params: [], + run: (_ctx, inputs) => { + const items = asList(inputs.list); + const start = Math.max(0, Math.floor(asNumber(inputs.start))); + const count = Math.max(0, Math.floor(asNumber(inputs.count))); + return { outputs: { result: items.slice(start, start + count) } }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index a1203982..f7b831b0 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -55,6 +55,14 @@ import { Subtract } from './data/Subtract'; import { Time } from './data/Time'; import { ToNumber } from './data/ToNumber'; import { ToText } from './data/ToText'; +import { Concat } from './data/Concat'; +import { Count } from './data/Count'; +import { Fill } from './data/Fill'; +import { IndexOf } from './data/IndexOf'; +import { ItemAt } from './data/ItemAt'; +import { ListLiteral } from './data/ListLiteral'; +import { SetItem } from './data/SetItem'; +import { SubList } from './data/SubList'; import { OnClick } from './events/OnClick'; import { OnCollisionEnter } from './events/OnCollisionEnter'; import { OnCollisionExit } from './events/OnCollisionExit'; @@ -140,6 +148,15 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ And, Or, Not, + // data: lists + ListLiteral, + Fill, + Count, + ItemAt, + SetItem, + SubList, + Concat, + IndexOf, // data: vectors and input Length, Distance, diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index 797a1bcb..aacbdf07 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -10,6 +10,7 @@ export const num = (key: string, label = key): PortDef => ({ key, label, type: ' export const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' }); export const str = (key: string, label = key): PortDef => ({ key, label, type: 'string' }); export const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' }); +export const list = (key: string, label = key): PortDef => ({ key, label, type: 'list' }); export const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' }); export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value }); diff --git a/src/core/graph/values.ts b/src/core/graph/values.ts index 5a3cde36..50ffc96b 100644 --- a/src/core/graph/values.ts +++ b/src/core/graph/values.ts @@ -1,4 +1,4 @@ -import type { PropertyBag, PropertyValue } from '../model/Properties'; +import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties'; import type { EntityHandle } from './NodeContext'; /** A value travelling along a data edge. Objects are live entity handles (play mode only). */ @@ -11,9 +11,24 @@ export function asNumber(value: Value | undefined): number { const n = Number(value); return Number.isFinite(n) ? n : 0; } + if (isList(value)) return value.length; return 0; } +/** Anything that is not a list reads as a one-item list, so a lone value can feed a list input. */ +export function asList(value: Value | undefined): ListValue { + if (isList(value)) return value; + if (value === null || value === undefined) return []; + return typeof value === 'object' ? [value.name] : [value]; +} + +/** A value narrowed to what a list may hold: handles and nested lists have no stored form. */ +export function asListItem(value: Value | undefined): number | string | boolean { + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value; + if (value === null || value === undefined) return 0; + return asString(value); +} + export function asBoolean(value: Value | undefined): boolean { if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0'; return Boolean(value); @@ -22,12 +37,14 @@ export function asBoolean(value: Value | undefined): boolean { /** Entities read as their name, so a handle can be logged or joined into text directly. */ export function asString(value: Value | undefined): string { if (value === null || value === undefined) return ''; + if (isList(value)) return value.join(', '); if (typeof value === 'object') return value.name; return String(value); } export function asEntity(value: Value | undefined): EntityHandle | null { - return value && typeof value === 'object' ? value : null; + if (!value || typeof value !== 'object' || isList(value)) return null; + return value; } /** @@ -36,7 +53,8 @@ export function asEntity(value: Value | undefined): EntityHandle | null { */ export function writeTyped(bag: PropertyBag, key: string, value: Value | undefined): void { const current = bag[key]; - if (typeof current === 'number') bag[key] = asNumber(value); + if (isList(current)) bag[key] = asList(value); + else if (typeof current === 'number') bag[key] = asNumber(value); else if (typeof current === 'boolean') bag[key] = asBoolean(value); else bag[key] = asString(value); } diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts index d39df599..ba80d54b 100644 --- a/src/core/model/Properties.ts +++ b/src/core/model/Properties.ts @@ -1,8 +1,25 @@ +/** + * A list is deliberately flat – a list of lists would need a nested editor and a deep compare on + * every override, and nothing so far wants one: a board is addressed as `row · width + column`. + */ +export type ListValue = readonly (number | string | boolean)[]; + /** Colors are stored as 0xRRGGBB numbers. */ -export type PropertyValue = number | string | boolean; +export type PropertyValue = number | string | boolean | ListValue; export type PropertyBag = Record; +export const isList = (value: unknown): value is ListValue => Array.isArray(value); + +/** Values are compared by content, because two equal lists are never the same array. */ +export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean { + if (isList(a) || isList(b)) { + if (!isList(a) || !isList(b) || a.length !== b.length) return false; + return a.every((item, i) => item === b[i]); + } + return a === b; +} + /** `undefined` removes the key, which is how an undo of "no value" works. */ export function setBagValue(bag: PropertyBag, key: string, value: PropertyValue | undefined): void { if (value === undefined) delete bag[key]; diff --git a/src/core/model/instance.ts b/src/core/model/instance.ts index b9a665cc..0793f426 100644 --- a/src/core/model/instance.ts +++ b/src/core/model/instance.ts @@ -15,6 +15,7 @@ import { type Position, type PropertyBag, type PropertyValue, + sameValue, setBagValue, type Vec2, } from './Properties'; @@ -312,7 +313,7 @@ function instanceRef(target: InstanceTarget, object: SceneObject): ObjectRef { }), setOverride: (key, value) => write((edit) => { - if (value === target.base.overrides[key]) delete edit.overrides[key]; + if (sameValue(value, target.base.overrides[key])) delete edit.overrides[key]; else setBagValue(edit.overrides, key, value); }), }; diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 515aa8ab..de1ad463 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -1,6 +1,6 @@ import type { Asset } from '../model/Asset'; import { type AssetType, isPlaceableType } from '../model/AssetTypes'; -import type { PropertyBag, PropertyValue } from '../model/Properties'; +import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties'; /** The inspector is generated from these entries, so a new property is one line in a schema below. */ export type PropertyDef = @@ -34,11 +34,20 @@ export type PropertyDef = * The id of a texture asset or of one of its frames. Its own kind rather than an `asset` * reference, because a frame is not an asset and the picker has to offer both. May dangle. */ - | { key: string; label: string; kind: 'texture'; default: string }; + | { key: string; label: string; kind: 'texture'; default: string } + /** + * `items` only decides how the inspector reads and writes the line; the graph types a list as a + * list, so one `list` port fits them all. There is no `object` item kind: a list is stored in the + * project file, and an object is a live handle that no reload survives. + */ + | { key: string; label: string; kind: 'list'; default: ListValue; items: ListItemKind }; + +export const LIST_ITEM_KINDS = ['number', 'text', 'boolean', 'color'] as const; +export type ListItemKind = (typeof LIST_ITEM_KINDS)[number]; export type PropertySchema = readonly PropertyDef[]; -export const VARIABLE_KINDS = ['number', 'boolean', 'text'] as const; +export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'list'] as const; export type VariableKind = (typeof VARIABLE_KINDS)[number]; /** @@ -155,7 +164,12 @@ export function defaultsOf(schema: PropertySchema): PropertyBag { return bag; } -export function variableDef(key: string, kind: VariableKind, defaultValue?: PropertyValue): PropertyDef { +export function variableDef( + key: string, + kind: VariableKind, + defaultValue?: PropertyValue, + items?: ListItemKind, +): PropertyDef { switch (kind) { case 'number': return { key, label: key, kind, default: typeof defaultValue === 'number' ? defaultValue : 0 }; @@ -163,6 +177,14 @@ export function variableDef(key: string, kind: VariableKind, defaultValue?: Prop return { key, label: key, kind, default: Boolean(defaultValue ?? false) }; case 'text': return { key, label: key, kind, default: defaultValue === undefined ? '' : String(defaultValue) }; + case 'list': + return { + key, + label: key, + kind, + default: isList(defaultValue) ? defaultValue : [], + items: items ?? 'number', + }; } } @@ -191,5 +213,36 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal const s = String(value); return def.options.some((o) => o.value === s) ? s : def.default; } + case 'list': + return isList(value) ? value.map((item) => coerceListItem(item, def.items)) : def.default; } } + +/** Colors read back as `#rrggbb` so what the inspector shows can be typed in again unchanged. */ +export function listToText(list: ListValue, items: ListItemKind): string { + return list + .map((v) => (items === 'color' ? `#${Number(v).toString(16).padStart(6, '0')}` : String(v))) + .join(', '); +} + +export function listFromText(text: string, items: ListItemKind): ListValue { + const trimmed = text.trim(); + if (trimmed === '') return []; + return trimmed.split(',').map((part) => coerceListItem(part.trim(), items)); +} + +export function coerceListItem(value: PropertyValue, items: ListItemKind): number | string | boolean { + if (items === 'text') return isList(value) ? value.join(', ') : String(value); + if (items === 'boolean') { + if (typeof value === 'boolean') return value; + // A list is typed as text, so the spelling has to count: Boolean('false') would be true. + const s = String(value).trim().toLowerCase(); + return !(s === '' || s === 'false' || s === '0' || s === 'no'); + } + if (items === 'color') { + const c = typeof value === 'number' ? value : parseInt(String(value).replace('#', ''), 16); + return Number.isFinite(c) ? Math.max(0, Math.min(0xffffff, Math.floor(c))) : 0; + } + const n = typeof value === 'number' ? value : Number(value); + return Number.isFinite(n) ? n : 0; +} diff --git a/src/core/properties/resolve.ts b/src/core/properties/resolve.ts index 38c69ded..34e85a2e 100644 --- a/src/core/properties/resolve.ts +++ b/src/core/properties/resolve.ts @@ -1,5 +1,5 @@ import type { Asset } from '../model/Asset'; -import type { PropertyBag, PropertyValue } from '../model/Properties'; +import { type PropertyBag, type PropertyValue, sameValue } from '../model/Properties'; import type { SceneObject } from '../model/SceneObject'; import { propDef, schemaForAsset } from './PropertySchema'; @@ -35,7 +35,7 @@ export function isModified(asset: Asset, key: string): boolean { const def = propDef(asset, key); if (!def) return false; const own = asset.props[key]; - return own !== undefined && own !== def.default; + return own !== undefined && !sameValue(own, def.default); } /** The value the object would have without its override for `key`. */ @@ -46,7 +46,7 @@ export function inheritedValue(asset: Asset, key: string): PropertyValue | undef /** An override that happens to carry the asset's own value does not count as a deviation. */ export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string): boolean { const own = object.overrides[key]; - return own !== undefined && own !== inheritedValue(asset, key); + return own !== undefined && !sameValue(own, inheritedValue(asset, key)); } /** @@ -54,5 +54,5 @@ export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string * "neither overrides" means equal even when the asset value changed since. */ export function deviatesFromTemplate(object: SceneObject, base: SceneObject, key: string): boolean { - return object.overrides[key] !== base.overrides[key]; + return !sameValue(object.overrides[key], base.overrides[key]); } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 8eef2a22..edc5796b 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -22,6 +22,8 @@ import { coerceValue, COMMON_PROPS, isCommonProp, + LIST_ITEM_KINDS, + type ListItemKind, type PropertyDef, type PropertySchema, schemaFor, @@ -150,8 +152,10 @@ function normalizeInstanceEdits(raw: unknown): Record { const offset = asRecord(e.editorOffset); if (typeof offset.x === 'number' || typeof offset.y === 'number') edit.editorOffset = { x: toNumber(offset.x), y: toNumber(offset.y) }; - for (const [key, v] of Object.entries(asRecord(e.overrides))) - if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') edit.overrides[key] = v; + for (const [key, v] of Object.entries(asRecord(e.overrides))) { + const value = asStoredValue(v); + if (value !== undefined) edit.overrides[key] = value; + } if (!isEmptyEdit(edit)) edits[path] = edit; } return edits; @@ -169,7 +173,10 @@ function normalizeVariables(raw: unknown): PropertyDef[] { // Common property keys are reserved – a variable of that name would shadow the switch. if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key) || isCommonProp(key)) continue; seen.add(key); - const def = variableDef(key, kind); + const items = (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items)) + ? (v.items as ListItemKind) + : undefined; + const def = variableDef(key, kind, undefined, items); def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue); defs.push(def); } @@ -287,6 +294,20 @@ function asRecord(value: unknown): Record { return value && typeof value === 'object' ? (value as Record) : {}; } +/** + * A value from an untyped slot (instance edits carry no schema to coerce against). Lists are kept + * but flattened away one level, so nothing nested can enter the model behind the type's back. + */ +function asStoredValue(value: unknown): PropertyValue | undefined { + if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value; + if (Array.isArray(value)) + return value.filter( + (item): item is number | string | boolean => + typeof item === 'number' || typeof item === 'string' || typeof item === 'boolean', + ); + return undefined; +} + function toNumber(value: unknown): number { const n = Number(value); return Number.isFinite(n) ? n : 0; diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 75b3643e..7016c169 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -14,7 +14,7 @@ import { } from '../../core/graph'; import type { BehaviorAsset } from '../../core/model/Asset'; import type { Project } from '../../core/model/Project'; -import type { PropertyValue } from '../../core/model/Properties'; +import { isList, type PropertyValue } from '../../core/model/Properties'; import { assetRefLabel } from '../../core/properties/assetRef'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui'; @@ -58,6 +58,8 @@ export function portColor(type: PortType): number { return c.portString; case 'object': return c.portObject; + case 'list': + return c.portList; case 'any': return c.portAny; } @@ -295,7 +297,7 @@ function formatParam( asset && !asset.variables.some((v) => v.key === value) ) - return `${value}?`; + return `${asString(value)}?`; return formatValue(value); } @@ -303,5 +305,11 @@ function formatValue(value: Value): string { // Whole numbers stay bare to keep nodes narrow; fractions match the field that edits them. if (typeof value === 'number') return Number.isInteger(value) ? String(value) : formatNumber(value); if (typeof value === 'boolean') return value ? 'true' : 'false'; + // A list can hold hundreds of items – shown whole it would stretch the node across the graph. + if (isList(value)) { + if (value.length === 0) return 'empty'; + const head = value.slice(0, 4).map(formatValue).join(', '); + return value.length > 4 ? `${head}, … (${value.length})` : head; + } return asString(value); } diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 3709c307..70a0bc9d 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -29,13 +29,15 @@ const VARIABLE_KIND_LABELS: Record = { number: 'Number', boolean: 'Boolean', text: 'Text', + list: 'List', }; // Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`, but only the -// three VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in. +// VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in. const VARIABLE_KIND_MARKS: Partial> = { number: '#', boolean: '✓', text: 'Aa', + list: '≡', }; /** Structure-defining facts of an asset selection: the schema keys and kinds. */ diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index c6ff8d17..41f33bc1 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -1,4 +1,4 @@ -import type { PropertyValue } from '../../core/model/Properties'; +import { isList, type PropertyValue } from '../../core/model/Properties'; import type { Project } from '../../core/model/Project'; import { assetChoiceLabel, @@ -6,7 +6,7 @@ import { assetRefLabel, NO_ASSET_LABEL, } from '../../core/properties/assetRef'; -import type { PropertyDef } from '../../core/properties/PropertySchema'; +import { listFromText, listToText, type PropertyDef } from '../../core/properties/PropertySchema'; import { NO_TEXTURE_LABEL, textureRefChoices, textureRefLabel } from '../../core/properties/textureRef'; import { Button, @@ -194,6 +194,15 @@ export function createField( const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange }); return { widget: field, grow: true, setValue: (v) => field.setValue(Number(v)) }; } + case 'list': { + // Typed as one comma-separated line rather than a row per item: a board is hundreds of + // entries, and a growing list of fields would push everything else out of the inspector. + const asText = (v: PropertyValue) => listToText(isList(v) ? v : [], def.items); + const field = new TextField(asText(value), { + onChange: (text) => handlers.onChange(listFromText(String(text), def.items)), + }); + return { widget: field, grow: true, setValue: (v) => field.setValue(asText(v)) }; + } case 'boolean': { const field = new Checkbox(Boolean(value)); field.onChange = handlers.onChange; diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index 1fc79728..edbf36c4 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -13,6 +13,7 @@ const KIND_LABELS: Record = { boolean: 'Boolean', text: 'Text', object: 'Object', + list: 'List', }; const KIND_MARKS: Record = { @@ -20,6 +21,7 @@ const KIND_MARKS: Record = { boolean: '✓', text: 'Aa', object: '◇', + list: '≡', }; /** diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 2d53eebd..9764550c 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -58,6 +58,7 @@ export const Theme = { portBoolean: 0xe57373, portString: 0xe6a8ff, portObject: 0x56ccf2, + portList: 0xf2a75c, portAny: 0xbdbdbd, /** Ring around the port a running edge drag would connect to. */ portTarget: 0xffffff, diff --git a/tests/core/list-value.test.ts b/tests/core/list-value.test.ts new file mode 100644 index 00000000..90f08855 --- /dev/null +++ b/tests/core/list-value.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { sameValue } from '../../src/core/model/Properties'; +import { findAsset, objectHolders, type Project } from '../../src/core/model/Project'; +import { deserializeProject } from '../../src/core/store/serialize'; +import { coerceValue, listFromText, listToText, variableDef } from '../../src/core/properties/PropertySchema'; +import { deviatesFromAsset } from '../../src/core/properties/resolve'; +import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; + +const findObject = (project: Project, id: string) => + objectHolders(project) + .flatMap((holder) => holder.objects) + .find((o) => o.id === id); + +describe('a list as a value', () => { + it('survives saving and loading – with its items, not as a string', () => { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'B'); + f.stack.execute(new AddVariableCommand(behavior.id, 'grid', 'list')); + const object = place(f, behavior.id, { x: 0, y: 0 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'grid', [1, 0, 2])); + + const loaded = roundTrip(f.project); + const stored = findObject(loaded, object)?.overrides.grid; + expect(stored).toEqual([1, 0, 2]); + }); + + it('survives inside a container instance too, where no schema is there to coerce it', () => { + // Instance edits are read from an untyped slot: the old filter kept number/string/boolean and + // dropped everything else, which would have swallowed the list without a word. + const loaded = deserializeProject( + { + name: 'P', + activeSceneId: 's1', + assets: [ + { + id: 's1', + type: 'scene', + objects: [ + { + id: 'i1', + assetId: 'c1', + position: {}, + instanceEdits: { b1: { overrides: { grid: [3, 4] } } }, + }, + ], + }, + { id: 'c1', type: 'container', objects: [] }, + ], + }, + 'p1', + ); + expect(findObject(loaded, 'i1')?.instanceEdits.b1?.overrides.grid).toEqual([3, 4]); + }); + + it('is compared by its items, so reloading does not turn every list into an override', () => { + // The same items in a fresh array: a reference compare would call this a deviation on every load. + expect(sameValue([1, 2, 3], [1, 2, 3])).toBe(true); + expect(sameValue([1, 2, 3], [1, 2])).toBe(false); + expect(sameValue([1, 2, 3], [1, 9, 3])).toBe(false); + }); + + it('an override that only repeats the default is not one', () => { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'B'); + f.stack.execute(new AddVariableCommand(behavior.id, 'row', 'list')); + const asset = findAsset(f.project, behavior.id)!; + asset.props.row = [1, 2]; + const object = place(f, behavior.id, { x: 0, y: 0 }); + + f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 2])); + const placed = f.object(object); + expect(deviatesFromAsset(placed, asset, 'row')).toBe(false); + + f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 3])); + expect(deviatesFromAsset(placed, asset, 'row')).toBe(true); + }); + + it('reads and writes as one comma-separated line, item kind by item kind', () => { + expect(listFromText('1, 2, 3', 'number')).toEqual([1, 2, 3]); + expect(listFromText('', 'number')).toEqual([]); + expect(listFromText('a, b', 'text')).toEqual(['a', 'b']); + // Spelling has to count here: Boolean('false') would be true. + expect(listFromText('true, false, 0', 'boolean')).toEqual([true, false, false]); + expect(listFromText('#ff0000, #00ff00', 'color')).toEqual([0xff0000, 0x00ff00]); + expect(listToText([0xff0000], 'color')).toBe('#ff0000'); + expect(listToText([1, 2], 'number')).toBe('1, 2'); + }); + + it('a value that is not a list falls back to the default rather than becoming one', () => { + const def = variableDef('grid', 'list', [7], 'number'); + expect(coerceValue(def, 'nonsense')).toEqual([7]); + expect(coerceValue(def, ['1', 2])).toEqual([1, 2]); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index eae414c7..7db7d1ee 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -289,6 +289,62 @@ const CASES: Record = { outputs: { text: 'b' }, }, ], + // --- lists ----------------------------------------------------------------------------------- + + List: [ + { name: 'hands out the list it was given', params: { value: [1, 2, 3] }, outputs: { value: [1, 2, 3] } }, + { name: 'an unset list is empty, not missing', outputs: { value: [] } }, + ], + Fill: [ + { name: 'repeats one value', inputs: { value: 0, count: 4 }, outputs: { result: [0, 0, 0, 0] } }, + { name: 'a negative count gives nothing', inputs: { value: 1, count: -3 }, outputs: { result: [] } }, + ], + Count: [ + { name: 'counts the items', inputs: { list: ['a', 'b'] }, outputs: { count: 2 } }, + { name: 'a lone value counts as one', inputs: { list: 7 }, outputs: { count: 1 } }, + ], + ItemAt: [ + { name: 'reads by index, counting from 0', inputs: { list: [4, 5, 6], index: 1 }, outputs: { item: 5 } }, + { name: 'past the end is 0, not a crash', inputs: { list: [4], index: 9 }, outputs: { item: 0 } }, + ], + SetItem: [ + { + name: 'replaces one item', + inputs: { list: [1, 2, 3], index: 1, value: 9 }, + outputs: { result: [1, 9, 3] }, + }, + { + name: 'leaves the list it was given untouched – a graph may hold it elsewhere', + inputs: { list: Object.freeze([1, 2, 3]), index: 0, value: 9 }, + outputs: { result: [9, 2, 3] }, + }, + { + name: 'an index outside the list changes nothing', + inputs: { list: [1, 2], index: 5, value: 9 }, + outputs: { result: [1, 2] }, + }, + ], + SubList: [ + { + name: 'takes count items from start', + inputs: { list: [1, 2, 3, 4], start: 1, count: 2 }, + outputs: { result: [2, 3] }, + }, + { + name: 'asking past the end gives what is there', + inputs: { list: [1, 2], start: 1, count: 9 }, + outputs: { result: [2] }, + }, + ], + Concat: [ + { name: 'one list after the other', inputs: { a: [1], b: [2, 3] }, outputs: { result: [1, 2, 3] } }, + { name: 'a lone value joins as one item', inputs: { a: [1], b: 2 }, outputs: { result: [1, 2] } }, + ], + IndexOf: [ + { name: 'finds where a value sits', inputs: { list: [7, 8, 9], value: 8 }, outputs: { index: 1 } }, + { name: 'a value that is not in there is -1', inputs: { list: [7], value: 8 }, outputs: { index: -1 } }, + ], + FindText: [ { name: 'finds where the search first appears', diff --git a/tools/lib/builders.mjs b/tools/lib/builders.mjs index 871bec5e..36a63b97 100644 --- a/tools/lib/builders.mjs +++ b/tools/lib/builders.mjs @@ -46,5 +46,11 @@ export const node = (id, type, x, y, params = {}, ports) => ({ /** A port a behavior's start or end node declares. */ export const port = (key, kind) => ({ key, kind }); -/** A behavior variable declaration. */ -export const variable = (key, kind, value) => ({ key, label: key, kind, default: value }); +/** A behavior variable declaration. `items` is the item kind of a `list`, ignored otherwise. */ +export const variable = (key, kind, value, items) => ({ + key, + label: key, + kind, + default: value, + ...(items ? { items } : {}), +});