Let a value be a bundle of named fields

PropertyValue was number | string | boolean | list. A struct joins it, which
costs two things the typechecker found rather than I did.

An object on a data edge used to mean a live entity, so asEntity would have
taken a struct for one and asString would have read .name off it. EntityHandle
now carries a symbol brand – a symbol so no field name can impersonate it, and
it never serializes, which suits a thing that exists only while the game runs.
It is built in exactly one place.

String(value) would have written a struct as [object Object] at 39 call sites.
They go through textOf(), which is identical for primitives.

normalizeGraph dropped everything but primitives from node params, so the List
node lost its value on every save ([3,1,4] round-tripped to undefined).
Variables were spared: they have a schema, params have none.
This commit is contained in:
root committed 2026-09-10 08:14:18 +02:00
1 parent 3b3f374e10
commit 2cf788eedd
34 files changed
+284 -82

No files matched your search

+5 -4
View File
@@ -1,7 +1,7 @@
import type { PropertyBag } from '../model/Properties';
/** `exec` carries control flow, the others values; `any` accepts every value type. */
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'any';
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'any';
export interface PortRef {
node: string;
@@ -19,8 +19,9 @@ export interface GraphNode {
y: number;
params: PropertyBag;
/**
* Ports the node declares itself, on top of the fixed ones of its type. Only the behavior
* start/end nodes carry this – they are what gives a nested behavior its signature.
* Ports the node declares itself, on top of the fixed ones of its type; the types that have
* them say so with `NodeDef.declaresPorts`. A behavior's start/end node is what gives the
* behavior its signature; an Expand/Pack node is what gives a struct its fields.
*/
ports?: DeclaredPort[];
}
@@ -31,7 +32,7 @@ export interface DeclaredPort {
kind: DeclaredPortKind;
}
export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object', 'list'] as const;
export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object', 'list', 'struct'] as const;
export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number];
export function declaredPortType(kind: DeclaredPortKind): PortType {
+8
View File
@@ -2,11 +2,19 @@ import type { PropertyBag } from '../model/Properties';
import { sumAlongChain } from '../model/walk';
import { enabledAlongChain } from '../properties/enabled';
/**
* Marks a live entity apart from a struct value: both reach a data edge as an object. A symbol
* rather than a field, so no struct field name can impersonate it – and it never serializes,
* which is right for a thing that exists only while the game runs.
*/
export const ENTITY_HANDLE: unique symbol = Symbol('entity handle');
/**
* What a graph sees of a running entity; the runtime's Entity satisfies this shape. Mutating
* `x`/`y`/`props` is how actions take effect.
*/
export interface EntityHandle {
readonly [ENTITY_HANDLE]: true;
id: string;
assetId: string;
name: string;
+5 -2
View File
@@ -93,9 +93,12 @@ export function hasExecInput(def: NodeDef): boolean {
return def.inputs.some((p) => p.type === 'exec');
}
/** Data inputs that may carry an inline value while unconnected. */
/**
* Data inputs that may carry an inline value while unconnected. An object is a live handle and a
* struct is assembled from its parts, so neither has a value that could be typed on one line.
*/
export function inlineInputs(def: NodeDef): PortDef[] {
return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object');
return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object' && p.type !== 'struct');
}
export function inlineInputDef(port: PortDef): PropertyDef {
+2 -2
View File
@@ -1,4 +1,4 @@
import type { PropertyBag } from '../model/Properties';
import { type PropertyBag, textOf } from '../model/Properties';
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
@@ -74,7 +74,7 @@ export function resolveDef(node: GraphNode, lookup: BehaviorLookup): NodeDef | u
*/
export function callProblem(node: GraphNode, lookup: BehaviorLookup, ownerId = ''): string | null {
if (node.type !== RUN_BEHAVIOR) return null;
const id = String(node.params[BEHAVIOR_PARAM] ?? '');
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
if (!id) return 'No behavior picked';
if (id === ownerId) return 'A behavior cannot run itself';
const program = lookup(id);
+2 -1
View File
@@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry';
import { asEntity, writeTyped } from '../../values';
import { any, exec, obj } from '../shared';
import { textOf } from '../../../model/Properties';
export const SetProperty: NodeDef = {
type: 'SetProperty',
label: 'Set property',
@@ -12,7 +13,7 @@ export const SetProperty: NodeDef = {
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
run: (_ctx, inputs, params) => {
const target = asEntity(inputs.object);
const key = String(params.property ?? '');
const key = textOf(params.property ?? '');
if (target && key) writeTyped(target.props, key, inputs.value);
return { next: 'exec' };
},
+2 -1
View File
@@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry';
import { asEntity, asNumber } from '../../values';
import { assetParam, exec, num, obj } from '../shared';
import { textOf } from '../../../model/Properties';
export const Spawn: NodeDef = {
type: 'Spawn',
label: 'Spawn',
@@ -13,7 +14,7 @@ export const Spawn: NodeDef = {
outputs: [exec(), obj('object')],
params: [assetParam],
run: (ctx, inputs, params) => {
const assetId = String(params.asset ?? '');
const assetId = textOf(params.asset ?? '');
const position = { x: asNumber(inputs.x), y: asNumber(inputs.y), z: asNumber(inputs.z) };
const spawned = assetId ? ctx.spawn(assetId, position, asEntity(inputs.parent)) : null;
if (!spawned) ctx.log(assetId ? `Spawn: asset ${assetId} not found` : 'Spawn: no asset selected');
+2 -1
View File
@@ -1,6 +1,7 @@
import type { NodeDef } from '../../NodeRegistry';
import { bool, normalize, num } from '../shared';
import { textOf } from '../../../model/Properties';
export const AxisInput: NodeDef = {
type: 'AxisInput',
label: 'Axis input',
@@ -15,7 +16,7 @@ export const AxisInput: NodeDef = {
{ key: 'down', label: 'Down', kind: 'key', default: 'ArrowDown' },
],
run: (ctx, _inputs, params) => {
const held = (key: string, fallback: string) => (ctx.isKeyDown(String(params[key] ?? fallback)) ? 1 : 0);
const held = (key: string, fallback: string) => (ctx.isKeyDown(textOf(params[key] ?? fallback)) ? 1 : 0);
const dx = held('right', 'ArrowRight') - held('left', 'ArrowLeft');
const dy = held('down', 'ArrowDown') - held('up', 'ArrowUp');
const { x, y, length } = normalize(dx, dy);
+2 -1
View File
@@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { bool, num } from '../shared';
import { textOf } from '../../../model/Properties';
const OPERATORS: Record<string, (a: number, b: number) => boolean> = {
'==': (a, b) => a === b,
'!=': (a, b) => a !== b,
@@ -30,7 +31,7 @@ export const Compare: NodeDef = {
run: (_ctx, inputs, params) => {
// The fallback matches the param's declared default, so a node with no setting behaves
// like a freshly created one.
const compare = OPERATORS[String(params.op)] ?? OPERATORS['<']!;
const compare = OPERATORS[textOf(params.op)] ?? OPERATORS['<']!;
return { outputs: { result: compare(asNumber(inputs.a), asNumber(inputs.b)) } };
},
};
+2 -1
View File
@@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry';
import { asEntity } from '../../values';
import { any, obj } from '../shared';
import { textOf } from '../../../model/Properties';
export const GetProperty: NodeDef = {
type: 'GetProperty',
label: 'Get property',
@@ -11,6 +12,6 @@ export const GetProperty: NodeDef = {
outputs: [any('value')],
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
run: (_ctx, inputs, params) => ({
outputs: { value: asEntity(inputs.object)?.props[String(params.property ?? '')] ?? null },
outputs: { value: asEntity(inputs.object)?.props[textOf(params.property ?? '')] ?? null },
}),
};
+2 -1
View File
@@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry';
import { asEntity } from '../../values';
import { assetParam, bool, obj } from '../shared';
import { textOf } from '../../../model/Properties';
export const IsAsset: NodeDef = {
type: 'IsAsset',
label: 'Is asset',
@@ -11,7 +12,7 @@ export const IsAsset: NodeDef = {
outputs: [bool('result')],
params: [assetParam],
run: (_ctx, inputs, params) => {
const assetId = String(params.asset ?? '');
const assetId = textOf(params.asset ?? '');
return { outputs: { result: Boolean(assetId) && asEntity(inputs.object)?.assetId === assetId } };
},
};
+2 -1
View File
@@ -1,6 +1,7 @@
import type { NodeDef } from '../../NodeRegistry';
import { bool, keyParam } from '../shared';
import { textOf } from '../../../model/Properties';
export const KeyIsDown: NodeDef = {
type: 'KeyIsDown',
label: 'Key is down',
@@ -9,5 +10,5 @@ export const KeyIsDown: NodeDef = {
inputs: [],
outputs: [bool('down')],
params: [keyParam],
run: (ctx, _inputs, params) => ({ outputs: { down: ctx.isKeyDown(String(params.key ?? '')) } }),
run: (ctx, _inputs, params) => ({ outputs: { down: ctx.isKeyDown(textOf(params.key ?? '')) } }),
};
+1
View File
@@ -9,6 +9,7 @@ export const bool = (key: string, label = key): PortDef => ({ key, label, type:
export const str = (key: string, label = key): PortDef => ({ key, label, type: 'string' });
export const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' });
export const list = (key: string, label = key): PortDef => ({ key, label, type: 'list' });
export const struct = (key: string, label = key): PortDef => ({ key, label, type: 'struct' });
export const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' });
export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value });
+2 -1
View File
@@ -1,6 +1,7 @@
import type { NodeDef } from '../../NodeRegistry';
import { any, variableParam } from '../shared';
import { textOf } from '../../../model/Properties';
export const GetVariable: NodeDef = {
type: 'GetVariable',
label: 'Get variable',
@@ -10,6 +11,6 @@ export const GetVariable: NodeDef = {
outputs: [any('value')],
params: [variableParam],
run: (ctx, _inputs, params) => ({
outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null },
outputs: { value: ctx.variables[textOf(params.variable ?? '')] ?? null },
}),
};
+2 -1
View File
@@ -2,6 +2,7 @@ import type { NodeDef } from '../../NodeRegistry';
import { writeTyped } from '../../values';
import { any, exec, variableParam } from '../shared';
import { textOf } from '../../../model/Properties';
export const SetVariable: NodeDef = {
type: 'SetVariable',
label: 'Set variable',
@@ -11,7 +12,7 @@ export const SetVariable: NodeDef = {
outputs: [exec()],
params: [variableParam],
run: (ctx, inputs, params) => {
const key = String(params.variable ?? '');
const key = textOf(params.variable ?? '');
if (key) writeTyped(ctx.variables, key, inputs.value);
return { next: 'exec' };
},
+59 -14
View File
@@ -1,9 +1,22 @@
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties';
import type { EntityHandle } from './NodeContext';
import {
type FieldValue,
isList,
isStruct,
type ListValue,
type PropertyBag,
type PropertyValue,
type StructValue,
textOf,
} from '../model/Properties';
import { ENTITY_HANDLE, type EntityHandle } from './NodeContext';
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
export type Value = PropertyValue | EntityHandle | null;
export function isEntity(value: Value | undefined): value is EntityHandle {
return typeof value === 'object' && value !== null && ENTITY_HANDLE in value;
}
export function asNumber(value: Value | undefined): number {
if (typeof value === 'number') return value;
if (typeof value === 'boolean') return value ? 1 : 0;
@@ -12,6 +25,8 @@ export function asNumber(value: Value | undefined): number {
return Number.isFinite(n) ? n : 0;
}
if (isList(value)) return value.length;
// A bundle counts as its parts, the same way a list counts as its items.
if (isStructValue(value)) return Object.keys(value).length;
return 0;
}
@@ -19,7 +34,9 @@ export function asNumber(value: Value | undefined): number {
export function asList(value: Value | undefined): ListValue {
if (isList(value)) return value;
if (value === null || value === undefined) return [];
return typeof value === 'object' ? [value.name] : [value];
if (isEntity(value)) return [value.name];
if (isStructValue(value)) return Object.values(value).map(asListItem);
return [value];
}
/** A value narrowed to what a list may hold: handles and nested lists have no stored form. */
@@ -29,6 +46,11 @@ export function asListItem(value: Value | undefined): number | string | boolean
return asString(value);
}
/** A value narrowed to what a struct field may hold: a list stays a list, anything deeper flattens. */
export function asField(value: Value | undefined): FieldValue {
return isList(value) ? value : asListItem(value);
}
export function asBoolean(value: Value | undefined): boolean {
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0';
return Boolean(value);
@@ -36,15 +58,42 @@ export function asBoolean(value: Value | undefined): boolean {
/** Entities read as their name, so a handle can be logged or joined into text directly. */
export function asString(value: Value | undefined): string {
if (value === null || value === undefined) return '';
if (isList(value)) return value.join(', ');
if (typeof value === 'object') return value.name;
return String(value);
if (value === null) return '';
return isEntity(value) ? value.name : textOf(value);
}
export function asEntity(value: Value | undefined): EntityHandle | null {
if (!value || typeof value !== 'object' || isList(value)) return null;
return value;
return isEntity(value) ? value : null;
}
/** A struct value, or the empty one: a number has no fields to become, so nothing is invented. */
export function asStruct(value: Value | undefined): StructValue {
return isStructValue(value) ? value : {};
}
function isStructValue(value: Value | undefined): value is StructValue {
return !isEntity(value) && isStruct(value);
}
/**
* A struct slot keeps the fields it was declared with: what arrives fills the ones it names and
* the rest stay put. That is what lets one node set some values of a bundle without erasing it.
*/
export function mergeStruct(current: StructValue, value: Value | undefined): StructValue {
const incoming = asStruct(value);
const merged: Record<string, PropertyValue> = {};
for (const [field, own] of Object.entries(current))
merged[field] = field in incoming ? asTypeOf(own, incoming[field]) : own;
return merged as StructValue;
}
/** `value` converted to the type the slot already holds, so a number stays a number. */
export function asTypeOf(current: PropertyValue | undefined, value: Value | undefined): PropertyValue {
if (isList(current)) return asList(value);
if (isStruct(current)) return mergeStruct(current, value);
if (typeof current === 'number') return asNumber(value);
if (typeof current === 'boolean') return asBoolean(value);
return asString(value);
}
/**
@@ -52,9 +101,5 @@ export function asEntity(value: Value | undefined): EntityHandle | null {
* variable stays a number no matter what arrives on an `any` input.
*/
export function writeTyped(bag: PropertyBag, key: string, value: Value | undefined): void {
const current = bag[key];
if (isList(current)) bag[key] = asList(value);
else if (typeof current === 'number') bag[key] = asNumber(value);
else if (typeof current === 'boolean') bag[key] = asBoolean(value);
else bag[key] = asString(value);
bag[key] = asTypeOf(bag[key], value);
}
+35 -2
View File
@@ -4,19 +4,52 @@
*/
export type ListValue = readonly (number | string | boolean)[];
/** What a struct field may hold: no field is itself a struct, for the same reason a list is flat. */
export type FieldValue = number | string | boolean | ListValue;
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
export type StructValue = { readonly [field: string]: FieldValue };
/** Colors are stored as 0xRRGGBB numbers. */
export type PropertyValue = number | string | boolean | ListValue;
export type PropertyValue = FieldValue | StructValue;
export type PropertyBag = Record<string, PropertyValue>;
export const isList = (value: unknown): value is ListValue => Array.isArray(value);
/** Values are compared by content, because two equal lists are never the same array. */
/**
* Model-level only: a live entity handle is an object too, and the graph tells the two apart by
* the handle's brand (see `values.ts`). Nothing in a PropertyBag is ever a handle.
*/
export const isStruct = (value: unknown): value is StructValue =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/**
* A property value as text. Nothing may hand a PropertyValue to `String()` directly: a struct would
* come out as "[object Object]" and a list without separators.
*/
export function textOf(value: PropertyValue | undefined): string {
if (value === undefined) return '';
if (isList(value)) return value.join(', ');
if (isStruct(value))
return Object.entries(value)
.map(([field, item]) => `${field}: ${textOf(item)}`)
.join(', ');
return String(value);
}
/** Values are compared by content, because two equal lists (or structs) are never the same object. */
export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
if (isList(a) || isList(b)) {
if (!isList(a) || !isList(b) || a.length !== b.length) return false;
return a.every((item, i) => item === b[i]);
}
if (isStruct(a) || isStruct(b)) {
if (!isStruct(a) || !isStruct(b)) return false;
const keys = Object.keys(a);
// Fields are never structs, so this recurses exactly one level deep.
return keys.length === Object.keys(b).length && keys.every((key) => sameValue(a[key], b[key]));
}
return a === b;
}
+3 -3
View File
@@ -1,5 +1,5 @@
import type { TextureAsset, TextureFrame } from './Asset';
import type { PropertyBag } from './Properties';
import { type PropertyBag, textOf } from './Properties';
import { findAsset, type Project } from './Project';
/** Mirrors `TextureStore::isValidName` on the server, where the name is derived from the bytes. */
@@ -56,7 +56,7 @@ export function textureRefIds(asset: TextureAsset): string[] {
}
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
const id = String(props.texture ?? '');
const id = textOf(props.texture ?? '');
if (!id) return null;
const asset = findAsset(project, id);
if (asset) return asset.type === 'texture' ? refFor(asset, null) : null;
@@ -68,7 +68,7 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n
if (!isTextureFile(asset.file)) return null;
return {
file: asset.file,
smooth: String(asset.props.filter ?? 'linear') !== 'nearest',
smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest',
rect: frame ? { x: frame.x, y: frame.y, w: frame.w, h: frame.h } : null,
sourceWidth: asset.width,
sourceHeight: asset.height,
+16 -9
View File
@@ -1,6 +1,13 @@
import type { Asset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../model/Properties';
import {
type FieldValue,
isList,
type ListValue,
type PropertyBag,
type PropertyValue,
textOf,
} from '../model/Properties';
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
@@ -176,7 +183,7 @@ export function variableDef(
case 'boolean':
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
case 'text':
return { key, label: key, kind, default: defaultValue === undefined ? '' : String(defaultValue) };
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
case 'list':
return {
key,
@@ -189,7 +196,7 @@ export function variableDef(
}
/** Clamps / coerces a raw value so it satisfies the definition. */
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
export function coerceValue(def: PropertyDef, value: PropertyValue): FieldValue {
switch (def.kind) {
case 'number': {
let n = typeof value === 'number' ? value : Number(value);
@@ -199,7 +206,7 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
return n;
}
case 'color': {
const n = typeof value === 'number' ? value : parseInt(String(value).replace('#', ''), 16);
const n = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16);
return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : def.default;
}
case 'boolean':
@@ -208,9 +215,9 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
case 'key':
case 'asset':
case 'texture':
return String(value);
return textOf(value);
case 'enum': {
const s = String(value);
const s = textOf(value);
return def.options.some((o) => o.value === s) ? s : def.default;
}
case 'list':
@@ -232,15 +239,15 @@ export function listFromText(text: string, items: ListItemKind): ListValue {
}
export function coerceListItem(value: PropertyValue, items: ListItemKind): number | string | boolean {
if (items === 'text') return isList(value) ? value.join(', ') : String(value);
if (items === 'text') return isList(value) ? value.join(', ') : textOf(value);
if (items === 'boolean') {
if (typeof value === 'boolean') return value;
// A list is typed as text, so the spelling has to count: Boolean('false') would be true.
const s = String(value).trim().toLowerCase();
const s = textOf(value).trim().toLowerCase();
return !(s === '' || s === 'false' || s === '0' || s === 'no');
}
if (items === 'color') {
const c = typeof value === 'number' ? value : parseInt(String(value).replace('#', ''), 16);
const c = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16);
return Number.isFinite(c) ? Math.max(0, Math.min(0xffffff, Math.floor(c))) : 0;
}
const n = typeof value === 'number' ? value : Number(value);
+3 -3
View File
@@ -3,7 +3,7 @@
* "Add collider", so all three agree on how big a thing is.
*/
import type { AssetType } from '../model/AssetTypes';
import type { PropertyBag, Vec2 } from '../model/Properties';
import { type PropertyBag, textOf, type Vec2 } from '../model/Properties';
import { cameraFrame } from './camera';
import { colliderSize } from './collider';
import { defaultsFor } from './PropertySchema';
@@ -21,7 +21,7 @@ const LINE_HEIGHT = 1.25;
/** Estimated from the content and font size, without measuring the text. */
export function textSize(props: PropertyBag): { w: number; h: number } {
const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize));
const lines = String(props.text ?? TEXT_DEFAULTS.text).split('\n');
const lines = textOf(props.text ?? TEXT_DEFAULTS.text).split('\n');
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
return {
w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH),
@@ -31,7 +31,7 @@ export function textSize(props: PropertyBag): { w: number; h: number } {
/** 0 = left edge on the position, 0.5 = centred, 1 = right. */
export function textAnchor(props: PropertyBag): number {
const align = String(props.align ?? TEXT_DEFAULTS.align);
const align = textOf(props.align ?? TEXT_DEFAULTS.align);
return align === 'left' ? 0 : align === 'right' ? 1 : 0.5;
}
+18 -5
View File
@@ -13,7 +13,7 @@ import {
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { FieldValue, PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { isTextureFile, normalizeFrameRect } from '../model/texture';
@@ -209,10 +209,11 @@ function normalizeGraph(raw: unknown): Graph {
const type = typeof n.type === 'string' ? n.type : '';
if (!id || nodeIds.has(id) || !nodeDef(type)) continue;
nodeIds.add(id);
// Params carry both settings and inline input values, so there is no schema to coerce against.
const params: PropertyBag = {};
for (const [key, value] of Object.entries(asRecord(n.params))) {
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean')
params[key] = value;
const stored = asStoredValue(value);
if (stored !== undefined) params[key] = stored;
}
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
const ports = normalizePorts(n.ports);
@@ -296,10 +297,22 @@ function asRecord(value: unknown): Record<string, unknown> {
}
/**
* A value from an untyped slot (instance edits carry no schema to coerce against). Lists are kept
* but flattened away one level, so nothing nested can enter the model behind the type's back.
* A value from an untyped slot (node params, instance edits). Lists and structs are kept but
* flattened one level, so nothing deeper than the model allows can enter behind the type's back.
*/
function asStoredValue(value: unknown): PropertyValue | undefined {
const field = asFieldValue(value);
if (field !== undefined) return field;
if (!value || typeof value !== 'object') return undefined;
const fields: Record<string, FieldValue> = {};
for (const [key, item] of Object.entries(value)) {
const stored = asFieldValue(item);
if (stored !== undefined) fields[key] = stored;
}
return fields;
}
function asFieldValue(value: unknown): FieldValue | undefined {
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value;
if (Array.isArray(value))
return value.filter(
+2 -1
View File
@@ -1,4 +1,5 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { textOf } from '../../core/model/Properties';
import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import {
@@ -380,7 +381,7 @@ export class GraphCanvas extends Widget {
/** Selecting the asset is what opens it: the graph tab follows the selection. */
private openCalledBehavior(node: GraphNode): boolean {
if (node.type !== RUN_BEHAVIOR) return false;
const id = String(node.params[BEHAVIOR_PARAM] ?? '');
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
const asset = id ? findAsset(this.state.project, id) : undefined;
if (asset?.type !== 'behavior') return false;
this.state.select({ kind: 'asset', assetId: id });
+5 -3
View File
@@ -14,7 +14,7 @@ import {
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import type { Project } from '../../core/model/Project';
import { isList, type PropertyValue } from '../../core/model/Properties';
import { isList, type PropertyValue, textOf } from '../../core/model/Properties';
import { assetRefLabel } from '../../core/properties/assetRef';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
@@ -60,6 +60,8 @@ export function portColor(type: PortType): number {
return c.portObject;
case 'list':
return c.portList;
case 'struct':
return c.portStruct;
case 'any':
return c.portAny;
}
@@ -296,8 +298,8 @@ function formatParam(
project: Project | null,
): string {
if (value === undefined || value === '') return '—';
if (param.kind === 'key') return keyLabel(String(value));
if (param.kind === 'asset') return assetRefLabel(project, String(value));
if (param.kind === 'key') return keyLabel(textOf(value));
if (param.kind === 'asset') return assetRefLabel(project, textOf(value));
if (
param.kind === 'enum' &&
param.key === 'variable' &&
+10 -10
View File
@@ -1,4 +1,4 @@
import { isList, type PropertyValue } from '../../core/model/Properties';
import { isList, type PropertyValue, textOf } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
import {
assetChoiceLabel,
@@ -187,8 +187,8 @@ export function createField(
return { widget: field, grow: true, setValue: (v) => field.setNumber(Number(v)) };
}
case 'text': {
const field = new TextField(String(value), { onInput: handlers.onInput, onChange: handlers.onChange });
return { widget: field, grow: true, setValue: (v) => field.setValue(String(v)) };
const field = new TextField(textOf(value), { onInput: handlers.onInput, onChange: handlers.onChange });
return { widget: field, grow: true, setValue: (v) => field.setValue(textOf(v)) };
}
case 'color': {
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
@@ -210,7 +210,7 @@ export function createField(
}
case 'enum': {
const labelOf = (v: PropertyValue) =>
def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
def.options.find((o) => o.value === textOf(v))?.label ?? (textOf(v) || '—');
const button = new Dropdown(labelOf(value));
button.onClick = () => {
new Menu(
@@ -220,13 +220,13 @@ export function createField(
return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) };
}
case 'key': {
const button = new KeyCaptureButton(String(value), (key) => handlers.onChange(key));
return { widget: button, grow: true, setValue: (v) => button.setKey(String(v)) };
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 = String(value);
let current = textOf(value);
const button = new Dropdown(assetRefLabel(ctx.project(), current));
button.onClick = () => {
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
@@ -246,7 +246,7 @@ export function createField(
widget: button,
grow: true,
setValue: (v) => {
current = String(v);
current = textOf(v);
button.setText(assetRefLabel(ctx.project(), current));
},
};
@@ -254,7 +254,7 @@ export function createField(
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 = String(value);
let current = textOf(value);
const button = new Dropdown(textureRefLabel(ctx.project(), current));
button.onClick = () => {
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
@@ -272,7 +272,7 @@ export function createField(
widget: button,
grow: true,
setValue: (v) => {
current = String(v);
current = textOf(v);
button.setText(textureRefLabel(ctx.project(), current));
},
};
+2
View File
@@ -14,6 +14,7 @@ const KIND_LABELS: Record<DeclaredPortKind, string> = {
text: 'Text',
object: 'Object',
list: 'List',
struct: 'Struct',
};
const KIND_MARKS: Record<DeclaredPortKind, string> = {
@@ -22,6 +23,7 @@ const KIND_MARKS: Record<DeclaredPortKind, string> = {
text: 'Aa',
object: '◇',
list: '≡',
struct: '⊞',
};
/**
+2 -2
View File
@@ -8,7 +8,7 @@ import {
} from 'pixi.js';
import { AddFrameCommand, SetFrameRectCommand } from '../../core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { textOf, type Vec2 } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { type FrameRect, MIN_FRAME_SIZE, normalizeFrameRect } from '../../core/model/texture';
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
@@ -335,7 +335,7 @@ export class TextureCanvas extends Widget {
private pictureOf(asset: TextureAsset) {
return textureLibrary.get({
file: asset.file,
smooth: String(asset.props.filter ?? 'linear') !== 'nearest',
smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest',
rect: null,
sourceWidth: asset.width,
sourceHeight: asset.height,
+2 -2
View File
@@ -1,6 +1,6 @@
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
import { isSymbolType } from '../../core/model/AssetTypes';
import { sortByZ, type Vec2 } from '../../core/model/Properties';
import { sortByZ, type Vec2, textOf } from '../../core/model/Properties';
import { footprintOf, textAnchor } from '../../core/properties/bounds';
import { rectAround, rectsIntersect } from '../../core/properties/camera';
import type { EditorState } from '../state';
@@ -213,7 +213,7 @@ export abstract class WorldViewport extends Widget {
}
text.visible = true;
text.position.set(item.x, item.y);
text.text = String(item.props.text ?? '');
text.text = textOf(item.props.text ?? '');
const size = Math.max(1, Number(item.props.fontSize ?? 32));
if (text.style.fontSize !== size) text.style.fontSize = size;
const color = Number(item.props.color ?? 0xffffff);
+3
View File
@@ -1,3 +1,4 @@
import type { ENTITY_HANDLE } from '../core/graph/NodeContext';
import type { AssetType } from '../core/model/AssetTypes';
import type { PropertyBag } from '../core/model/Properties';
@@ -8,6 +9,8 @@ import type { PropertyBag } from '../core/model/Properties';
* editor panels can show and edit them while playing.
*/
export interface Entity {
/** What tells a graph this object is a live entity and not a struct value. */
readonly [ENTITY_HANDLE]: true;
id: string;
/** Scene object this entity was instantiated from (for selection highlighting). */
objectId: string;
+2 -1
View File
@@ -1,4 +1,4 @@
import { isHandleEnabled, type PointerState, worldPositionOf } from '../core/graph';
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldPositionOf } from '../core/graph';
import { newId } from '../core/ids';
import type { Asset } from '../core/model/Asset';
import { isPlaceableType } from '../core/model/AssetTypes';
@@ -118,6 +118,7 @@ export class World {
let entity = this.findByObjectId(object.id);
if (!entity) {
entity = {
[ENTITY_HANDLE]: true,
id: `e-${object.id}`,
objectId: object.id,
assetId: asset.id,
+3 -3
View File
@@ -9,7 +9,7 @@ import {
} from '../../core/graph';
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import type { PropertyBag } from '../../core/model/Properties';
import { type PropertyBag, textOf } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project';
import type { Entity } from '../Entity';
import type { World } from '../World';
@@ -95,7 +95,7 @@ export class BehaviorSystem implements System {
update(world: World, dt: number): void {
this.syncInstances(world);
const keyOf = (node: { params: PropertyBag }) => String(node.params.key ?? '');
const keyOf = (node: { params: PropertyBag }) => textOf(node.params.key ?? '');
for (const instance of this.instances) {
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent)
@@ -136,7 +136,7 @@ export class BehaviorSystem implements System {
* in `nodeState`, so two nodes on the same key repeat on their own schedules.
*/
private fireKeyDown(i: GraphInterpreter, ctx: NodeContext, node: GraphNode, dt: number): void {
const key = String(node.params.key ?? '');
const key = textOf(node.params.key ?? '');
const stored = i.nodeState.get(node.id);
const { state, fires } = stepKeyRepeat(
stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null,
+1
View File
@@ -59,6 +59,7 @@ export const Theme = {
portString: 0xe6a8ff,
portObject: 0x56ccf2,
portList: 0xf2a75c,
portStruct: 0xd4c25a,
portAny: 0xbdbdbd,
/** Ring around the port a running edge drag would connect to. */
portTarget: 0xffffff,
+9
View File
@@ -486,6 +486,15 @@ describe('behavior serialization', () => {
expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length);
expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length);
});
it('keeps a node param that is a whole list or a struct, not just a single value', () => {
const { project, behavior, edit } = emptyBehavior();
const list = edit.addNode('List', { value: [3, 1, 4] });
const struct = edit.addNode('Number', { bundle: { x: 2, tags: ['a'] } });
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
expect(findNode(loaded.graph, list.id)!.params.value).toEqual([3, 1, 4]);
expect(findNode(loaded.graph, struct.id)!.params.bundle).toEqual({ x: 2, tags: ['a'] });
});
});
describe('unknown node types', () => {
+43 -2
View File
@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { sameValue } from '../../src/core/model/Properties';
import { sameValue, 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, listFromText, listToText, variableDef } from '../../src/core/properties/PropertySchema';
import { deviatesFromAsset } from '../../src/core/properties/resolve';
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
import { asEntity, asStruct, writeTyped } from '../../src/core/graph';
import { fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
const findObject = (project: Project, id: string) =>
objectHolders(project)
@@ -94,3 +95,43 @@ describe('a list as a value', () => {
expect(coerceValue(def, ['1', 2])).toEqual([1, 2]);
});
});
describe('a struct as a value', () => {
it('compares by content, one level deep, and tells a different shape apart', () => {
expect(sameValue({ x: 1, tags: ['a'] }, { x: 1, tags: ['a'] })).toBe(true);
expect(sameValue({ x: 1, tags: ['a'] }, { x: 1, tags: ['b'] })).toBe(false);
expect(sameValue({ x: 1 }, { x: 1, y: 2 })).toBe(false);
expect(sameValue({ x: 1 }, [1])).toBe(false);
});
it('reads as text field by field, never as "[object Object]"', () => {
expect(textOf({ x: 1, tags: ['a', 'b'] })).toBe('x: 1, tags: a, b');
});
it('is not a live entity, and a live entity is not one', () => {
const handle = fakeEntity('Player');
expect(asStruct(handle)).toEqual({});
expect(asStruct({ x: 1 })).toEqual({ x: 1 });
expect(asEntity({ x: 1 })).toBeNull();
expect(asEntity(handle)).toBe(handle);
});
/** What lets one node set some values of a bundle without erasing the rest of it. */
it('keeps the fields of the slot it is written into, filling only the ones that arrive', () => {
const bag = { piece: { x: 1, y: 2, name: 'T' } };
writeTyped(bag, 'piece', { y: 9, gone: 5 });
expect(bag.piece).toEqual({ x: 1, y: 9, name: 'T' });
});
it('converts each field to the type that field already held', () => {
const bag = { piece: { count: 0, on: false, label: '' } };
writeTyped(bag, 'piece', { count: '7', on: 'yes', label: 42 });
expect(bag.piece).toEqual({ count: 7, on: true, label: '42' });
});
it('a non-struct written into a struct slot leaves the declared fields standing', () => {
const bag = { piece: { x: 1 } };
writeTyped(bag, 'piece', 5);
expect(bag.piece).toEqual({ x: 1 });
});
});
+24 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { type EntityHandle, worldPositionOf } from '../../src/core/graph';
import { ENTITY_HANDLE, type EntityHandle, worldPositionOf } from '../../src/core/graph';
import { hasParentCycle, parentChain } from '../../src/core/model/walk';
import { makeSpriteScene, place, startWorld } from '../helpers';
@@ -41,8 +41,29 @@ describe('parentChain', () => {
describe('cycle-safe walks in the runtime', () => {
it('world position of a cyclic chain terminates and counts each ancestor once', () => {
const a: EntityHandle = { id: 'a', assetId: 'x', name: 'a', x: 1, y: 10, z: 0, props: {}, parent: null };
const b: EntityHandle = { id: 'b', assetId: 'x', name: 'b', x: 2, y: 20, z: 0, props: {}, parent: a };
const brand = { [ENTITY_HANDLE]: true } as const;
const a: EntityHandle = {
...brand,
id: 'a',
assetId: 'x',
name: 'a',
x: 1,
y: 10,
z: 0,
props: {},
parent: null,
};
const b: EntityHandle = {
...brand,
id: 'b',
assetId: 'x',
name: 'b',
x: 2,
y: 20,
z: 0,
props: {},
parent: a,
};
a.parent = b;
expect(worldPositionOf(b)).toEqual({ x: 3, y: 30, z: 0 });
});
+3 -2
View File
@@ -4,6 +4,7 @@ import { AddNodeCommand, ConnectCommand } from '../src/core/commands/graphComman
import { PlaceAssetCommand } from '../src/core/commands/sceneObjectCommands';
import { AddVariableCommand } from '../src/core/commands/variableCommands';
import {
ENTITY_HANDLE,
type EntityHandle,
type GraphNode,
type NodeContext,
@@ -132,12 +133,12 @@ export const NO_POINTER: PointerState = {
/** A top-level entity handle named after itself, with empty props. */
export function fakeEntity(name: string, x = 0, assetId = `asset-${name}`): EntityHandle {
return { id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null };
return { [ENTITY_HANDLE]: true, id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null };
}
/** An entity handle "h" carrying the given props, optionally below a parent. */
export function fakeHandle(props: PropertyBag, parent: EntityHandle | null = null): EntityHandle {
return { id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent };
return { [ENTITY_HANDLE]: true, id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent };
}
export type FakeContext = NodeContext & { logs: string[]; stopped: boolean };