Define a struct once as an asset and reuse its shape (#253)
game-maker / gate (push) Successful in 3m30s
game-maker / suite (push) Successful in 10m33s

A struct used to declare its fields wherever it stood. The same shape
in two variables meant typing it twice, and Pack/Expand copied it once
without staying in sync. A Struct asset now holds the shape in one
place, and every struct that picks it as its type follows it.

- New asset type "Struct" (not placeable, group Structs, own icon).
  The inspector shows its fields with name, kind and default value,
  and "Open" shows them in the variables tab.
- Struct variables and struct fields get a Type row: own fields, any
  Struct asset, or "Make struct type". With a type the fields come
  from the asset and cannot be added, renamed or removed there. The
  values stay per variable, overrides on placed objects still work.
- Pack and Expand get the setting Type. With a type their ports are
  the fields of the asset and read-only in the inspector.
- A change to the asset reaches every typed place in one undo step
  (syncStructTypes). A new field arrives with its default. A removed
  field goes along with its port, edge and typed value. A renamed
  field keeps its value in variables, overrides and on ports with
  edges, also through nested types.
- Undo for these edits records plain assignments (Recorder), because
  the reach of a change is not known in advance.
- Cycles such as Loot in Loot, also through another type, are refused
  and not offered in the picker.
- Deleting the asset names how many places use it. Afterwards each
  place keeps its fields as its own.
- Loading reshapes a stale copy in a project file to its type.
- "Make struct type" creates an asset from an existing struct and
  links the place to it.

Differs from the plan:

- A changed default value applies to new fields and newly typed
  places only. Existing values stay.
- Removing a single field shows no warning. Undo brings it back.

Typed lists are left for #254. Lists stay dynamic for now.

Found by e2e: after deleting or renaming the type the inspector did
not rebuild and kept showing the old name. Fixed, the check was red
before.

Tests:
- ./check.sh: 182 files, 2661 tests, CHECK EXIT 0
- GM_STEPS=1,4,21,22,54: ALL PASSED, 176 checks
- 14 unit tests in tests/core/struct-types.test.ts
- New e2e module 54 with 14 checks, real mouse. Module 04 counts
  23 asset types.
- Counter-probes: without the rename pass 4 unit tests fail, without
  reshaping values 8, without the cycle rule 1. In the browser,
  without spreading changes and with + Field despite a type, 3
  checks fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 06:04:40 +02:00
1 parent 4857bb75e6
commit 61bbb96a96
29 files changed
+1523 -66

No files matched your search

+18
View File
@@ -77,6 +77,16 @@ export interface VariablesAsset extends AssetBase {
scope?: Scope;
}
/**
* A named shape for structs (#253). Its fields are declared as a Variables asset declares its
* variables, so the same rows and commands edit them, and `props` holds their defaults. A struct
* variable or field naming it in `structType` takes on these fields (see `model/structTypes.ts`).
*/
export interface StructAsset extends AssetBase {
type: 'struct';
variables: PropertyDef[];
}
/** Placed as a child of the object it belongs to; its position is the offset from that body. */
export interface ColliderAsset extends AssetBase {
type: 'collider';
@@ -458,6 +468,7 @@ export type Asset =
| SceneAsset
| BehaviorAsset
| VariablesAsset
| StructAsset
| ColliderAsset
| RigidbodyAsset
| ContainerAsset
@@ -475,6 +486,13 @@ export type Asset =
| SoundAsset
| ParticlesAsset;
/** The assets whose `variables` are declared by the user – see `requireVariableOwner`. */
export type VariableOwnerAsset = BehaviorAsset | VariablesAsset | StructAsset;
export function isVariableOwner(asset: Asset | undefined): asset is VariableOwnerAsset {
return asset?.type === 'behavior' || asset?.type === 'variables' || asset?.type === 'struct';
}
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset | ParticlesAsset;
+5
View File
@@ -5,6 +5,7 @@ export const ASSET_TYPES = [
'text',
'behavior',
'variables',
'struct',
'collider',
'rigidbody',
'container',
@@ -32,6 +33,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
text: 'Text',
behavior: 'Behavior',
variables: 'Variables',
struct: 'Struct',
collider: 'Collider',
rigidbody: 'Rigidbody',
container: 'Container',
@@ -57,6 +59,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
* sound is nowhere on screen at all – a graph plays it, so there is nothing to place. A tile is
* what a tilemap draws with, so it is placed no more directly than a texture is – what is placed
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
*/
export type PlaceableAssetType = Exclude<
AssetType,
@@ -73,6 +76,7 @@ export type PlaceableAssetType = Exclude<
| 'font'
| 'sound'
| 'variables'
| 'struct'
>;
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
@@ -89,6 +93,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'font',
'sound',
'variables',
'struct',
]);
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
+374
View File
@@ -0,0 +1,374 @@
/**
* Struct assets as types (#253). A struct variable or field naming one in `structType` keeps a copy
* of its fields, so everything reading `fields` works as it did; `syncStructTypes` brings every such
* copy – and every value and `Pack`/`Expand` port hanging on it – back in line after the asset
* changed. A type that is gone leaves the copy as it was: the place keeps its last fields as its own.
*/
import type { DeclaredPort, Graph, GraphNode } from '../graph/Graph';
import { fixedPortKeys, isValidPortName, STRUCT_TYPE_PARAM } from '../graph/nested';
import { nodeDef } from '../graph/NodeRegistry';
import { isKindWith } from '../properties/kinds';
import {
coerceFieldValue,
emptyFieldValue,
type PropertyDef,
type StructDef,
type StructFieldDef,
structOf,
} from '../properties/PropertySchema';
import { valueAt, withValueAt } from '../properties/valuePath';
import type { Recorder } from '../util/recorder';
import { isGraphAsset, isVariableOwner, type StructAsset, type VariableOwnerAsset } from './Asset';
import { overrideSites } from './instance';
import { type FieldValue, isStruct, type PropertyValue, type StructValue, tagOf, textOf } from './Properties';
import { findAssetOf, type Project } from './Project';
export const PACK_STRUCT = 'PackStruct';
export const EXPAND_STRUCT = 'ExpandStruct';
/** Deeper than this only a cycle written by hand can go; it is cut there. */
const MAX_DEPTH = 32;
export function structAssetOf(project: Project, id: string | undefined): StructAsset | undefined {
return id ? findAssetOf(project, id, 'struct') : undefined;
}
export function isTypedNode(node: GraphNode): boolean {
return node.type === PACK_STRUCT || node.type === EXPAND_STRUCT;
}
export function nodeStructType(node: GraphNode): string {
return isTypedNode(node) ? textOf(node.params[STRUCT_TYPE_PARAM] ?? '') : '';
}
/** A declared variable as the struct field it becomes in every value of the type. */
export function fieldOfVariable(def: PropertyDef): StructFieldDef | undefined {
if (def.kind === 'struct')
return {
key: def.key,
kind: 'struct',
fields: def.fields,
...(def.structType ? { structType: def.structType } : {}),
};
if (def.kind === 'list') return { key: def.key, kind: 'list' };
return isKindWith('field', def.kind) ? { key: def.key, kind: def.kind } : undefined;
}
export function typeFields(asset: StructAsset): StructFieldDef[] {
return asset.variables.flatMap((def) => fieldOfVariable(def) ?? []);
}
/** A new value of the type: every field at the default the asset declares for it. */
export function typeDefault(asset: StructAsset): StructValue {
return structOf(typeFields(asset), asset.props);
}
/** The Struct assets named anywhere inside these fields; a typed field is not looked into. */
function typesIn(fields: readonly StructFieldDef[]): string[] {
return fields.flatMap((f) =>
f.kind !== 'struct' ? [] : f.structType ? [f.structType] : typesIn(f.fields),
);
}
/**
* Whether a value of type `from` holds one of type `target` anywhere inside, or is one. Such a
* type cannot be a field of `target`: its default would never end.
*/
export function containsType(project: Project, from: string, target: string, depth = 0): boolean {
if (from === target) return true;
const asset = structAssetOf(project, from);
if (!asset || depth > MAX_DEPTH) return false;
return typesIn(typeFields(asset)).some((id) => containsType(project, id, target, depth + 1));
}
/** Where values of type `target` sit inside a value of type `from`: `[]` when it is one itself. */
function typePaths(project: Project, from: string, target: string, depth = 0): string[][] {
if (from === target) return [[]];
const asset = structAssetOf(project, from);
if (!asset || depth > MAX_DEPTH) return [];
return pathsIn(project, typeFields(asset), target, depth + 1);
}
function pathsIn(
project: Project,
fields: readonly StructFieldDef[],
target: string,
depth: number,
): string[][] {
if (depth > MAX_DEPTH) return [];
const paths: string[][] = [];
for (const f of fields) {
if (f.kind !== 'struct') continue;
const inner = f.structType
? typePaths(project, f.structType, target, depth + 1)
: pathsIn(project, f.fields, target, depth + 1);
for (const path of inner) paths.push([f.key, ...path]);
}
return paths;
}
/**
* A place that takes on a type: the struct at `path` inside the variable `def` of `owner`. Below it
* nothing is a place of its own – the type's fields say what is there.
*/
export interface TypedSite {
owner: VariableOwnerAsset;
def: StructDef;
path: string[];
structType: string;
}
export function typedSites(project: Project): TypedSite[] {
const sites: TypedSite[] = [];
const visit = (
owner: VariableOwnerAsset,
def: StructDef,
at: { structType?: string; fields: readonly StructFieldDef[] },
path: string[],
): void => {
if (at.structType) {
sites.push({ owner, def, path, structType: at.structType });
return;
}
for (const f of at.fields) if (f.kind === 'struct') visit(owner, def, f, [...path, f.key]);
};
for (const asset of project.assets)
if (isVariableOwner(asset))
for (const def of asset.variables) if (def.kind === 'struct') visit(asset, def, def, []);
return sites;
}
/** Everywhere the value of a variable is kept: its declared default, the owner's, the overrides. */
interface Holder {
read(): PropertyValue | undefined;
write(value: PropertyValue): void;
}
function holdersOf(project: Project, site: TypedSite, rec: Recorder): Holder[] {
const { owner, def } = site;
const holders: Holder[] = [
{ read: () => def.default, write: (value) => rec.set(def, 'default', value as StructValue) },
];
if (owner.props[def.key] !== undefined)
holders.push({
read: () => owner.props[def.key],
write: (value) => rec.set(owner.props, def.key, value),
});
for (const { bag } of overrideSites(project, (id) => id === owner.id))
if (bag[def.key] !== undefined)
holders.push({ read: () => bag[def.key], write: (value) => rec.set(bag, def.key, value) });
return holders;
}
/**
* `value` in the shape of `fields`: what it has keeps its value (brought to the field's kind), what
* it lacks takes the type's default, and what the type does not declare falls away.
*/
export function shapedStruct(
project: Project,
fields: readonly StructFieldDef[],
value: PropertyValue | undefined,
defaults: StructValue,
depth = 0,
): StructValue {
const source = isStruct(value) && tagOf(value) === undefined ? value : {};
const shaped: Record<string, FieldValue> = {};
for (const field of fields) {
const own = source[field.key];
const fallback = defaults[field.key];
const inner = field.kind === 'struct' ? structAssetOf(project, field.structType) : undefined;
if (inner && field.kind === 'struct' && depth < MAX_DEPTH) {
const innerDefaults = isStruct(fallback) ? fallback : typeDefault(inner);
shaped[field.key] = shapedStruct(project, field.fields, own ?? innerDefaults, innerDefaults, depth + 1);
} else if (own !== undefined) shaped[field.key] = coerceFieldValue(own, field);
else
shaped[field.key] = fallback !== undefined ? coerceFieldValue(fallback, field) : emptyFieldValue(field);
}
return shaped;
}
/** The declaration with the fields of the struct at `path` replaced – and nothing else touched. */
function withFieldsAt(
fields: readonly StructFieldDef[],
path: readonly string[],
change: (field: Extract<StructFieldDef, { kind: 'struct' }>) => StructFieldDef,
): readonly StructFieldDef[] {
const [step, ...rest] = path;
return fields.map((f) =>
f.key !== step || f.kind !== 'struct'
? f
: rest.length === 0
? change(f)
: { ...f, fields: withFieldsAt(f.fields, rest, change) },
);
}
/** The struct field at `path` of a variable, or the variable itself for none. */
export function structAt(
def: StructDef,
path: readonly string[],
): { fields: readonly StructFieldDef[]; structType?: string } | undefined {
let at: { fields: readonly StructFieldDef[]; structType?: string } = def;
for (const step of path) {
const next = at.fields.find((f) => f.key === step);
if (next?.kind !== 'struct') return undefined;
at = next;
}
return at;
}
function setFields(
def: StructDef,
path: readonly string[],
fields: readonly StructFieldDef[],
rec: Recorder,
) {
if (path.length === 0) rec.set(def, 'fields', fields);
else
rec.set(
def,
'fields',
withFieldsAt(def.fields, path, (f) => ({ ...f, fields })),
);
}
/** Names the type of the struct at `path`; `''` makes it hold its own fields again. */
export function setStructType(def: StructDef, path: readonly string[], typeId: string, rec: Recorder): void {
if (path.length === 0) {
if (typeId) rec.set(def, 'structType', typeId);
else rec.delete(def, 'structType');
return;
}
rec.set(
def,
'fields',
withFieldsAt(def.fields, path, ({ structType: _dropped, ...f }) =>
typeId ? { ...f, structType: typeId } : f,
),
);
}
/** A field of a Struct asset renamed: `path` leads through the type's value to the struct holding it. */
export interface FieldRename {
structType: string;
path: readonly string[];
from: string;
to: string;
}
function renamedKey(value: StructValue, from: string, to: string): StructValue {
const next: Record<string, FieldValue> = {};
for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item;
return next;
}
/** Carries a renamed field's values over, before the shapes are rebuilt – which would drop them. */
function applyRename(project: Project, rec: Recorder, rename: FieldRename): void {
const { from, to } = rename;
for (const site of typedSites(project))
for (const inside of typePaths(project, site.structType, rename.structType)) {
const path = [...site.path, ...inside, ...rename.path];
for (const holder of holdersOf(project, site, rec)) {
const value = holder.read();
const at = valueAt(value, path);
if (!isStruct(at) || !(from in at)) continue;
holder.write(withValueAt(value, path, renamedKey(at, from, to)) ?? value!);
}
}
if (rename.path.length > 0) return;
for (const { graph, node } of typedNodes(project)) {
if (nodeStructType(node) !== rename.structType) continue;
const ports = node.ports ?? [];
if (!ports.some((p) => p.key === from)) continue;
rec.set(
node,
'ports',
ports.map((p) => (p.key === from ? { ...p, key: to } : p)),
);
for (const edge of graph.edges) {
if (edge.to.node === node.id && edge.to.port === from) rec.set(edge.to, 'port', to);
if (edge.from.node === node.id && edge.from.port === from) rec.set(edge.from, 'port', to);
}
const inline = node.params[from];
if (inline !== undefined) {
rec.set(node.params, to, inline);
rec.delete(node.params, from);
}
}
}
function typedNodes(project: Project): { graph: Graph; node: GraphNode }[] {
return project.assets.flatMap((asset) =>
isGraphAsset(asset)
? asset.graph.nodes.filter(isTypedNode).map((node) => ({ graph: asset.graph, node }))
: [],
);
}
/** The ports follow the fields; an edge on a port that is gone goes with it, as its typed-in value does. */
function syncNodePorts(
graph: Graph,
node: GraphNode,
fields: readonly StructFieldDef[],
rec: Recorder,
): void {
const def = nodeDef(node.type);
if (!def) return;
const fixed = new Set(fixedPortKeys(def));
const ports: DeclaredPort[] = fields
.filter((f) => isValidPortName(f.key) && !fixed.has(f.key))
.map((f) => ({ key: f.key, kind: f.kind }));
const kept = new Set(ports.map((p) => p.key));
const gone = new Set((node.ports ?? []).map((p) => p.key).filter((key) => !kept.has(key)));
rec.set(node, 'ports', ports);
if (gone.size === 0) return;
const inputs = def.declaresPorts === 'inputs';
const hangs = (end: { node: string; port: string }) => end.node === node.id && gone.has(end.port);
if (graph.edges.some((e) => (inputs ? hangs(e.to) : hangs(e.from))))
rec.set(
graph,
'edges',
graph.edges.filter((e) => !(inputs ? hangs(e.to) : hangs(e.from))),
);
if (inputs) for (const key of gone) rec.delete(node.params, key);
}
/**
* Brings every typed place in line with its Struct asset: the copied fields, every value kept for
* it, the ports of typed `Pack`/`Expand` nodes. A type holding another type is only right once that
* one is, so the passes repeat until one changes nothing. Every write goes through `rec`, so the
* whole reach of an edit is undone in one go.
*/
export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldRename): void {
if (rename) applyRename(project, rec, rename);
const passes = project.assets.filter((a) => a.type === 'struct').length + 2;
for (let pass = 0; pass < passes; pass++) {
const before = rec.count;
for (const site of typedSites(project)) {
const type = structAssetOf(project, site.structType);
// A struct asset naming a type that holds it is a cycle written by hand: left as it is.
if (!type || (site.owner.type === 'struct' && containsType(project, type.id, site.owner.id))) continue;
const fields = typeFields(type);
setFields(site.def, site.path, fields, rec);
const defaults = typeDefault(type);
for (const holder of holdersOf(project, site, rec)) {
const value = holder.read();
const next = shapedStruct(project, fields, valueAt(value, site.path), defaults);
holder.write(withValueAt(value, site.path, next) ?? next);
}
}
for (const { graph, node } of typedNodes(project)) {
const type = structAssetOf(project, nodeStructType(node));
if (type) syncNodePorts(graph, node, typeFields(type), rec);
}
if (rec.count === before) return;
}
}
/** How many places name the type – what deleting it would cut loose. */
export function structTypeUsages(project: Project, typeId: string): { places: number; nodes: number } {
return {
places: typedSites(project).filter((site) => site.structType === typeId).length,
nodes: typedNodes(project).filter(({ node }) => nodeStructType(node) === typeId).length,
};
}