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

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

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

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

No files matched your search

+10 -5
View File
@@ -140,14 +140,19 @@ shown on the node and editable in the inspector.
neighbouring item touched. A **struct item** carries its own shape the same way: its fields are
renamed, retyped, added (`+ Field ▾`) and removed on its own rows, which grows that one item and
not the list. Two kinds need saying, because JSON cannot tell them from the value beside them: a
**colour** is a number (`{"$color": 16711680}`) and a **texture** is an id, which is a text
(`{"$texture": "walk-up"}`, the picker a sprite's `texture` property has – a whole picture, one
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
**colour** is a number and a **texture** is an id, which is a text. Such a *tagged* kind wears its
name as the one key of a small object – `{"$color": 16711680}`, `{"$texture": "walk-up"}` (the
picker a sprite's `texture` property has – a whole picture, one 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 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
one into the graph, as `Number` and `Text` do a number and a text. **Every kind is one row of one
table** (`core/properties/kinds.ts`): its word and mark, what it travels as on an edge, its empty
value, how a value is read as it, whether it is tagged and where it may be picked. The kind lists,
the `+ …` menus, the tags, the port fields and the literal nodes all follow from the row, so a new
kind is that row plus, if it names something in the project, its picker. 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
+1 -1
View File
@@ -7,13 +7,13 @@ import {
} from '../model/Properties';
import { overrideSites } from '../model/instance';
import type { Project } from '../model/Project';
import type { VariableKind } from '../properties/kinds';
import {
emptyFieldValue,
isValidFieldName,
isValidVariableName,
type PropertyDef,
type StructFieldDef,
type VariableKind,
variableDef,
} from '../properties/PropertySchema';
import { type Removed, removeFirst, restoreAt } from '../util/list';
+5 -16
View File
@@ -1,4 +1,7 @@
import type { PropertyBag } from '../model/Properties';
import { type DeclaredPortKind, KIND } from '../properties/kinds';
export type { DeclaredPortKind } from '../properties/kinds';
/** `exec` carries control flow, the others values; `any` accepts every value type. */
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'any';
@@ -28,7 +31,7 @@ export interface GraphNode {
collapsed?: boolean;
}
/** A port a start/end node declares. `kind` maps to the PortType of the same name (`text` → string). */
/** A port a start/end node declares. `kind` travels as the kind table says (`text` → string). */
export interface DeclaredPort {
key: string;
kind: DeclaredPortKind;
@@ -36,22 +39,8 @@ export interface DeclaredPort {
write?: true;
}
/** A color travels as a number and a texture as text; the kind says which field edits the value. */
export const DECLARED_PORT_KINDS = [
'number',
'boolean',
'text',
'color',
'texture',
'object',
'list',
'struct',
] as const;
export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number];
export function declaredPortType(kind: DeclaredPortKind): PortType {
if (kind === 'text' || kind === 'texture') return 'string';
return kind === 'color' ? 'number' : kind;
return KIND[kind].travels;
}
export interface GraphEdge {
+8 -19
View File
@@ -1,9 +1,10 @@
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { PropertyDef } from '../properties/PropertySchema';
import { isKindWith, plainKindOf } from '../properties/kinds';
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
import type { BehaviorLookup } from './lookup';
import type { NodeContext } from './NodeContext';
import { asBoolean, asList, asNumber, asString, type Value } from './values';
import { asList, type Value } from './values';
export interface PortDef {
key: string;
@@ -142,23 +143,11 @@ export function withInlineDefault(port: PortDef): PortDef {
export function inlineInputDef(port: PortDef): PropertyDef {
if (port.field) return { ...port.field, key: port.key, label: port.label };
switch (port.type) {
case 'boolean':
return { key: port.key, label: port.label, kind: 'boolean', default: asBoolean(port.default ?? false) };
case 'string':
case 'any':
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
case 'list':
return { key: port.key, label: port.label, kind: 'list', default: asList(port.default) };
default:
return {
key: port.key,
label: port.label,
kind: 'number',
default: asNumber(port.default ?? 0),
step: 1,
};
}
const kind = port.type === 'any' ? 'text' : plainKindOf(port.type);
if (kind === 'list') return { key: port.key, label: port.label, kind, default: asList(port.default) };
const flat = kind !== undefined && isKindWith('field', kind) ? kind : 'number';
const def = flatDef(port.key, flat, { label: port.label, value: port.default });
return def.kind === 'number' ? { ...def, step: 1 } : def;
}
/** The value an unconnected input carries: the node's own inline value, else the port default. */
+4 -7
View File
@@ -1,5 +1,6 @@
import { textOf } from '../model/Properties';
import { isValidFieldName } from '../properties/PropertySchema';
import { isTaggedKind } from '../properties/kinds';
import { flatDef, isValidFieldName } from '../properties/PropertySchema';
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
import type { BehaviorLookup } from './lookup';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
@@ -75,14 +76,10 @@ export function isTerminalNode(node: GraphNode): boolean {
return isTerminalType(node.type);
}
/** A color or texture port carries the field that edits it; the graph itself sees a number or a text. */
/** A tagged kind is one the value cannot show, so its port carries the field that edits it. */
export function portDefOf(port: DeclaredPort): PortDef {
const def: PortDef = { key: port.key, label: port.key, type: declaredPortType(port.kind) };
if (port.kind === 'color')
return { ...def, field: { key: port.key, label: port.key, kind: 'color', default: 0 } };
if (port.kind === 'texture')
return { ...def, field: { key: port.key, label: port.key, kind: 'texture', default: '' } };
return def;
return isTaggedKind(port.kind) ? { ...def, field: flatDef(port.key, port.kind) } : def;
}
/** The ports the start (or end) node of `graph` declares, as seen from a node calling it. */
-14
View File
@@ -1,14 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asBoolean } from '../../values';
import { bool } from '../shared';
export const BooleanLiteral: NodeDef = {
type: 'Boolean',
label: 'Boolean',
category: 'data',
description: 'A constant true/false.',
inputs: [],
outputs: [bool('value')],
params: [{ key: 'value', label: 'Value', kind: 'boolean', default: false }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asBoolean(params.value) } }),
};
+3 -4
View File
@@ -1,5 +1,4 @@
import { sameValue } from '../../../model/Properties';
import { itemValue } from '../../../properties/listItems';
import { sameValue, untag } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asListItem } from '../../values';
import { any, list, num } from '../shared';
@@ -15,9 +14,9 @@ export const IndexOf: NodeDef = {
// Compared by content, not by identity: two equal structs are never the same object. A color item
// is compared as its number, or searching a list of colors with one would never find it.
run: (_ctx, inputs) => {
const wanted = itemValue(asListItem(inputs.value));
const wanted = untag(asListItem(inputs.value));
return {
outputs: { index: asList(inputs.list).findIndex((item) => sameValue(itemValue(item), wanted)) },
outputs: { index: asList(inputs.list).findIndex((item) => sameValue(untag(item), wanted)) },
};
},
};
+2 -2
View File
@@ -1,4 +1,4 @@
import { itemValue } from '../../../properties/listItems';
import { untag } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber } from '../../values';
import { any, int, list } from '../shared';
@@ -14,7 +14,7 @@ export const ItemAt: NodeDef = {
run: (_ctx, inputs) => {
const items = asList(inputs.list);
const i = Math.floor(asNumber(inputs.index));
return { outputs: { item: itemValue(items[i]) ?? 0 } };
return { outputs: { item: untag(items[i]) ?? 0 } };
},
params: [],
};
-14
View File
@@ -1,14 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asList } from '../../values';
import { list } from '../shared';
export const ListLiteral: NodeDef = {
type: 'List',
label: 'List',
category: 'data',
description: 'A constant list, edited item by item in the List tab – each item with its own kind.',
inputs: [],
outputs: [list('value')],
params: [{ key: 'value', label: 'Value', kind: 'list', default: [] }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asList(params.value) } }),
};
-14
View File
@@ -1,14 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { num } from '../shared';
export const NumberLiteral: NodeDef = {
type: 'Number',
label: 'Number',
category: 'data',
description: 'A constant number.',
inputs: [],
outputs: [num('value')],
params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: 1 }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asNumber(params.value) } }),
};
-14
View File
@@ -1,14 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asString } from '../../values';
import { str } from '../shared';
export const StringLiteral: NodeDef = {
type: 'String',
label: 'Text',
category: 'data',
description: 'A constant text.',
inputs: [],
outputs: [str('value')],
params: [{ key: 'value', label: 'Value', kind: 'text', default: '' }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asString(params.value) } }),
};
-15
View File
@@ -1,15 +0,0 @@
import type { NodeDef } from '../../NodeRegistry';
import { asString } from '../../values';
import { str } from '../shared';
/** The picture is its id on the edge: a whole texture, one frame of a sheet or an animation. */
export const TextureLiteral: NodeDef = {
type: 'Texture',
label: 'Texture',
category: 'data',
description: 'A constant picture: a texture, one of its frames or an animation, as a sprite wears it.',
inputs: [],
outputs: [str('value')],
params: [{ key: 'value', label: 'Value', kind: 'texture', default: '' }],
run: (_ctx, _inputs, params) => ({ outputs: { value: asString(params.value) } }),
};
+37
View File
@@ -0,0 +1,37 @@
import { KIND, kindLabel, type VariableKind } from '../../../properties/kinds';
import { coerceValue, flatDef, type PropertyDef } from '../../../properties/PropertySchema';
import type { NodeDef } from '../../NodeRegistry';
/** A node handing out one constant of a kind: its one setting, edited as that kind, is its output. */
function literal(type: string, kind: Exclude<VariableKind, 'struct'>, description: string): NodeDef {
const value: PropertyDef =
kind === 'list'
? { key: 'value', label: 'Value', kind, default: [] }
: kind === 'number'
? { ...flatDef('value', kind, { label: 'Value' }), step: 1 }
: flatDef('value', kind, { label: 'Value' });
return {
type,
label: kindLabel(kind),
category: 'data',
description,
inputs: [],
outputs: [{ key: 'value', label: 'value', type: KIND[kind].travels }],
params: [value],
run: (_ctx, _inputs, params) => ({ outputs: { value: coerceValue(value, params.value ?? value.default) } }),
};
}
export const NumberLiteral = literal('Number', 'number', 'A constant number.');
export const BooleanLiteral = literal('Boolean', 'boolean', 'A constant true/false.');
export const StringLiteral = literal('String', 'text', 'A constant text.');
export const TextureLiteral = literal(
'Texture',
'texture',
'A constant picture: a texture, one of its frames or an animation, as a sprite wears it.',
);
export const ListLiteral = literal(
'List',
'list',
'A constant list, edited item by item in the List tab – each item with its own kind.',
);
+1 -5
View File
@@ -10,7 +10,6 @@ import { Abs } from './data/Abs';
import { Add } from './data/Add';
import { And } from './data/And';
import { AxisInput } from './data/AxisInput';
import { BooleanLiteral } from './data/Boolean';
import { Ceil } from './data/Ceil';
import { Clamp } from './data/Clamp';
import { Compare } from './data/Compare';
@@ -27,6 +26,7 @@ import { IsTouching } from './data/IsTouching';
import { Join } from './data/Join';
import { KeyIsDown } from './data/KeyIsDown';
import { Length } from './data/Length';
import { BooleanLiteral, ListLiteral, NumberLiteral, StringLiteral, TextureLiteral } from './data/literals';
import { Max } from './data/Max';
import { Min } from './data/Min';
import { Modulo } from './data/Modulo';
@@ -36,7 +36,6 @@ import { Multiply } from './data/Multiply';
import { NameOf } from './data/NameOf';
import { Normalize } from './data/Normalize';
import { Not } from './data/Not';
import { NumberLiteral } from './data/Number';
import { ObjectByName } from './data/ObjectByName';
import { Or } from './data/Or';
import { Owner } from './data/Owner';
@@ -47,10 +46,8 @@ import { Random } from './data/Random';
import { Round } from './data/Round';
import { Self } from './data/Self';
import { Sqrt } from './data/Sqrt';
import { StringLiteral } from './data/String';
import { Substring } from './data/Substring';
import { Subtract } from './data/Subtract';
import { TextureLiteral } from './data/Texture';
import { Time } from './data/Time';
import { ToNumber } from './data/ToNumber';
import { ToText } from './data/ToText';
@@ -60,7 +57,6 @@ import { ExpandStruct } from './data/ExpandStruct';
import { Fill } from './data/Fill';
import { IndexOf } from './data/IndexOf';
import { ItemAt } from './data/ItemAt';
import { ListLiteral } from './data/ListLiteral';
import { SetItem } from './data/SetItem';
import { SubList } from './data/SubList';
import { OnAnimationFinished } from './events/OnAnimationFinished';
+6 -5
View File
@@ -1,12 +1,13 @@
import { type DeclaredPortKind, isKindWith, KIND, plainKindOf } from '../properties/kinds';
import { itemKindOf, structItemFields } from '../properties/listItems';
import type { PropertyDef, StructFieldDef } from '../properties/PropertySchema';
import { DECLARED_PORT_KINDS, type DeclaredPort, type DeclaredPortKind, type GraphNode } from './Graph';
import type { DeclaredPort, GraphNode } from './Graph';
/** The port a variable or property travels on. An enum, a key or an asset is text. */
/** The port a variable or property travels on. A kind no port declares (an enum, a key) is what it travels as. */
export function portKindOf(def: PropertyDef): DeclaredPortKind {
return (DECLARED_PORT_KINDS as readonly string[]).includes(def.kind)
? (def.kind as DeclaredPortKind)
: 'text';
if (isKindWith('port', def.kind)) return def.kind;
const plain = plainKindOf(KIND[def.kind].travels);
return plain !== undefined && isKindWith('port', plain) ? plain : 'text';
}
/**
+28 -31
View File
@@ -1,8 +1,10 @@
import type { TaggedKind, ValueOf } from '../properties/kinds';
/** 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 or texture. */
export type FieldValue = number | string | boolean | ListValue | ColorItem | TextureItem;
/** What a struct field may hold. A field is never itself a struct – except a tagged item. */
export type FieldValue = number | string | boolean | ListValue | TaggedItem;
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
export type StructValue = { readonly [field: string]: FieldValue };
@@ -15,42 +17,38 @@ export type PropertyBag = Record<string, PropertyValue>;
export const isList = (value: unknown): value is ListValue => Array.isArray(value);
/**
* Inside a list a color and a texture have to say so: a list declares nothing about its items, and
* there 0xRRGGBB is a plain number and an asset id a plain text. A field name cannot start with `$`
* (`isValidFieldName`), so a tag can never collide with a struct item of one field.
* A value of a kind JSON cannot show, under its kind's tag: `{ "$color": 16711680 }`,
* `{ "$texture": "walk-up" }`. Only a list needs it, where nothing is declared. A field name cannot
* start with `$` (`isValidFieldName`), so a tag never collides with a struct item of one field.
*/
export const COLOR_TAG = '$color';
export const TEXTURE_TAG = '$texture';
export type TaggedItem = {
[K in TaggedKind]: { readonly [P in `$${K}`]: ValueOf<K> } & TaggedFields;
}[TaggedKind];
/** Indexable like any struct, so the one place that reads fields by name needs no special case. */
export interface ColorItem {
readonly $color: number;
interface TaggedFields {
readonly [field: string]: FieldValue;
}
/** A picture a sprite can wear: a texture, one of its frames, or an animation – all one id. */
export interface TextureItem {
readonly $texture: string;
readonly [field: string]: FieldValue;
const TAG = '$';
/** The kind `value` wears as a tag, if it is a tagged item. */
export function tagOf(value: unknown): string | undefined {
if (!isStruct(value)) return undefined;
const keys = Object.keys(value);
const key = keys.length === 1 ? keys[0] : undefined;
return key?.startsWith(TAG) ? key.slice(TAG.length) : undefined;
}
const isTagged = (value: unknown, tag: string): boolean =>
isStruct(value) && tag in value && Object.keys(value).length === 1;
export function tagged(kind: TaggedKind, value: number | string): TaggedItem {
return { [TAG + kind]: value } as TaggedItem;
}
export const isColorItem = (value: unknown): value is ColorItem => isTagged(value, COLOR_TAG);
export const isTextureItem = (value: unknown): value is TextureItem =>
isTagged(value, TEXTURE_TAG) && typeof (value as TextureItem).$texture === 'string';
export const colorItem = (color: number): ColorItem => ({ $color: color });
export const textureItem = (id: string): TextureItem => ({ $texture: id });
/** The number behind a tagged color. */
export const colorItemValue = (item: ColorItem): number => colorOf(item.$color, 0);
/** The id behind a tagged texture. */
export const textureItemValue = (item: TextureItem): string => item.$texture;
/** A tagged item as what it wraps; anything else as itself. */
export function untag(value: PropertyValue | undefined): PropertyValue | undefined {
const tag = tagOf(value);
return tag === undefined ? value : (value as StructValue)[TAG + tag];
}
/**
* Model-level only: a live entity handle is an object too, and the graph tells the two apart by
@@ -67,8 +65,7 @@ export function textOf(value: PropertyValue | undefined): string {
if (value === undefined) return '';
if (isList(value)) return value.map((item) => textOf(item)).join(', ');
// A tagged value reads as what it wraps, the same as the matching property does.
if (isColorItem(value)) return String(colorItemValue(value));
if (isTextureItem(value)) return textureItemValue(value);
if (tagOf(value) !== undefined) return textOf(untag(value));
if (isStruct(value))
return Object.entries(value)
.map(([field, item]) => `${field}: ${textOf(item)}`)
+2 -1
View File
@@ -3,6 +3,7 @@
* `Project.ts` so that module stays free of the property schema and of container instancing:
* both of those already import it, and answering this question needs both.
*/
import { isKindWith } from '../properties/kinds';
import { schemaForAsset } from '../properties/PropertySchema';
import type { AnimationAsset, Asset } from './Asset';
import { overrideSites } from './instance';
@@ -48,7 +49,7 @@ export function findPropertyUsages(project: Project, ids: string | readonly stri
const matches = (value: unknown): boolean => typeof value === 'string' && wanted.has(value);
for (const asset of project.assets) {
const keys = schemaForAsset(asset)
.filter((def) => def.kind === 'asset' || def.kind === 'texture')
.filter((def) => isKindWith('reference', def.kind))
.map((def) => def.key);
refKeys.set(asset.id, keys);
for (const key of keys) if (matches(asset.props[key])) result.push({ on: 'asset', asset, key });
+47 -77
View File
@@ -9,44 +9,40 @@ import {
type PropertyValue,
type StructValue,
textOf,
booleanOf,
colorOf,
} from '../model/Properties';
import { coerceField, flatListOf, listItemOf } from './listItems';
import {
coerceAs,
coerceField,
emptyOf,
type FieldKind,
type FlatKind,
type ValueOf,
type VariableKind,
} from './kinds';
import { flatListOf, listItemOf } from './listItems';
/**
* What a kind's definition carries beyond key, label and default. `decimals` belongs only on
* properties that are integral by meaning (counts, sizes in whole pixels), not on geometry: dragging
* produces fractions, and the field rounds the value away rather than hiding it. Without
* `assetTypes` an asset picker offers every placeable asset.
*/
type Extras<K extends FlatKind> = K extends 'number'
? { min?: number; max?: number; step?: number; decimals?: number }
: K extends 'enum'
? { options: { value: string; label: string }[] }
: K extends 'asset'
? { assetTypes?: readonly AssetType[] }
: unknown;
/** The definition of a flat kind: its default is a value of that kind, as the table says. */
export type FlatDef<K extends FlatKind = FlatKind> = {
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P> } & Extras<P>;
}[K];
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
/**
* `decimals` belongs only on properties that are integral by meaning (counts, sizes in whole
* pixels), not on geometry: dragging produces fractions, and the field rounds the value away
* rather than hiding it.
*/
| {
key: string;
label: string;
kind: 'number';
default: number;
min?: number;
max?: number;
step?: number;
decimals?: number;
}
| { key: string; label: string; kind: 'text'; default: string }
| { key: string; label: string; kind: 'color'; default: number }
| { key: string; label: string; kind: 'boolean'; default: boolean }
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
| { key: string; label: string; kind: 'key'; default: string }
/**
* An asset id (`''` = none); without `assetTypes` the picker offers every placeable asset.
* Ids are not validated on load, so a reference can dangle and readers must cope.
*/
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] }
/**
* 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 }
| FlatDef
/**
* A list declares nothing about its items: each one carries its own kind (see `listItems.ts`),
* and the graph types a list as a list, so one `list` port fits them all. There is no `object`
@@ -60,18 +56,6 @@ 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', '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, 'struct'] as const;
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
/** What a field of a struct property holds – a primitive plus `list`. */
export const STRUCT_FIELD_KINDS = [...FIELD_KINDS, 'list'] as const;
export type StructFieldKind = (typeof STRUCT_FIELD_KINDS)[number];
export interface PrimitiveFieldDef {
key: string;
kind: FieldKind;
@@ -89,8 +73,17 @@ export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
export type PropertySchema = readonly PropertyDef[];
export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'color', 'texture', 'list', 'struct'] as const;
export type VariableKind = (typeof VARIABLE_KINDS)[number];
/**
* The definition of a field kind, its default read from `value` (empty when nothing is given). The
* cast is the table's promise that `coerce` yields the kind.
*/
export function flatDef<K extends FieldKind>(
key: string,
kind: K,
{ label = key, value }: { label?: string; value?: PropertyValue } = {},
): FlatDef<K> {
return { key, label, kind, default: coerceField(value, kind) } as FlatDef<K>;
}
/**
* Properties every placeable asset has. A disabled object is not drawn, does not collide and its
@@ -238,35 +231,20 @@ export function variableDef(
fields?: readonly StructFieldDef[],
): PropertyDef {
switch (kind) {
case 'number':
return { key, label: key, kind, default: typeof defaultValue === 'number' ? defaultValue : 0 };
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': {
const declared = fields ?? [];
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
}
default:
return flatDef(key, kind, { value: defaultValue });
}
}
/** The empty value of a kind, so a field that is not there yet still has one. */
const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
number: 0,
text: '',
boolean: false,
color: 0,
texture: '',
};
export function emptyFieldValue(field: StructFieldDef): FieldValue {
return field.kind === 'list' ? [] : EMPTY_FIELD[field.kind];
return field.kind === 'list' ? [] : emptyOf(field.kind);
}
/**
@@ -285,21 +263,11 @@ export function structOf(fields: readonly StructFieldDef[], from: PropertyValue
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
switch (def.kind) {
case 'number': {
let n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) n = def.default;
let n = coerceAs(value, def.kind, def.default);
if (def.min !== undefined) n = Math.max(def.min, n);
if (def.max !== undefined) n = Math.min(def.max, n);
return n;
}
case 'color':
return colorOf(value, def.default);
case 'boolean':
return booleanOf(value);
case 'text':
case 'key':
case 'asset':
case 'texture':
return textOf(value);
case 'enum': {
const s = textOf(value);
return def.options.some((o) => o.value === s) ? s : def.default;
@@ -309,6 +277,8 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
return isList(value) ? value.map(listItemOf) : def.default;
case 'struct':
return structOf(def.fields, value);
default:
return coerceAs(value, def.kind, def.default);
}
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Every kind a value can be, in one table: what it is called, how it is marked, what it travels as
* on a data edge, its empty value, how any value is read as it, and where it may be picked. A new
* kind is a row here – the kind lists, the menus, the tags and the coercions follow from it.
*
* A `tagged` kind is one JSON cannot show: a color is a number, a texture id a text. In a list,
* where nothing is declared, such an item wears its tag (`tagOf`), and a port of that kind carries
* the field that edits it. A `reference` kind names something in the project.
*/
import type { PortType } from '../graph/Graph';
import { booleanOf, colorOf, type PropertyValue, textOf } from '../model/Properties';
interface KindRow {
readonly kind: string;
readonly label: string;
readonly mark?: string;
readonly travels: PortType;
readonly empty?: number | string | boolean;
readonly coerce?: (value: unknown, fallback: never) => number | string | boolean;
readonly tagged?: true;
readonly reference?: true;
readonly variable?: true;
readonly field?: true;
readonly port?: true;
}
const numberOf = (value: unknown, fallback: number): number => {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
};
const text = (value: unknown): string => textOf(value as PropertyValue | undefined);
const bool = (value: unknown): boolean => booleanOf(value);
const color = (value: unknown, fallback: number): number => colorOf(value as PropertyValue, fallback);
export const KINDS = [
{
kind: 'number',
label: 'Number',
mark: '#',
travels: 'number',
empty: 0,
coerce: numberOf,
variable: true,
field: true,
port: true,
},
{
kind: 'text',
label: 'Text',
mark: 'Aa',
travels: 'string',
empty: '',
coerce: text,
variable: true,
field: true,
port: true,
},
{
kind: 'boolean',
label: 'Boolean',
mark: '✓',
travels: 'boolean',
empty: false,
coerce: bool,
variable: true,
field: true,
port: true,
},
{
kind: 'color',
label: 'Color',
mark: '◧',
travels: 'number',
empty: 0,
coerce: color,
tagged: true,
variable: true,
field: true,
port: true,
},
/** A texture, one of its frames or an animation – all one id, which is why it is not an `asset`. */
{
kind: 'texture',
label: 'Texture',
mark: '▦',
travels: 'string',
empty: '',
coerce: text,
tagged: true,
reference: true,
variable: true,
field: true,
port: true,
},
{ kind: 'object', label: 'Object', mark: '◇', travels: 'object', port: true },
{ kind: 'list', label: 'List', mark: '≡', travels: 'list', variable: true, port: true },
{ kind: 'struct', label: 'Struct', mark: '⊞', travels: 'struct', variable: true, port: true },
// Schema-only kinds: a setting has them, nothing a user declares does.
{ kind: 'enum', label: 'Choice', travels: 'string', empty: '', coerce: text },
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
{ kind: 'key', label: 'Key', travels: 'string', empty: '', coerce: text },
/** An asset id, `''` = none. Ids are not validated on load, so a reference can dangle. */
{ kind: 'asset', label: 'Asset', travels: 'string', empty: '', coerce: text, reference: true },
] as const satisfies readonly KindRow[];
type Row = (typeof KINDS)[number];
export type Kind = Row['kind'];
type RowOf<K extends Kind> = Extract<Row, { kind: K }>;
type With<Role extends string> = Extract<Row, Record<Role, true>>['kind'];
export type VariableKind = With<'variable'>;
export type FieldKind = With<'field'>;
export type DeclaredPortKind = With<'port'>;
export type TaggedKind = With<'tagged'>;
export type ReferenceKind = With<'reference'>;
/** The kinds whose value is one primitive – the ones with an empty value. */
export type FlatKind = Extract<Row, { empty: unknown }>['kind'];
/** What one item of a list may be – a primitive plus `struct`, so a bundle can sit in a list. */
export type ListItemKind = FieldKind | 'struct';
/** What a field of a struct holds – a primitive plus `list`. No `struct`: that bounds the depth. */
export type StructFieldKind = FieldKind | 'list';
type Widen<T> = T extends number ? number : T extends string ? string : T extends boolean ? boolean : never;
export type ValueOf<K extends FlatKind> = Widen<RowOf<K>['empty']>;
export const KIND = Object.fromEntries(KINDS.map((row) => [row.kind, row])) as { [K in Kind]: RowOf<K> };
const rowOf = (kind: string): Row | undefined => (KIND as Partial<Record<string, Row>>)[kind];
export function isKindWith<Role extends string>(role: Role, kind: string): kind is With<Role> {
return (rowOf(kind) as Partial<Record<string, unknown>> | undefined)?.[role] === true;
}
const kindsWith = <Role extends string>(role: Role): readonly With<Role>[] =>
KINDS.map((row) => row.kind).filter((kind): kind is With<Role> => isKindWith(role, kind));
export const VARIABLE_KINDS = kindsWith('variable');
export const FIELD_KINDS = kindsWith('field');
export const DECLARED_PORT_KINDS = kindsWith('port');
export const LIST_ITEM_KINDS: readonly ListItemKind[] = [...FIELD_KINDS, 'struct'];
export const STRUCT_FIELD_KINDS: readonly StructFieldKind[] = [...FIELD_KINDS, 'list'];
export const isTaggedKind = (kind: string): kind is TaggedKind => isKindWith('tagged', kind);
export function kindLabel(kind: string): string {
return rowOf(kind)?.label ?? kind;
}
export function kindMark(kind: string): string {
const row = rowOf(kind);
return row && 'mark' in row ? row.mark : '?';
}
/** A flat row as its kind's value type – what the table promises and a generic `K` cannot see. */
const flatRow = <K extends FlatKind>(kind: K) =>
KIND[kind] as unknown as {
empty: ValueOf<K>;
coerce: (value: unknown, fallback: ValueOf<K>) => ValueOf<K>;
};
export function emptyOf<K extends FlatKind>(kind: K): ValueOf<K> {
return flatRow(kind).empty;
}
/** `value` read as `kind`, `fallback` where it cannot be. */
export function coerceAs<K extends FlatKind>(value: unknown, kind: K, fallback: ValueOf<K>): ValueOf<K> {
return flatRow(kind).coerce(value, fallback);
}
export function coerceField<K extends FlatKind>(value: unknown, kind: K): ValueOf<K> {
return coerceAs(value, kind, emptyOf(kind));
}
/** The kind a bare value travelling as `type` reads as: the untagged kind of that port type. */
export function plainKindOf(type: string): VariableKind | undefined {
return VARIABLE_KINDS.find((kind) => !isTaggedKind(kind) && KIND[kind].travels === type);
}
+31 -61
View File
@@ -1,45 +1,44 @@
/**
* A list declares nothing about what it holds: every item carries its own kind, read back off the
* value. JSON already says what a number, a text, a boolean and a struct are, so only a color and a
* texture need a tag (`COLOR_TAG`, `TEXTURE_TAG`) – which keeps every list ever stored readable.
* value. JSON says what a number, a text, a boolean and a struct are; a tagged kind says so itself
* (`tagOf`), 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.
* Depth is capped the way the declarations used to cap it: a struct item holds flat items, and so
* does a list inside a struct field.
*/
import {
booleanOf,
type ColorItem,
colorItem,
colorItemValue,
colorOf,
type FieldValue,
isColorItem,
isList,
isStruct,
isTextureItem,
type PropertyValue,
type StructValue,
textOf,
type TextureItem,
textureItem,
textureItemValue,
type TaggedItem,
tagged,
tagOf,
untag,
} from '../model/Properties';
import type { FieldKind, ListItemKind } from './PropertySchema';
import {
coerceField,
type FieldKind,
isKindWith,
isTaggedKind,
type ListItemKind,
plainKindOf,
} from './kinds';
/** 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 or texture, so the depth stays capped. */
export type FlatItem = number | string | boolean | ColorItem | TextureItem;
/** What a flat position holds: no struct except a tagged item, so the depth stays capped. */
export type FlatItem = number | string | boolean | TaggedItem;
/** 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 {
if (isColorItem(item)) return 'color';
if (isTextureItem(item)) return 'texture';
const tag = tagOf(item);
if (tag !== undefined && isTaggedKind(tag)) return tag;
if (isStruct(item) || isList(item)) return 'struct';
if (typeof item === 'string') return 'text';
if (typeof item === 'boolean') return 'boolean';
return 'number';
const plain = plainKindOf(typeof item);
return plain !== undefined && isKindWith('field', plain) ? plain : 'number';
}
/** Flat positions hold no struct, so one reads as its text. */
@@ -48,26 +47,9 @@ export function fieldKindOf(value: PropertyValue | undefined): FieldKind {
return kind === 'struct' ? 'text' : kind;
}
/** What the widgets and the graph see: a tag is what it wraps, everything else is itself. */
export function itemValue(item: PropertyValue | undefined): PropertyValue | undefined {
if (isColorItem(item)) return colorItemValue(item);
if (isTextureItem(item)) return textureItemValue(item);
return item;
}
/** `value` as an item of `kind`, which is how a row changes what it holds. */
export function makeItem(kind: ListItemKind, value: PropertyValue | undefined): ListItem {
switch (kind) {
case 'color':
// Through `itemValue`: a tagged color handed to `colorOf` would be read as hex text.
return colorItem(colorOf(itemValue(value) ?? 0, 0));
case 'texture':
return textureItem(textOf(itemValue(value)));
case 'struct':
return structItemOf(value);
default:
return coerceField(itemValue(value), kind);
}
return kind === 'struct' ? structItemOf(value) : makeFlatItem(kind, value);
}
/** `value` as an item of the kind the slot already holds, so writing a color keeps it a color. */
@@ -77,9 +59,8 @@ 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 {
if (kind === 'color') return colorItem(colorOf(itemValue(value) ?? 0, 0));
if (kind === 'texture') return textureItem(textOf(itemValue(value)));
return coerceField(itemValue(value), kind);
if (isTaggedKind(kind)) return tagged(kind, coerceField(untag(value), kind));
return coerceField(untag(value), kind);
}
/** A flat slot (a struct item's field, an item of a list in a struct field) keeps its kind too. */
@@ -93,19 +74,17 @@ export function listItemOf(value: PropertyValue | undefined): ListItem {
}
/**
* 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.
* A flat position holds a primitive or a tagged item. 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 textureItem(textureItemValue(value));
if (isList(value) || isStruct(value)) return undefined;
return coerceField(value, fieldKindOf(value));
if (isList(value) || (isStruct(value) && !isTaggedKind(tagOf(value) ?? ''))) return undefined;
return makeFlatItem(fieldKindOf(value), value);
}
/** A struct item keeps the fields it carries, each as the kind its own value says. */
export function structItemOf(value: PropertyValue | undefined): StructValue {
if (!isStruct(value) || isColorItem(value) || isTextureItem(value)) return {};
if (!isStruct(value) || tagOf(value) !== undefined) return {};
const fields: Record<string, FieldValue> = {};
for (const [key, item] of Object.entries(value)) {
const flat = flatItemOf(item);
@@ -122,15 +101,6 @@ export function flatListOf(value: PropertyValue | undefined): readonly FlatItem[
/** The shape a struct item has right now: its keys in order, each with the kind of its value. */
export function structItemFields(item: PropertyValue | undefined): { key: string; kind: FieldKind }[] {
if (!isStruct(item) || isColorItem(item) || isTextureItem(item)) return [];
if (!isStruct(item) || tagOf(item) !== undefined) return [];
return Object.entries(item).map(([key, value]) => ({ key, kind: fieldKindOf(value) }));
}
/** 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' || 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);
return Number.isFinite(n) ? n : 0;
}
+13 -15
View File
@@ -1,7 +1,6 @@
// Importing the graph package index is what registers the built-in nodes.
import {
createEmptyGraph,
DECLARED_PORT_KINDS,
type DeclaredPort,
type DeclaredPortKind,
type Graph,
@@ -15,36 +14,35 @@ import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import {
colorItem,
colorItemValue,
type FieldValue,
isTextureItem,
textureItem,
textureItemValue,
isColorItem,
type PropertyBag,
type PropertyValue,
type StructValue,
tagOf,
} 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 type { FlatItem } from '../properties/listItems';
import {
DECLARED_PORT_KINDS,
FIELD_KINDS,
type FieldKind,
isTaggedKind,
VARIABLE_KINDS,
type VariableKind,
} from '../properties/kinds';
import { type FlatItem, makeFlatItem } from '../properties/listItems';
import {
coerceValue,
COMMON_PROPS,
FIELD_KINDS,
type FieldKind,
isValidFieldName,
isValidVariableName,
type PropertyDef,
type PropertySchema,
schemaFor,
type StructFieldDef,
VARIABLE_KINDS,
variableDef,
type VariableKind,
} from '../properties/PropertySchema';
/**
@@ -356,11 +354,11 @@ 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 objects a flat position may hold: a tagged color or texture. */
/** A primitive, or the one object a flat position may hold: a tagged item. */
const asFlatItem = (value: unknown): FlatItem | undefined => {
if (isPrimitive(value)) return value;
if (isColorItem(value)) return colorItem(colorItemValue(value));
return isTextureItem(value) ? textureItem(textureItemValue(value)) : undefined;
const tag = tagOf(value);
return tag !== undefined && isTaggedKind(tag) ? makeFlatItem(tag, value as PropertyValue) : undefined;
};
/** A list item: a flat one, or a struct of flat ones. */
+2 -6
View File
@@ -17,11 +17,9 @@ import {
} from '../../core/graph';
import type { Project } from '../../core/model/Project';
import { isList, type PropertyValue } from '../../core/model/Properties';
import { assetRefLabel } from '../../core/properties/assetRef';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { textureRefLabel } from '../../core/properties/textureRef';
import { formatNumber, Theme, textStyle, type TextStyleToken, toHex, truncate, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
import { collapsedNodeWidth } from './folding';
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
@@ -431,11 +429,9 @@ function formatParam(param: PropertyDef, value: PropertyValue | undefined, proje
/** A value in the words of the field that edits it: a key's name, a color's hex, a texture's name. */
function formatField(def: PropertyDef, value: Value, project: Project | null): string {
if (def.kind === 'key') return keyLabel(asString(value));
if (def.kind === 'asset') return assetRefLabel(project, asString(value));
if (def.kind === 'texture') return textureRefLabel(project, asString(value));
if (def.kind === 'color') return toHex(asNumber(value));
if (def.kind === 'enum') return def.options.find((o) => o.value === value)?.label ?? formatValue(value);
return formatValue(value);
return referenceLabel(def, project, asString(value)) ?? formatValue(value);
}
function formatLive(output: PortDef, value: Value, project: Project | null): string {
+8 -16
View File
@@ -15,20 +15,15 @@ import {
import type { Command } from '../../core/commands/Command';
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import {
type PropertyDef,
schemaForAsset,
type StructFieldDef,
type StructFieldKind,
VARIABLE_KINDS,
} from '../../core/properties/PropertySchema';
import { type StructFieldKind, VARIABLE_KINDS } from '../../core/properties/kinds';
import { type PropertyDef, schemaForAsset, type StructFieldDef } from '../../core/properties/PropertySchema';
import { markedAssetSchema } from '../../core/properties/deviation';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Dropdown, HStack, Label, Menu, TextField, Theme } from '../../ui';
import { aspectActions, sizeCommand } from './aspectAction';
import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import { buildVariable, KIND_LABELS, type VariableEditing } from './variableRows';
import { buildVariable, kindMenu, type VariableEditing } from './variableRows';
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { assetValue } from './valueSpecs';
@@ -137,14 +132,11 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
header.add(new Label('VARIABLES', { style: 'title' }), 1);
const add = new Dropdown('+ Variable', { variant: 'ghost' });
add.onClick = () => {
const items = VARIABLE_KINDS.map((kind) => ({
label: KIND_LABELS[kind],
onSelect: () => {
const current = findAsset(host.state.project, asset.id);
if (current?.type === 'behavior')
host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
},
}));
const items = kindMenu(VARIABLE_KINDS, (kind) => {
const current = findAsset(host.state.project, asset.id);
if (current?.type === 'behavior')
host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
});
new Menu(items).openBelow(add);
};
header.add(add);
+65 -74
View File
@@ -6,9 +6,10 @@ import {
assetRefLabel,
NO_ASSET_LABEL,
} from '../../core/properties/assetRef';
import { isKindWith, type ReferenceKind } from '../../core/properties/kinds';
import {
emptyFieldValue,
type FieldKind,
flatDef,
type PropertyDef,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
@@ -202,29 +203,70 @@ 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. */
export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef {
switch (kind) {
case 'number':
return { key, label, kind: 'number', default: 0 };
case 'text':
return { key, label, kind: 'text', default: '' };
case 'boolean':
return { key, label, kind: 'boolean', default: false };
case 'color':
return { key, label, kind: 'color', default: 0 };
case 'texture':
return { key, label, kind: 'texture', default: '' };
}
}
export type PrimitiveDef = Extract<PropertyDef, { kind: FieldKind }>;
/** A struct field as a property definition – a list field becomes the same row a list property has. */
export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef {
return field.kind === 'list'
? { key: field.key, label, kind: 'list', default: [] }
: primitiveDef(field.key, field.kind, label);
: flatDef(field.key, field.kind, { label });
}
/** What a reference kind names in the project: how an id reads, and what its picker may offer. */
interface Reference {
none: string;
label(project: Project | null, id: string): string;
choices(project: Project, def: PropertyDef): { id: string; label: string }[];
}
const REFERENCES: Record<ReferenceKind, Reference> = {
asset: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project, def) =>
assetRefChoices(project, def.kind === 'asset' ? def.assetTypes : undefined).map((asset) => ({
id: asset.id,
label: assetChoiceLabel(asset),
})),
},
texture: { none: NO_TEXTURE_LABEL, label: textureRefLabel, choices: textureRefChoices },
};
/** An id in the words of its reference kind; nothing for a kind that names nothing. */
export function referenceLabel(def: PropertyDef, project: Project | null, id: string): string | undefined {
return isKindWith('reference', def.kind) ? REFERENCES[def.kind].label(project, id) : undefined;
}
function referenceField(
ctx: FieldContext,
def: PropertyDef,
ref: Reference,
value: PropertyValue,
handlers: FieldHandlers,
): Field {
// The current id is kept here rather than read back off the label: the label is a name, two
// assets may share one (a frame's label repeats its sheet's), and a dangling reference has none.
let current = textOf(value);
const button = new Dropdown(ref.label(ctx.project(), current));
button.onClick = () => {
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
const items: MenuItem[] = [
{ label: mark('', ref.none), onSelect: () => handlers.onChange('') },
// Built on click, not once per row: assets are created, renamed and deleted while the
// inspector stands. Searchable: one sliced sheet alone contributes an entry per frame.
...ref.choices(ctx.project(), def).map((choice) => ({
label: mark(choice.id, choice.label),
onSelect: () => handlers.onChange(choice.id),
})),
];
new Menu(items, { searchable: true }).openBelow(button);
};
return {
widget: button,
grow: true,
setValue: (v) => {
current = textOf(v);
button.setText(ref.label(ctx.project(), current));
},
};
}
/** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */
@@ -308,60 +350,9 @@ export function createField(
const button = new KeyCaptureButton(textOf(value), (key) => handlers.onChange(key));
return { widget: button, grow: true, setValue: (v) => button.setKey(textOf(v)) };
}
case 'asset': {
// The current id is kept here rather than read back off the label: the label is a name,
// two assets may share one, and a dangling reference has no name at all.
let current = textOf(value);
const button = new Dropdown(assetRefLabel(ctx.project(), current));
button.onClick = () => {
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
const items: MenuItem[] = [
{ label: mark('', NO_ASSET_LABEL), onSelect: () => handlers.onChange('') },
// Built on click, not once per row: assets are created, renamed and deleted while
// the inspector stands.
...assetRefChoices(ctx.project(), def.assetTypes).map((asset) => ({
label: mark(asset.id, assetChoiceLabel(asset)),
onSelect: () => handlers.onChange(asset.id),
})),
];
// Searchable: the list grows with the project, like the parent picker.
new Menu(items, { searchable: true }).openBelow(button);
};
return {
widget: button,
grow: true,
setValue: (v) => {
current = textOf(v);
button.setText(assetRefLabel(ctx.project(), current));
},
};
}
case 'texture': {
// Kept for the same reason as in the asset case, and more urgently: a frame's label
// repeats its sheet's name, so several entries can read identically.
let current = textOf(value);
const button = new Dropdown(textureRefLabel(ctx.project(), current));
button.onClick = () => {
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
const items: MenuItem[] = [
{ label: mark('', NO_TEXTURE_LABEL), onSelect: () => handlers.onChange('') },
...textureRefChoices(ctx.project()).map((choice) => ({
label: mark(choice.id, choice.label),
onSelect: () => handlers.onChange(choice.id),
})),
];
// Searchable: one sliced sheet alone contributes an entry per frame.
new Menu(items, { searchable: true }).openBelow(button);
};
return {
widget: button,
grow: true,
setValue: (v) => {
current = textOf(v);
button.setText(textureRefLabel(ctx.project(), current));
},
};
}
case 'asset':
case 'texture':
return referenceField(ctx, def, REFERENCES[def.kind], value, handlers);
}
}
+11 -15
View File
@@ -9,7 +9,6 @@ import {
} from '../../core/commands/portCommands';
import {
BEHAVIOR_START,
DECLARED_PORT_KINDS,
type DeclaredPort,
type GraphNode,
isPortOf,
@@ -26,7 +25,8 @@ import {
} from '../../core/graph';
import { findAssetOf } from '../../core/model/Project';
import { textOf } from '../../core/model/Properties';
import { type PropertyDef, schemaForAsset, VARIABLE_KINDS } from '../../core/properties/PropertySchema';
import { DECLARED_PORT_KINDS, kindMark, VARIABLE_KINDS } from '../../core/properties/kinds';
import { type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
import {
Checkbox,
Dropdown,
@@ -41,7 +41,7 @@ import {
} from '../../ui';
import { NAME_WIDTH, removeButton } from './fieldFactory';
import { addNote, type InspectorHost } from './host';
import { KIND_LABELS, kindMark, markLabel } from './variableRows';
import { kindMenu, markLabel } from './variableRows';
/**
* The ports of a Variables node: each one *is* a variable of the behavior, so it is picked from that
@@ -121,13 +121,11 @@ function buildPickedPortSection(
onSelect: () => onPick(portOf(def)),
}));
const typed = (): MenuItem[] =>
VARIABLE_KINDS.map((kind) => ({
label: KIND_LABELS[kind],
onSelect: () =>
host.state.execute(
new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }),
),
}));
kindMenu(VARIABLE_KINDS, (kind) =>
host.state.execute(
new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }),
),
);
const searchable = noun === 'Property';
const header = new HStack({ gap: Theme.gap, align: 'center' });
@@ -189,11 +187,9 @@ export function buildPortSection(
if (noun === 'Field') addShapePicker(host, assetId, node, variables, header);
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
add.onClick = () => {
const items = DECLARED_PORT_KINDS.map((kind) => ({
label: KIND_LABELS[kind],
onSelect: () =>
host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })),
}));
const items = kindMenu(DECLARED_PORT_KINDS, (kind) =>
host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })),
);
new Menu(items).openBelow(add);
};
header.add(add);
+7 -44
View File
@@ -1,14 +1,6 @@
import type { DeclaredPortKind } from '../../core/graph';
import type { PropertyValue } from '../../core/model/Properties';
import {
type ListItemKind,
type PropertyDef,
STRUCT_FIELD_KINDS,
type StructDef,
type StructFieldDef,
type StructFieldKind,
type VariableKind,
} from '../../core/properties/PropertySchema';
import { kindLabel, kindMark, STRUCT_FIELD_KINDS, type StructFieldKind } from '../../core/properties/kinds';
import type { PropertyDef, StructDef, StructFieldDef } from '../../core/properties/PropertySchema';
import {
Button,
Dropdown,
@@ -38,34 +30,9 @@ import {
} from './fieldFactory';
import { addNote, type InspectorHost } from './host';
type ShownKind = VariableKind | ListItemKind | StructFieldKind | DeclaredPortKind;
/** One glyph and one name per kind, wherever a kind is shown: variables, list items, fields, ports. */
const KIND_MARKS: Record<ShownKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
color: '◧',
texture: '▦',
list: '≡',
struct: '⊞',
object: '◇',
};
export const KIND_LABELS: Record<ShownKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
color: 'Color',
texture: 'Texture',
list: 'List',
struct: 'Struct',
object: 'Object',
};
/** Variables are `PropertyDef`s, so the kind is typed wide; only the shown kinds ever reach it. */
export function kindMark(kind: string): string {
return (KIND_MARKS as Partial<Record<string, string>>)[kind] ?? '?';
/** The menu every "add a kind" button opens: one entry per kind, named as the kind table names it. */
export function kindMenu<K extends string>(kinds: readonly K[], pick: (kind: K) => void): MenuItem[] {
return kinds.map((kind) => ({ label: kindLabel(kind), onSelect: () => pick(kind) }));
}
/** Changing a declaration – offered only on the asset that declares it. */
@@ -149,7 +116,7 @@ function structFieldRows(
function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack {
const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next)));
return gridRow(1, markLabel(KIND_MARKS[field.kind]), name);
return gridRow(1, markLabel(kindMark(field.kind)), name);
}
/** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */
@@ -198,10 +165,6 @@ export function markPicker(glyph: string, items: MenuItem[]): Button {
function addFieldMenu(kinds: readonly StructFieldKind[], edit: VariableEditing): Dropdown {
const add = new Dropdown('+ Field', { variant: 'ghost' });
add.onClick = () => {
new Menu(
kinds.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => edit.addField(kind) })),
).openBelow(add);
};
add.onClick = () => new Menu(kindMenu(kinds, (kind) => edit.addField(kind))).openBelow(add);
return add;
}
+29 -31
View File
@@ -18,33 +18,39 @@ import {
VStack,
type Widget,
} from '../../ui';
import { type FieldValue, type ListValue, type PropertyValue, textOf } from '../../core/model/Properties';
import {
type FieldValue,
type ListValue,
type PropertyValue,
textOf,
untag,
} from '../../core/model/Properties';
import { nextFieldName } from '../../core/commands/naming';
import {
FIELD_KINDS,
type FieldKind,
kindMark,
LIST_ITEM_KINDS,
type ListItemKind,
} from '../../core/properties/PropertySchema';
} from '../../core/properties/kinds';
import {
type ListItem,
itemKindOf,
itemValue,
makeFlatItem,
makeItem,
structItemFields,
structItemOf,
} from '../../core/properties/listItems';
import { flatDef } from '../../core/properties/PropertySchema';
import {
addValue,
createField,
type FieldContext,
NAME_WIDTH,
primitiveDef,
READ_ONLY,
removeButton,
} from '../inspector/fieldFactory';
import { KIND_LABELS, kindMark, markPicker } from '../inspector/variableRows';
import { kindMenu, markPicker } from '../inspector/variableRows';
import type { EditorState } from '../state';
import { type ListAccess, resolveList } from './slot';
@@ -103,9 +109,7 @@ export class ListEditor extends VStack {
// Built on click, not once: which kinds fit depends on the list the tab is showing.
this.addButton.onClick = () => {
const kinds = this.access()?.kinds ?? LIST_ITEM_KINDS;
new Menu(
kinds.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => this.addItem(kind) })),
).openBelow(this.addButton);
new Menu(kindMenu(kinds, (kind) => this.addItem(kind))).openBelow(this.addButton);
};
footer.add(this.addButton);
footer.add(new Spacer(), 1);
@@ -210,13 +214,11 @@ export class ListEditor extends VStack {
row.add(label);
const mark = markPicker(
kindMark(kind),
access.kinds
.filter((next) => next !== kind)
.map((next) => ({
label: KIND_LABELS[next],
// Through `itemValue`, so a color keeps its number when it becomes one.
onSelect: () => this.replaceItem(access, index, makeItem(next, itemValue(item))),
})),
// Through `untag`, so a color keeps its number when it becomes one.
kindMenu(
access.kinds.filter((next) => next !== kind),
(next) => this.replaceItem(access, index, makeItem(next, untag(item))),
),
);
if (tall) mark.minHeight = Theme.rowHeight;
row.add(mark);
@@ -224,12 +226,12 @@ export class ListEditor extends VStack {
else {
const field = createField(
this.ctx,
primitiveDef(String(index), kind),
itemValue(item) ?? '',
flatDef(String(index), kind),
untag(item) ?? '',
access.itemHandlers(index),
);
addValue(row, field);
this.setters.push((next) => field.setValue(itemValue(next) ?? ''));
this.setters.push((next) => field.setValue(untag(next) ?? ''));
this.rowWidgets.push(rowWidgets(mark, field.widget));
}
row.add(removeButton(() => access.replace(access.value.filter((_, i) => i !== index))));
@@ -255,14 +257,14 @@ export class ListEditor extends VStack {
line.minHeight = Theme.rowHeight;
const fieldMark = markPicker(
kindMark(field.kind),
FIELD_KINDS.filter((next) => next !== field.kind).map((next) => ({
label: KIND_LABELS[next],
onSelect: () =>
kindMenu(
FIELD_KINDS.filter((next) => next !== field.kind),
(next) =>
this.replaceItem(access, index, {
...bundle,
[field.key]: makeFlatItem(next, itemValue(bundle[field.key])),
[field.key]: makeFlatItem(next, untag(bundle[field.key])),
}),
})),
),
);
const name = nameField(field.key, (to) => {
if (to && to !== field.key) this.replaceItem(access, index, renamedField(bundle, field.key, to));
@@ -271,8 +273,8 @@ export class ListEditor extends VStack {
line.add(name);
const sub = createField(
this.ctx,
primitiveDef(field.key, field.kind),
itemValue(bundle[field.key]) ?? '',
flatDef(field.key, field.kind),
untag(bundle[field.key]) ?? '',
handlers.structFieldHandlers?.(field) ?? READ_ONLY,
);
setters.set(field.key, addValue(line, sub));
@@ -290,7 +292,7 @@ export class ListEditor extends VStack {
row.add(stack, 1);
this.setters.push((next) => {
const now = structItemOf(next);
for (const field of fields) setters.get(field.key)?.(itemValue(now[field.key]) ?? '');
for (const field of fields) setters.get(field.key)?.(untag(now[field.key]) ?? '');
});
this.rowWidgets.push(widgets);
}
@@ -319,11 +321,7 @@ function nameField(key: string, rename: (next: string) => void): TextField {
function addFieldMenu(add: (kind: FieldKind) => void): Dropdown {
const button = new Dropdown('+ Field', { variant: 'ghost' });
button.onClick = () => {
new Menu(FIELD_KINDS.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => add(kind) }))).openBelow(
button,
);
};
button.onClick = () => new Menu(kindMenu(FIELD_KINDS, add)).openBelow(button);
return button;
}
+2 -8
View File
@@ -10,15 +10,9 @@ import { behaviorLookup } from '../../core/model/behaviorPrograms';
import type { ObjectRef } from '../../core/model/instance';
import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { FIELD_KINDS, LIST_ITEM_KINDS, type ListItemKind } from '../../core/properties/kinds';
import { fieldLike, type ListItem, itemLike, structItemOf } from '../../core/properties/listItems';
import {
FIELD_KINDS,
LIST_ITEM_KINDS,
type ListItemKind,
type PropertyDef,
schemaForAsset,
structOf,
} from '../../core/properties/PropertySchema';
import { type PropertyDef, schemaForAsset, structOf } from '../../core/properties/PropertySchema';
import { resolveAssetProps } from '../../core/properties/resolve';
import type { FieldHandlers } from '../inspector/fieldFactory';
import { currentProps, currentRef } from '../inspector/objectQueries';
+135
View File
@@ -0,0 +1,135 @@
/**
* The kind table is the one place a kind is described. Everything a kind needs – its lists, its
* name and mark, its empty value, its tag, its port – has to follow from the row alone, so each
* check here walks the table rather than naming a kind.
*/
import { describe, expect, it } from 'vitest';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { declaredPortType, portDefOf } from '../../src/core/graph';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { tagOf, untag } from '../../src/core/model/Properties';
import { findAsset } from '../../src/core/model/Project';
import {
coerceField,
DECLARED_PORT_KINDS,
emptyOf,
FIELD_KINDS,
isKindWith,
isTaggedKind,
KIND,
KINDS,
kindLabel,
kindMark,
LIST_ITEM_KINDS,
plainKindOf,
STRUCT_FIELD_KINDS,
VARIABLE_KINDS,
} from '../../src/core/properties/kinds';
import { itemKindOf, listItemOf, makeItem } from '../../src/core/properties/listItems';
import { deserializeProject } from '../../src/core/store/serialize';
import { makeBehavior, makeProject, roundTrip } from '../helpers';
const TAGGED = FIELD_KINDS.filter(isTaggedKind);
/** A value of the type a tagged kind travels as, distinct from the kind's empty value. */
const sampleOf = (kind: (typeof TAGGED)[number]) => (KIND[kind].travels === 'number' ? 0xff0000 : 'walk-up');
describe('the kind table', () => {
it('has a word and a mark for every kind a user can pick, and no two marks alike', () => {
const picked = new Set([...VARIABLE_KINDS, ...FIELD_KINDS, ...DECLARED_PORT_KINDS]);
for (const kind of picked) {
expect(kindLabel(kind), kind).not.toBe(kind);
expect(kindMark(kind), kind).not.toBe('?');
}
expect(new Set([...picked].map(kindMark)).size).toBe(picked.size);
});
it('derives the kind lists from the roles, the depth rules on top', () => {
for (const row of KINDS) {
expect(VARIABLE_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('variable', row.kind));
expect(FIELD_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('field', row.kind));
expect(DECLARED_PORT_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('port', row.kind));
}
expect(LIST_ITEM_KINDS).toEqual([...FIELD_KINDS, 'struct']);
expect(STRUCT_FIELD_KINDS).toEqual([...FIELD_KINDS, 'list']);
});
it('reads nothing as the empty value of a kind, and the empty value as itself', () => {
for (const kind of FIELD_KINDS) {
expect(coerceField(undefined, kind), kind).toBe(emptyOf(kind));
expect(coerceField(emptyOf(kind), kind), kind).toBe(emptyOf(kind));
}
});
it('reads a bare value as the untagged kind travelling its way', () => {
for (const kind of FIELD_KINDS) {
if (isTaggedKind(kind)) continue;
expect(plainKindOf(KIND[kind].travels), kind).toBe(kind);
expect(itemKindOf(emptyOf(kind)), kind).toBe(kind);
}
});
});
describe('a tagged kind', () => {
it('wraps what it reads, and reads back as itself', () => {
for (const kind of TAGGED) {
const item = makeItem(kind, sampleOf(kind));
expect(tagOf(item), kind).toBe(kind);
expect(itemKindOf(item), kind).toBe(kind);
expect(untag(item), kind).toBe(sampleOf(kind));
expect(listItemOf(item), kind).toEqual(item);
// The bare value beside it is the plain kind – which is why the tag is needed at all.
expect(itemKindOf(sampleOf(kind)), kind).toBe(plainKindOf(KIND[kind].travels));
}
});
it('survives a save and load in a list variable and in a node parameter', () => {
for (const kind of TAGGED) {
const item = makeItem(kind, sampleOf(kind));
const f = makeProject();
const behavior = makeBehavior(f, 'B');
f.stack.execute(new AddVariableCommand(behavior.id, 'items', 'list'));
findAsset(f.project, behavior.id)!.props.items = [item, sampleOf(kind)];
expect(findAsset(roundTrip(f.project), behavior.id)?.props.items, kind).toEqual([item, sampleOf(kind)]);
const loaded = deserializeProject({
id: 'p',
assets: [
{
id: 'b',
type: 'behavior',
name: 'B',
graph: { nodes: [{ id: 'n', type: 'List', x: 0, y: 0, params: { value: [item] } }], edges: [] },
},
],
});
const asset = findAsset(loaded, 'b');
expect(asset?.type === 'behavior' && asset.graph.nodes[0]?.params.value, kind).toEqual([item]);
}
});
});
describe('a declared port', () => {
it('travels as the table says, and carries the field that edits it only when tagged', () => {
for (const kind of DECLARED_PORT_KINDS) {
expect(declaredPortType(kind), kind).toBe(KIND[kind].travels);
expect(portDefOf({ key: 'p', kind }).field?.kind, kind).toBe(isTaggedKind(kind) ? kind : undefined);
}
});
});
describe('the literal nodes', () => {
it('are one per kind: named, typed and edited as the kind they hand out', () => {
for (const [type, kind] of [
['Number', 'number'],
['Boolean', 'boolean'],
['String', 'text'],
['Texture', 'texture'],
['List', 'list'],
] as const) {
const def = BUILT_IN_NODES.find((d) => d.type === type)!;
expect(def.label, type).toBe(kindLabel(kind));
expect(def.outputs[0]?.type, type).toBe(KIND[kind].travels);
expect(def.params[0]?.kind, type).toBe(kind);
}
});
});
+18 -15
View File
@@ -5,10 +5,10 @@
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 { type PropertyValue, tagged, untag } from '../../src/core/model/Properties';
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 { itemKindOf, makeItem, structItemFields } from '../../src/core/properties/listItems';
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
/** A behavior with a list variable `stops`, placed once with an override of its own. */
@@ -25,7 +25,7 @@ function withList(value: unknown = [1, 2]) {
describe('the kind of one list item', () => {
it('is read off the item itself, a color through its tag', () => {
expect([7, 'a', true, colorItem(0xff0000), { x: 1 }].map(itemKindOf)).toEqual([
expect([7, 'a', true, tagged('color', 0xff0000), { x: 1 }].map(itemKindOf)).toEqual([
'number',
'text',
'boolean',
@@ -35,17 +35,17 @@ describe('the kind of one list item', () => {
});
it('lets one list hold all of them at once, through a write and a read back', () => {
const mixed = [7, 'tee', true, colorItem(0xff0000), { x: 1, y: 'two' }];
const mixed = [7, 'tee', true, tagged('color', 0xff0000), { x: 1, y: 'two' }];
const { f, behaviorId } = withList(mixed);
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual(mixed);
});
/** The one kind JSON cannot tell apart from a number, which is why it is tagged at all. */
it('keeps a color apart from the number beside it', () => {
const { f, behaviorId } = withList([colorItem(0xff0000), 0xff0000]);
const { f, behaviorId } = withList([tagged('color', 0xff0000), 0xff0000]);
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
expect(back.map(itemKindOf)).toEqual(['color', 'number']);
expect(back.map(itemValue)).toEqual([0xff0000, 0xff0000]);
expect(back.map(untag)).toEqual([0xff0000, 0xff0000]);
});
/**
@@ -53,22 +53,22 @@ describe('the kind of one list item', () => {
* sprite wears is one id whether it names a texture, one of its frames or an animation.
*/
it('keeps a texture apart from the text beside it, and reads back as its id', () => {
const { f, behaviorId } = withList([textureItem('anim-walk'), 'anim-walk']);
const { f, behaviorId } = withList([tagged('texture', 'anim-walk'), 'anim-walk']);
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
expect(back.map(itemKindOf)).toEqual(['texture', 'text']);
expect(back.map(itemValue)).toEqual(['anim-walk', 'anim-walk']);
expect(back.map(untag)).toEqual(['anim-walk', 'anim-walk']);
});
it('turns a text into a texture item and back without losing the id', () => {
expect(makeItem('texture', 'frame-26')).toEqual(textureItem('frame-26'));
expect(makeItem('text', textureItem('frame-26'))).toBe('frame-26');
expect(makeItem('texture', 'frame-26')).toEqual(tagged('texture', 'frame-26'));
expect(makeItem('text', tagged('texture', 'frame-26'))).toBe('frame-26');
});
/** 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 { f, behaviorId } = withList([{ pic: tagged('texture', 'frame-26'), n: 1 }]);
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
expect(back).toEqual([{ pic: textureItem('frame-26'), n: 1 }]);
expect(back).toEqual([{ pic: tagged('texture', 'frame-26'), n: 1 }]);
expect(structItemFields(back[0]).map((field) => `${field.key}:${field.kind}`)).toEqual([
'pic:texture',
'n:number',
@@ -121,12 +121,15 @@ describe('a list in a node parameter, where nothing is declared', () => {
};
it('keeps a tagged texture an item of its own', () => {
expect(paramOf([textureItem('frame-26'), 'frame-26'])).toEqual([textureItem('frame-26'), 'frame-26']);
expect(paramOf([tagged('texture', 'frame-26'), 'frame-26'])).toEqual([
tagged('texture', 'frame-26'),
'frame-26',
]);
});
it('and one inside a struct item as well', () => {
expect(paramOf([{ pic: textureItem('frame-26'), n: 1 }])).toEqual([
{ pic: textureItem('frame-26'), n: 1 },
expect(paramOf([{ pic: tagged('texture', 'frame-26'), n: 1 }])).toEqual([
{ pic: tagged('texture', 'frame-26'), n: 1 },
]);
});
});
+10 -6
View File
@@ -3,7 +3,7 @@ import { Fill } from '../../src/core/graph/nodes/data/Fill';
import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf';
import { ItemAt } from '../../src/core/graph/nodes/data/ItemAt';
import { SetItem } from '../../src/core/graph/nodes/data/SetItem';
import { colorItem, sameValue, textOf, textureItem } from '../../src/core/model/Properties';
import { sameValue, tagged, textOf } from '../../src/core/model/Properties';
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
import { makeBehavior, makeSpriteScene, roundTrip } from '../helpers';
@@ -99,24 +99,28 @@ describe('an untyped slot on load', () => {
*/
describe('the list nodes with tagged items', () => {
it('hands a texture item out as the plain id it holds', () => {
expect(run(ItemAt, { list: [textureItem('walk-up'), textureItem('fr-0-1')], index: 1 })).toEqual({
expect(
run(ItemAt, { list: [tagged('texture', 'walk-up'), tagged('texture', 'fr-0-1')], index: 1 }),
).toEqual({
item: 'fr-0-1',
});
});
it('and a colour item as its number', () => {
expect(run(ItemAt, { list: [colorItem(0xff0000)], index: 0 })).toEqual({ item: 0xff0000 });
expect(run(ItemAt, { list: [tagged('color', 0xff0000)], index: 0 })).toEqual({ item: 0xff0000 });
});
/** Or searching a list of pictures with the id a `Properties` node just read would never hit. */
it('finds a texture item by the id beside it', () => {
expect(run(IndexOf, { list: [textureItem('a'), textureItem('b')], value: 'b' })).toEqual({ index: 1 });
expect(run(IndexOf, { list: [tagged('texture', 'a'), tagged('texture', 'b')], value: 'b' })).toEqual({
index: 1,
});
});
/** The slot keeps its kind, so a list of pictures stays one after the graph has written to it. */
it('keeps a texture slot a texture when the graph writes a plain id into it', () => {
expect(run(SetItem, { list: [textureItem('a')], index: 0, value: 'b' })).toEqual({
result: [textureItem('b')],
expect(run(SetItem, { list: [tagged('texture', 'a')], index: 0, value: 'b' })).toEqual({
result: [tagged('texture', 'b')],
});
});
});
+3 -3
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { colorItem, sameValue, textOf } from '../../src/core/model/Properties';
import { sameValue, tagged, textOf } from '../../src/core/model/Properties';
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
import { deserializeProject } from '../../src/core/store/serialize';
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
import { coerceField } from '../../src/core/properties/listItems';
import { coerceField } from '../../src/core/properties/kinds';
import { deviatesFromAsset } from '../../src/core/properties/resolve';
import { asBoolean, asEntity, asStruct, writeTyped } from '../../src/core/graph';
import { fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
@@ -81,7 +81,7 @@ describe('a list as a value', () => {
it('keeps a loaded item as the kind it is, kind by kind', () => {
const def = variableDef('v', 'list', []);
expect(coerceValue(def, ['1', 2, true])).toEqual(['1', 2, true]);
expect(coerceValue(def, [colorItem(0xff0000)])).toEqual([colorItem(0xff0000)]);
expect(coerceValue(def, [tagged('color', 0xff0000)])).toEqual([tagged('color', 0xff0000)]);
// A colour only keeps its kind through the tag; as a bare number it is one.
expect(coerceValue(def, [0xff0000])).toEqual([0xff0000]);
});
+2 -2
View File
@@ -15,7 +15,7 @@ import {
withInlineDefault,
} from '../../src/core/graph';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { textureItem } from '../../src/core/model/Properties';
import { tagged } 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';
@@ -54,7 +54,7 @@ describe('a variable of kind color or texture', () => {
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({
expect(structOf([{ key: 'pic', kind: 'texture' }], { pic: tagged('texture', 'fr-0-1') })).toEqual({
pic: 'fr-0-1',
});
expect(structOf([{ key: 'pic', kind: 'texture' }], undefined)).toEqual({ pic: '' });
+8 -5
View File
@@ -9,8 +9,9 @@ import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCom
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { colorItem } from '../../src/core/model/Properties';
import { tagged } from '../../src/core/model/Properties';
import { createEmptyProject, findAsset } from '../../src/core/model/Project';
import { FIELD_KINDS, LIST_ITEM_KINDS } from '../../src/core/properties/kinds';
import { variableDef } from '../../src/core/properties/PropertySchema';
import { resolveList } from '../../src/editor/list/slot';
import { EditorState, type ListSlot } from '../../src/editor/state';
@@ -83,8 +84,8 @@ describe('the address a list is edited through', () => {
});
it('takes a whole list of mixed items as it is, here and on a node setting', () => {
list().replace(['5', true, colorItem(0xff0000)]);
expect(list().value).toEqual(['5', true, colorItem(0xff0000)]);
list().replace(['5', true, tagged('color', 0xff0000)]);
expect(list().value).toEqual(['5', true, tagged('color', 0xff0000)]);
const add = new AddNodeCommand(f.behavior.id, 'List', 0, 0);
expect(f.state.execute(add)).toBe(true);
@@ -260,8 +261,10 @@ describe('a list a struct field holds', () => {
* the next write, which reads as the menu entry doing nothing at all.
*/
it('offers the flat kinds only, where the whole list has struct as well', () => {
expect(list().kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture']);
expect(list().kinds).toEqual(FIELD_KINDS);
expect(list().kinds).not.toContain('struct');
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
expect(whole.kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture', 'struct']);
expect(whole.kinds).toEqual(LIST_ITEM_KINDS);
expect(whole.kinds).toContain('struct');
});
});
View File
Whitespace-only changes.
@@ -12,3 +12,12 @@ 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.
---
Umgesetzt (Umfang "alles", inkl. Literal-Nodes). Jede Wert-Art ist jetzt eine Zeile in
`src/core/properties/kinds.ts`; die Art-Listen, Menues, Tags, Port-Felder und Literal-Nodes folgen
daraus. Ein Tag ist `'$' + kind`, also generisch. Eine neue Art ist die Zeile plus – wenn sie etwas
im Projekt benennt – ihr Picker (`fieldFactory.REFERENCES`, vom Typecheck erzwungen).
Nicht angefasst, weil kein Typ-Thema: die `type !== 'exec'`-Guards und das Zwillingspaar
`Variables.ts`/`Properties.ts`.
+6 -6
View File
@@ -56,7 +56,7 @@ export const propertiesOf = (objectType, objectBehavior) => ({
/**
* A behavior variable declaration. `fields` is the shape of a `struct`; a `list` declares nothing,
* its items each carry their own kind (a colour as `colorItem`).
* its items each carry their own kind (a colour or a texture as `tagged`).
*/
export const variable = (key, kind, value, fields) => ({
key,
@@ -69,8 +69,8 @@ export const variable = (key, kind, value, fields) => ({
/** A struct field declaration. */
export const field = (key, kind = 'number') => ({ key, kind });
/** A colour as a list item: inside a list, 0xRRGGBB would otherwise be a plain number. */
export const colorItem = (color) => ({ $color: color });
/** A picture as a list item – a texture, one of its frames or an animation, all one id. */
export const textureItem = (id) => ({ $texture: id });
/**
* A list item of a kind JSON cannot show: inside a list a colour would be a plain number and a
* texture id a plain text, so it wears its kind as a tag – `{ $color: 0xff0000 }`.
*/
export const tagged = (kind, value) => ({ [`$${kind}`]: value });
+3 -12
View File
@@ -19,16 +19,7 @@
*/
import { fileURLToPath } from 'node:url';
import {
edge,
node,
port,
propertiesOf,
sceneObject,
textureItem,
variable,
written,
} from './lib/builders.mjs';
import { edge, node, port, propertiesOf, sceneObject, tagged, variable, written } from './lib/builders.mjs';
import { PROJECTS, cliArgs, uploadTexture, writeProject } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('seed-actor.mjs', PROJECTS.actor);
@@ -80,8 +71,8 @@ const walkAnimations = DIRECTIONS.map((dir, row) => ({
}));
/** The two lists the graph indexes: one walk animation and one standing frame per direction. */
const WALK_PICTURES = DIRECTIONS.map((dir) => textureItem(WALK(dir)));
const IDLE_PICTURES = DIRECTIONS.map((_, row) => textureItem(idleFrame(row)));
const WALK_PICTURES = DIRECTIONS.map((dir) => tagged('texture', WALK(dir)));
const IDLE_PICTURES = DIRECTIONS.map((_, row) => tagged('texture', idleFrame(row)));
/**
* Sets `facing` to a constant – one per direction, so no edge has to cross the graph. It is the row
+6 -2
View File
@@ -8,13 +8,13 @@
* Usage: node tools/seed-tetris.mjs [projectId] [baseUrl]
*/
import {
colorItem,
edge,
field,
node,
port,
propertiesOf,
sceneObject,
tagged,
variable,
written,
} from './lib/builders.mjs';
@@ -891,7 +891,11 @@ const tetrisVariables = [
variable('grid', 'list', EMPTY_GRID),
variable('shapes', 'list', shapeTable()),
// Tagged, because a list says nothing about its items and a colour is a number like any other.
variable('colors', 'list', SHAPE_COLORS.map(colorItem)),
variable(
'colors',
'list',
SHAPE_COLORS.map((color) => tagged('color', color)),
),
variable('spawnX', 'list', spawnColumns()),
// The falling piece and the one move being tried on it – four numbers each, and never apart.
variable('piece', 'struct', NO_PIECE, PIECE_FIELDS),