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

+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 });