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

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