Files
feuerwerk/src/core/properties/kinds.ts
T
adminandClaude Opus 5.5 923baf2557 Let Spawn take an asset over an edge and find a child by name (#237)
An asset could only be picked as a setting on the Spawn node. It could
not be wired in, held in a variable or put into a list. Finding an
object below the own one needed a scene-wide search by name.

- "Asset" is now a kind like Animation or Sound: port, variable, list
  item and a new "Asset" literal node, with its own cube icon.
- Spawn and Is asset have an `asset` input. It uses the key of the old
  setting, so a saved asset reads as the value of the input and older
  projects keep spawning without a migration.
- New node "Child by name" searches below an object level by level.
  A child wins over a grandchild of the same name. Nothing found or no
  object gives null.

Tests:
- ./check.sh: 168 files, 2453 tests, CHECK EXIT 0.
- 9 new unit tests in tests/core/asset-kind.test.ts, catalog cases for
  Spawn, Is asset, Child by name and the literal.
- e2e 6z-ii (2 checks): node rows and a real drag of the literal onto
  `asset`. e2e 6: 119 checks passed. Spawn checks in 3 and 9 passed.
- Counter-checks: depth-first search fails 1, Spawn reading only the
  old setting fails 3, runtime ignoring the parent fails 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:36:49 +02:00

249 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
/** A mark in letters; the other kinds are marked by a picture (`KIND_ICON_SVG` in the editor). */
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',
travels: 'boolean',
empty: false,
coerce: bool,
variable: true,
field: true,
port: true,
},
{
kind: 'color',
label: 'Color',
travels: 'number',
empty: 0,
coerce: color,
tagged: true,
variable: true,
field: true,
port: true,
},
/** A texture or one of its frames – one id either way, which is why it is not an `asset`. */
{
kind: 'texture',
label: 'Texture',
travels: 'string',
empty: '',
coerce: text,
tagged: true,
reference: true,
variable: true,
field: true,
port: true,
},
/** A state machine asset's id – a text, so it is tagged in a list like a texture. */
{
kind: 'statemachine',
label: 'State machine',
travels: 'string',
empty: '',
coerce: text,
tagged: true,
reference: true,
variable: true,
field: true,
port: true,
},
/** A sound asset's id – a text, so it is tagged in a list like a texture. */
{
kind: 'sound',
label: 'Sound',
travels: 'string',
empty: '',
coerce: text,
tagged: true,
reference: true,
variable: true,
field: true,
port: true,
},
/** A scene asset's id – a text, so it is tagged in a list like a texture. */
{
kind: 'scene',
label: 'Scene',
travels: 'string',
empty: '',
coerce: text,
tagged: true,
reference: true,
variable: true,
field: true,
port: true,
},
/** An animation asset's id – a text, so it is tagged in a list like a texture. */
{
kind: 'animation',
label: 'Animation',
travels: 'string',
empty: '',
coerce: text,
tagged: true,
reference: true,
variable: true,
field: true,
port: true,
},
/**
* An asset's id, `''` = none – what `Spawn` places. Declared by a user it offers every placeable
* asset; a schema row narrows that with `assetTypes`. Ids are not validated on load, so it can dangle.
*/
{
kind: 'asset',
label: 'Asset',
travels: 'string',
empty: '',
coerce: text,
tagged: true,
reference: true,
variable: true,
field: true,
port: true,
},
{ kind: 'object', label: 'Object', travels: 'object', port: true },
{ kind: 'list', label: 'List', travels: 'list', variable: true, port: true },
{ kind: 'struct', label: 'Struct', 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 },
/** One collision layer by number (#152); the names come from the project, not from the row. */
{ kind: 'layer', label: 'Layer', travels: 'number', empty: 0, coerce: numberOf },
/** A set of collision layers as the 16 bits of a mask (#152). */
{ kind: 'layers', label: 'Layers', travels: 'number', empty: 0, coerce: numberOf },
/** A tilemap layer by name, `''` = the first (#135); the names come from the project's tilemaps. */
{ kind: 'tileLayer', label: 'Tilemap layer', travels: 'string', empty: '', coerce: text },
] 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);
if (!row) return '?';
return '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);
}