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

+11 -3
View File
@@ -5,7 +5,7 @@ import {
type PropertyValue,
type StructValue,
} from '../model/Properties';
import { isKeptVariable, setKeptVariable } from '../model/Asset';
import { isKeptVariable, setKeptVariable, type VariableOwnerAsset } from '../model/Asset';
import { overrideSites } from '../model/instance';
import type { Project } from '../model/Project';
import type { VariableKind } from '../properties/kinds';
@@ -42,7 +42,7 @@ export class AddVariableCommand implements Command {
execute(project: Project): void {
const asset = requireVariableOwner(project, this.assetId);
if (!isValidVariableName(this.def.key)) throw new CommandError(INVALID_NAME);
if (!isValidName(asset, this.def.key)) throw new CommandError(INVALID_NAME);
if (asset.variables.some((v) => v.key === this.def.key))
throw new CommandError(`Variable "${this.def.key}" already exists`);
asset.variables.push(this.def);
@@ -139,7 +139,7 @@ export class RenameVariableCommand extends RenameCommand {
protected rename(project: Project, from: string, to: string): void {
const asset = requireVariableOwner(project, this.assetId);
if (from === to) return;
if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME);
if (!isValidName(asset, to)) throw new CommandError(INVALID_NAME);
const def = asset.variables.find((v) => v.key === from);
if (!def) throw new CommandError(`Variable "${from}" not found`);
if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`);
@@ -187,6 +187,14 @@ export class SetVariableKeptCommand implements Command {
}
}
/**
* A struct's fields are no properties of anything placed, so the reserved property names are free
* there – as they are for a struct field.
*/
function isValidName(asset: VariableOwnerAsset, name: string): boolean {
return asset.type === 'struct' ? isValidFieldName(name) : isValidVariableName(name);
}
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';