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>
249 lines
8.4 KiB
TypeScript
249 lines
8.4 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|