From 61bbb96a9614724fba20d8283e0180e48f66ce53 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 28 Sep 2026 06:04:40 +0200 Subject: [PATCH] Define a struct once as an asset and reuse its shape (#253) 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 --- README.md | 3 +- src/core/commands/assetCommands.ts | 2 + src/core/commands/naming.ts | 4 +- src/core/commands/require.ts | 11 +- src/core/commands/structTypeCommands.ts | 178 ++++++++++ src/core/commands/variableCommands.ts | 14 +- src/core/graph/nested.ts | 11 + src/core/graph/nodes/data/ExpandStruct.ts | 3 +- src/core/graph/nodes/data/PackStruct.ts | 3 +- src/core/model/Asset.ts | 18 ++ src/core/model/AssetTypes.ts | 5 + src/core/model/structTypes.ts | 374 ++++++++++++++++++++++ src/core/properties/PropertySchema.ts | 8 +- src/core/properties/deviation.ts | 2 +- src/core/store/serialize.ts | 20 +- src/core/util/recorder.ts | 41 +++ src/editor/actions.ts | 18 +- src/editor/inspector/assetSection.ts | 103 ++++-- src/editor/inspector/nodeSection.ts | 38 ++- src/editor/inspector/portSection.ts | 14 + src/editor/inspector/variableRows.ts | 60 +++- src/editor/panels/assetGroups.ts | 1 + src/editor/panels/assetIcons.ts | 2 + src/editor/panels/icons/struct.svg | 8 + src/editor/variables/VariablesEditor.ts | 6 +- tests/core/struct-types.test.ts | 356 ++++++++++++++++++++ tools/e2e/04-assets.mjs | 4 +- tools/e2e/54-struct-types.mjs | 280 ++++++++++++++++ tools/e2e/index.mjs | 2 + 29 files changed, 1523 insertions(+), 66 deletions(-) create mode 100644 src/core/commands/structTypeCommands.ts create mode 100644 src/core/model/structTypes.ts create mode 100644 src/core/util/recorder.ts create mode 100644 src/editor/panels/icons/struct.svg create mode 100644 tests/core/struct-types.test.ts create mode 100644 tools/e2e/54-struct-types.mjs diff --git a/README.md b/README.md index c919f96c..6dfcaa2d 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1486 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1500 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs @@ -64,6 +64,7 @@ CI. Details, the GPU requirement and the detached runner are in | [Scenes](https://git.nockal.com/meleki/game-maker/wiki/Scenes) | The edited scene, the start scene, and switching scenes from a graph | | [Scene editing](https://git.nockal.com/meleki/game-maker/wiki/Scene-editing) | Placing, z-order, hierarchy, the editor offset | | [Behaviors](https://git.nockal.com/meleki/game-maker/wiki/Behaviors) | Node graphs: variables, the node set, structs and lists, loops, nesting, editing | +| [Structs](https://git.nockal.com/meleki/game-maker/wiki/Structs) | Struct assets: a named shape that struct variables, fields and `Pack`/`Expand` follow | | [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Shapes (box, circle, capsule, polygon), collision events, blocking movement, one-way colliders and raycasts | | [Physics](https://git.nockal.com/meleki/game-maker/wiki/Physics) | Rigidbodies, the collider material, gravity, the fixed step | | [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, nine-slice, the filter, upload limits | diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 77837b14..55690ff8 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -49,6 +49,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() }; case 'variables': return { ...base, type: 'variables', variables: [] }; + case 'struct': + return { ...base, type: 'struct', variables: [] }; case 'collider': return { ...base, type: 'collider' }; case 'rigidbody': diff --git a/src/core/commands/naming.ts b/src/core/commands/naming.ts index a0c9f699..9042e6fb 100644 --- a/src/core/commands/naming.ts +++ b/src/core/commands/naming.ts @@ -1,4 +1,4 @@ -import type { BehaviorAsset, VariablesAsset } from '../model/Asset'; +import type { VariableOwnerAsset } from '../model/Asset'; import type { Project } from '../model/Project'; function firstFree(taken: ReadonlySet, candidate: (i: number) => string, from: number): string { @@ -28,7 +28,7 @@ export function nextNumberedName(taken: readonly string[], base: string): string } /** "var1", "var2", … – the first variable name not yet used by the behavior. */ -export function nextVariableName(asset: BehaviorAsset | VariablesAsset, base = 'var'): string { +export function nextVariableName(asset: VariableOwnerAsset, base = 'var'): string { return firstFree(new Set(asset.variables.map((v) => v.key)), (i) => `${base}${i}`, 1); } diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index a1d0fa4a..07fdb3df 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -9,7 +9,7 @@ import type { StateMachineAsset, TilemapAsset, TilemapLayer, - VariablesAsset, + VariableOwnerAsset, VectorArtAsset, VectorShape, } from '../model/Asset'; @@ -84,10 +84,13 @@ export function requireBehavior(project: Project, assetId: string): BehaviorAsse return asset; } -/** An asset that declares variables: a behavior, or a Variables asset shared by the whole run. */ -export function requireVariableOwner(project: Project, assetId: string): BehaviorAsset | VariablesAsset { +/** + * An asset that declares variables: a behavior, a Variables asset shared by the whole run, or a + * Struct asset, whose fields are declared the same way (#253). + */ +export function requireVariableOwner(project: Project, assetId: string): VariableOwnerAsset { const asset = findAsset(project, assetId); - if (asset?.type !== 'behavior' && asset?.type !== 'variables') + if (asset?.type !== 'behavior' && asset?.type !== 'variables' && asset?.type !== 'struct') throw new CommandError(`Variable owner ${assetId} not found`); return asset; } diff --git a/src/core/commands/structTypeCommands.ts b/src/core/commands/structTypeCommands.ts new file mode 100644 index 00000000..b37a07f8 --- /dev/null +++ b/src/core/commands/structTypeCommands.ts @@ -0,0 +1,178 @@ +/** + * Struct assets as types (#253). An edit of a Struct asset reaches every place using the type, so + * those writes are recorded as they happen (`Recorder`) and undone in one go with the edit itself. + */ +import { newId } from '../ids'; +import type { GraphNode } from '../graph/Graph'; +import { STRUCT_TYPE_PARAM } from '../graph/nested'; +import type { StructAsset } from '../model/Asset'; +import { nextAssetName } from './naming'; +import { isStruct, type PropertyValue } from '../model/Properties'; +import type { Project } from '../model/Project'; +import { + containsType, + type FieldRename, + isTypedNode, + setStructType, + structAssetOf, + structAt, + syncStructTypes, +} from '../model/structTypes'; +import { + coerceValue, + type PropertyDef, + type StructDef, + type StructFieldDef, + variableDef, +} from '../properties/PropertySchema'; +import { valueAt } from '../properties/valuePath'; +import { removeById } from '../util/list'; +import { Recorder } from '../util/recorder'; +import { type Command, CommandError } from './Command'; +import { requireNode, requireVariableOwner } from './require'; + +/** + * Runs an edit of a Struct asset's fields and then carries it to every place of that type. A + * `rename` moves the values over first; without it a renamed field would be dropped and added back + * at its default. + */ +export class WithStructTypes implements Command { + readonly label: string; + private rec = new Recorder(); + + constructor( + private readonly inner: Command, + private readonly rename?: FieldRename, + ) { + this.label = inner.label; + } + + execute(project: Project): void { + this.inner.execute(project); + this.rec = new Recorder(); + syncStructTypes(project, this.rec, this.rename); + } + + undo(project: Project): void { + this.rec.revert(); + this.inner.undo(project); + } +} + +/** What takes on a type: a struct variable or a struct field inside one, or a `Pack`/`Expand` node. */ +export type StructTypeTarget = + | { kind: 'variable'; ownerId: string; key: string; path: readonly string[] } + | { kind: 'node'; assetId: string; nodeId: string }; + +function requireStructVariable(project: Project, ownerId: string, key: string): StructDef { + const def = requireVariableOwner(project, ownerId).variables.find((v) => v.key === key); + if (def?.kind !== 'struct') throw new CommandError(`Variable "${key}" is no struct`); + return def; +} + +function requireTypedNode(project: Project, assetId: string, nodeId: string): GraphNode { + const node = requireNode(project, assetId, nodeId); + if (!isTypedNode(node)) throw new CommandError('Only Pack and Expand follow a struct type'); + return node; +} + +/** + * Gives a place a type, or with `''` its own fields back – the ones it had from the type, so + * nothing is lost either way. Inside a Struct asset a type holding that asset is refused. + */ +export class SetStructTypeCommand implements Command { + readonly label: string; + private rec = new Recorder(); + + constructor( + private readonly target: StructTypeTarget, + private readonly typeId: string, + ) { + this.label = typeId ? 'Set struct type' : 'Use own fields'; + } + + execute(project: Project): void { + const type = this.typeId ? structAssetOf(project, this.typeId) : undefined; + if (this.typeId && !type) throw new CommandError('That struct is gone'); + this.rec = new Recorder(); + const target = this.target; + if (target.kind === 'node') { + const node = requireTypedNode(project, target.assetId, target.nodeId); + if (this.typeId) this.rec.set(node.params, STRUCT_TYPE_PARAM, this.typeId); + else this.rec.delete(node.params, STRUCT_TYPE_PARAM); + } else { + const owner = requireVariableOwner(project, target.ownerId); + const def = requireStructVariable(project, target.ownerId, target.key); + if (!structAt(def, target.path)) + throw new CommandError(`Field "${target.path.join('.')}" is no struct`); + if (type && owner.type === 'struct' && containsType(project, type.id, owner.id)) + throw new CommandError(`${type.name} holds ${owner.name}, so it cannot be a field of it`); + setStructType(def, target.path, this.typeId, this.rec); + } + syncStructTypes(project, this.rec); + } + + undo(): void { + this.rec.revert(); + } +} + +/** A struct field as a declared variable of a Struct asset, its default taken from `value`. */ +function variableOfField(field: StructFieldDef, value: PropertyValue | undefined): PropertyDef { + const def = variableDef( + field.key, + field.kind, + undefined, + field.kind === 'struct' ? field.fields : undefined, + ); + if (value !== undefined) def.default = coerceValue(def, value); + if (def.kind === 'struct' && field.kind === 'struct' && field.structType) def.structType = field.structType; + return def; +} + +/** + * Turns the fields a struct holds by itself into a Struct asset, and types the struct with it. The + * value it holds now becomes the asset's defaults, so the place reads exactly as before. + */ +export class MakeStructTypeCommand implements Command { + readonly label = 'Make struct type'; + private asset?: StructAsset; + private rec = new Recorder(); + + constructor( + private readonly ownerId: string, + private readonly key: string, + private readonly path: readonly string[], + ) {} + + /** The asset made, once the command ran – for the caller to select it. */ + get assetId(): string { + return this.asset?.id ?? ''; + } + + execute(project: Project): void { + const owner = requireVariableOwner(project, this.ownerId); + const def = requireStructVariable(project, this.ownerId, this.key); + const at = structAt(def, this.path); + if (!at) throw new CommandError(`Field "${this.path.join('.')}" is no struct`); + if (at.structType && structAssetOf(project, at.structType)) + throw new CommandError('This struct has a type already'); + if (!this.asset) { + const value = valueAt(owner.props[def.key] ?? def.default, this.path); + const fields = isStruct(value) ? value : {}; + const variables = at.fields.map((f) => variableOfField(f, fields[f.key])); + const props = Object.fromEntries(variables.map((v) => [v.key, v.default])); + const name = nextAssetName(project, this.path.at(-1) ?? this.key); + this.asset = { id: newId(), type: 'struct', name, props, variables }; + } + project.assets.push(this.asset); + this.rec = new Recorder(); + setStructType(def, this.path, this.asset.id, this.rec); + syncStructTypes(project, this.rec); + } + + undo(project: Project): void { + this.rec.revert(); + if (this.asset) project.assets = removeById(project.assets, this.asset.id); + } +} diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index 94936e18..7f935c38 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -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 _'; diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index c292b7d2..eed70695 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -35,6 +35,17 @@ export const VARIABLES_SETTING: PropertyDef = { assetTypes: ['variables'], }; +export const STRUCT_TYPE_PARAM = 'structType'; + +/** The Struct asset whose fields a `Pack` or `Expand` takes as its ports (#253); empty is by hand. */ +export const STRUCT_TYPE_SETTING: PropertyDef = { + key: STRUCT_TYPE_PARAM, + label: 'Type', + kind: 'asset', + default: '', + assetTypes: ['struct'], +}; + export function variablesSourceOf(params: PropertyBag): string { return textOf(params[VARIABLES_PARAM] ?? ''); } diff --git a/src/core/graph/nodes/data/ExpandStruct.ts b/src/core/graph/nodes/data/ExpandStruct.ts index 2d15178c..4b9c2330 100644 --- a/src/core/graph/nodes/data/ExpandStruct.ts +++ b/src/core/graph/nodes/data/ExpandStruct.ts @@ -1,5 +1,6 @@ import type { NodeDef } from '../../NodeRegistry'; import { asStruct, unplaced, type Value } from '../../values'; +import { STRUCT_TYPE_SETTING } from '../../nested'; import { struct } from '../shared'; /** @@ -16,7 +17,7 @@ export const ExpandStruct: NodeDef = { 'reads as empty.', inputs: [struct('value')], outputs: [], - params: [], + params: [STRUCT_TYPE_SETTING], declaresPorts: 'outputs', // A declared output the struct has no field for is simply absent from the cache, and reads as empty. run: (_ctx, inputs) => { diff --git a/src/core/graph/nodes/data/PackStruct.ts b/src/core/graph/nodes/data/PackStruct.ts index c14423f0..8e4a5a56 100644 --- a/src/core/graph/nodes/data/PackStruct.ts +++ b/src/core/graph/nodes/data/PackStruct.ts @@ -1,6 +1,7 @@ import type { FieldValue } from '../../../model/Properties'; import type { NodeDef } from '../../NodeRegistry'; import { asField } from '../../values'; +import { STRUCT_TYPE_SETTING } from '../../nested'; import { struct } from '../shared'; /** @@ -14,7 +15,7 @@ export const PackStruct: NodeDef = { description: 'Bundles named values into one struct, one input per field.', inputs: [], outputs: [struct('value')], - params: [], + params: [STRUCT_TYPE_SETTING], declaresPorts: 'inputs', run: (_ctx, inputs) => { const fields: Record = {}; diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 32575314..5516ff9c 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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; diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 0100218c..dea697de 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -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 = { text: 'Text', behavior: 'Behavior', variables: 'Variables', + struct: 'Struct', collider: 'Collider', rigidbody: 'Rigidbody', container: 'Container', @@ -57,6 +59,7 @@ export const ASSET_TYPE_LABELS: Record = { * 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 = new Set([ @@ -89,6 +93,7 @@ const NOT_PLACEABLE: ReadonlySet = new Set([ 'font', 'sound', 'variables', + 'struct', ]); export function isPlaceableType(type: AssetType): type is PlaceableAssetType { diff --git a/src/core/model/structTypes.ts b/src/core/model/structTypes.ts new file mode 100644 index 00000000..c829ce1f --- /dev/null +++ b/src/core/model/structTypes.ts @@ -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 = {}; + 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, +): 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 = {}; + 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, + }; +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 6fe0d8e9..ea297e50 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -168,7 +168,8 @@ export type PropertyDef = | { key: string; label: string; kind: 'list'; default: ListValue } /** * `fields` is the declaration; the value carries exactly those keys, which is what lets a struct - * keep its shape while single fields are written (`mergeStruct`). + * keep its shape while single fields are written (`mergeStruct`). With a `structType` the fields + * are a copy of that Struct asset's (#253), kept in step by `syncStructTypes`. */ | { key: string; @@ -176,6 +177,7 @@ export type PropertyDef = kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[]; + structType?: string; } /** An object variable holds a live handle during a run, and nothing at rest (`NO_OBJECT`). */ | ObjectDef; @@ -205,6 +207,7 @@ export interface StructFieldStructDef { key: string; kind: 'struct'; fields: readonly StructFieldDef[]; + structType?: string; } export type StructFieldDef = PrimitiveFieldDef | ListFieldDef | StructFieldStructDef; @@ -513,6 +516,7 @@ const ASSET_SCHEMAS: Record = { ], // Every row is declared on the asset itself – see `schemaForBag`. variables: [], + struct: [], // The states are the asset's graph (see StateMachineAsset), not a property. statemachine: [], // The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it. @@ -694,7 +698,7 @@ export function schemaForObject(project: Project, asset: Asset, object: SceneObj function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema { if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables]; // Nothing but declarations: the starting values live in `props` under the very same keys. - if (asset.type === 'variables') return asset.variables; + if (asset.type === 'variables' || asset.type === 'struct') return asset.variables; if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))]; // Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up. if (asset.type === 'particles') { diff --git a/src/core/properties/deviation.ts b/src/core/properties/deviation.ts index 5edc8b1b..7325f89b 100644 --- a/src/core/properties/deviation.ts +++ b/src/core/properties/deviation.ts @@ -12,7 +12,7 @@ import { deviatesFromAsset, isModified } from './resolve'; */ export function markedAssetSchema(project: Project, asset: Asset): PropertySchema { // Declared variables are drawn by the inspector's own section, not as asset rows. - return asset.type === 'behavior' || asset.type === 'variables' + return asset.type === 'behavior' || asset.type === 'variables' || asset.type === 'struct' ? schemaFor(asset.type) : schemaForAsset(project, asset); } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index c58906d3..ec07f1aa 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -70,6 +70,8 @@ import { newId } from '../ids'; import { SHAPE_FILL_DEFAULT } from '../model/vectorart'; import { sanitizedCurve } from '../model/curve'; import { hasParentCycle } from '../model/walk'; +import { syncStructTypes } from '../model/structTypes'; +import { Recorder } from '../util/recorder'; import { DECLARED_PORT_KINDS, FIELD_KINDS, @@ -114,7 +116,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue; rawProps.set(a.id, asRecord(a.props)); if (a.type === 'sprite' || a.type === 'particles') wearers.set(a.id, a.type); - if (a.type === 'behavior' || a.type === 'variables') { + if (a.type === 'behavior' || a.type === 'variables' || a.type === 'struct') { const vars = normalizeVariables(a.variables); variables.set(a.id, vars); // The full schema, not just the common rows: this is what pass 2 coerces `props` against, and @@ -191,6 +193,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { if (readSharedScope(a.scope) === 'scene') shared.scope = 'scene'; else if (a.persistent === true) shared.persistent = true; assets.push(shared); + } else if (type === 'struct') { + assets.push({ id, type, name, props, variables: variables.get(id) ?? [] }); } else if (type === 'statemachine') { const machine: StateMachineAsset = { id, @@ -271,6 +275,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { gameScale: gameScaleOf(r), sceneVisibility: sceneVisibilityOf(r), }; + // A hand-edited file may have copies that no longer match their Struct asset. + syncStructTypes(project, new Recorder()); return project; } @@ -386,6 +392,7 @@ function normalizeVariables(raw: unknown): PropertyDef[] { const fields = normalizeStructFields(v.fields); const def = variableDef(key, kind, undefined, fields); def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue); + if (def.kind === 'struct') readStructType(def, v.structType); defs.push(def); } return defs; @@ -410,10 +417,19 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] { function readField(key: string, f: Record): StructFieldDef | null { const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value)); if (f.kind === 'list') return { key, kind: 'list' }; - if (f.kind === 'struct') return { key, kind: 'struct', fields: normalizeStructFields(f.fields) }; + if (f.kind === 'struct') { + const field: StructFieldDef = { key, kind: 'struct', fields: normalizeStructFields(f.fields) }; + readStructType(field, f.structType); + return field; + } return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null; } +/** A dangling id is kept, as a curve's is: the struct keeps the fields it last had (#253). */ +function readStructType(def: { structType?: string }, raw: unknown): void { + if (typeof raw === 'string' && raw !== '') def.structType = raw; +} + function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolean): DeclaredPort[] { const ports: DeclaredPort[] = []; const seen = new Set(fixed); diff --git a/src/core/util/recorder.ts b/src/core/util/recorder.ts new file mode 100644 index 00000000..09cefad3 --- /dev/null +++ b/src/core/util/recorder.ts @@ -0,0 +1,41 @@ +/** + * Assignments that can be taken back, last one first. For an edit whose reach is not known in + * advance – a Struct asset's change lands wherever its type is used (#253) – so undo does not have + * to know either. Only plain properties are written, so everything holding a reference to the + * changed objects keeps it. + */ +export class Recorder { + private readonly steps: (() => void)[] = []; + + /** Writing what is already there is no step at all. */ + set(target: T, key: K, value: T[K]): void { + const had = Object.hasOwn(target, key); + const before = target[key]; + if (had && same(before, value)) return; + target[key] = value; + this.steps.push(() => { + if (had) target[key] = before; + else delete target[key]; + }); + } + + delete(target: T, key: K): void { + if (!Object.hasOwn(target, key)) return; + const before = target[key]; + delete target[key]; + this.steps.push(() => (target[key] = before)); + } + + /** How many writes are held – a pass that leaves it alone changed nothing. */ + get count(): number { + return this.steps.length; + } + + revert(): void { + for (let step = this.steps.pop(); step; step = this.steps.pop()) step(); + } +} + +function same(a: unknown, b: unknown): boolean { + return a === b || JSON.stringify(a) === JSON.stringify(b); +} diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 4c7e2bad..a8be2945 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -16,6 +16,7 @@ import { RemoveNodeCommand } from '../core/commands/graphCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; import type { AssetType } from '../core/model/AssetTypes'; import { curveUsages } from '../core/model/curve'; +import { structTypeUsages } from '../core/model/structTypes'; import { generatesPictures } from '../core/model/generated'; import { isTemplateContent } from '../core/model/instance'; import type { Command } from '../core/commands/Command'; @@ -133,18 +134,21 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void { const animations = picture ? animationsUsing(state.project, ids).length : 0; const tiles = picture ? tilesUsing(state.project, ids).length : 0; const eased = asset.type === 'curve' ? curveUsages(state.project, assetId) : { keys: 0, nodes: 0 }; + const typed = asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, nodes: 0 }; const detail = asset.type === 'scene' ? `The scene and its ${asset.objects.length} object(s) are removed.` : eased.keys > 0 || eased.nodes > 0 ? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.` - : references > 0 || animations > 0 || tiles > 0 - ? wornDetail(references, animations, tiles, asset.type) - : usages === 0 - ? 'This cannot be undone with Ctrl+Z after reload.' - : asset.type === 'container' - ? `${usages} placed container(s) are removed together with their content.` - : `${usages} placed instance(s) are removed as well (scenes and container templates).`; + : typed.places > 0 || typed.nodes > 0 + ? `${typed.places} struct(s) and ${typed.nodes} Pack/Expand node(s) use it and keep its fields as their own.` + : references > 0 || animations > 0 || tiles > 0 + ? wornDetail(references, animations, tiles, asset.type) + : usages === 0 + ? 'This cannot be undone with Ctrl+Z after reload.' + : asset.type === 'container' + ? `${usages} placed container(s) are removed together with their content.` + : `${usages} placed instance(s) are removed as well (scenes and container templates).`; new Dialog(`Delete "${asset.name}"?`, detail, [ { label: 'Cancel' }, { label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) }, diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 35ec8a93..aa31c448 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -21,11 +21,18 @@ import { import type { Command } from '../../core/commands/Command'; import { type Asset, - type BehaviorAsset, isKeptVariable, + isVariableOwner, type StateMachineAsset, + type VariableOwnerAsset, type VariablesAsset, } from '../../core/model/Asset'; +import { + MakeStructTypeCommand, + SetStructTypeCommand, + WithStructTypes, +} from '../../core/commands/structTypeCommands'; +import { containsType, type FieldRename, structAssetOf } from '../../core/model/structTypes'; import { findAsset, type Project } from '../../core/model/Project'; import type { PropertyBag, PropertyValue } from '../../core/model/Properties'; import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope'; @@ -38,7 +45,7 @@ import { } from '../../core/properties/PropertySchema'; import { markedAssetSchema } from '../../core/properties/deviation'; import { isModified, resolveAssetProps } from '../../core/properties/resolve'; -import { Button, Dropdown, HStack, Label, Menu, Theme } from '../../ui'; +import { Button, Dropdown, HStack, Label, Menu, type MenuEntry, Theme } from '../../ui'; import { FontPreview } from '../font/FontPreview'; import { clearSaved, hasSaved } from '../saves'; import { SoundPreview } from '../sound/SoundPreview'; @@ -47,7 +54,7 @@ import { aspectActions, sizeCommand } from './aspectAction'; import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory'; import { addNameField, addNote, addTitle, type InspectorHost } from './host'; import { buildShapeListSection, shapeListSignature } from './shapeSection'; -import { buildVariable, kindMenu, type VariableEditing } from './variableRows'; +import { buildVariable, kindMenu, type StructTyping, type VariableEditing } from './variableRows'; import { listSource, previewingHandlers, structFields } from './previewHandlers'; import { assetValue } from './valueSpecs'; @@ -73,13 +80,27 @@ export function assetSignature(project: Project, assetId: string): string { const shapes = asset.type === 'vectorart' ? `|${shapeListSignature(asset)}` : ''; const values = resolveAssetProps(project, asset); const rows = schemaForAsset(project, asset).filter((def) => isRowShown(def, values)); - return rows.map(defSignature).join(',') + machine + kept + keeps + shapes; + // A Type row shows the name of its Struct asset, or "Own fields" once that asset is gone (#253). + const types = `|types:${typeNames(project, rows)}`; + return rows.map(defSignature).join(',') + machine + kept + keeps + shapes + types; +} + +function typeNames(project: Project, rows: readonly (PropertyDef | StructFieldDef)[]): string { + return rows + .flatMap((def): string[] => + def.kind === 'struct' + ? [structAssetOf(project, def.structType)?.name ?? '', typeNames(project, def.fields)] + : [], + ) + .join(','); } export function defSignature(def: PropertyDef | StructFieldDef): string { // Only a struct has rows of its own, at any depth; a list's items rebuild themselves (#252). const fields = def.kind === 'struct' ? def.fields.map(defSignature).join('+') : ''; - return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`; + // A type decides which of those rows may be changed, and names itself in a row (#253). + const type = def.kind === 'struct' && def.structType ? `@${def.structType}` : ''; + return `${def.key}:${def.kind}${type}${fields ? `(${fields})` : ''}`; } /** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */ @@ -95,7 +116,7 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void { }); buildProps(host, asset, markedAssetSchema(host.state.project, asset)); - if (asset.type === 'behavior' || asset.type === 'variables') buildVariables(host, asset); + if (isVariableOwner(asset)) buildVariables(host, asset); if (asset.type === 'variables') { buildVariablesScope(host, asset); buildPersistence(host, asset); @@ -196,21 +217,21 @@ function fieldsAt(fields: readonly StructFieldDef[], path: readonly string[]): r /** * Declared variables, each with its declaration editable – plus an "add" menu, and in the inspector - * the way to the same rows in a Variables tab (#252). + * the way to the same rows in a Variables tab (#252). A Struct asset's fields are declared the same + * way (#253); every change to them is carried on to the places of that type. */ -export function buildVariables( - host: InspectorHost, - asset: BehaviorAsset | VariablesAsset, - inTab = false, -): void { +export function buildVariables(host: InspectorHost, asset: VariableOwnerAsset, inTab = false): void { + const isType = asset.type === 'struct'; const header = new HStack({ gap: Theme.gap, align: 'center' }); - header.add(new Label('VARIABLES', { style: 'title' }), 1); - const add = new Dropdown('+ Variable', { variant: 'ghost' }); + header.add(new Label(isType ? 'FIELDS' : 'VARIABLES', { style: 'title' }), 1); + const add = new Dropdown(isType ? '+ Field' : '+ Variable', { variant: 'ghost' }); + const run = (command: Command, rename?: FieldRename): void => + void host.state.execute(isType ? new WithStructTypes(command, rename) : command); add.onClick = () => { const items = kindMenu(VARIABLE_KINDS, (kind) => { const current = findAsset(host.state.project, asset.id); - if (current?.type === 'behavior' || current?.type === 'variables') - host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind)); + if (isVariableOwner(current)) + run(new AddVariableCommand(asset.id, nextVariableName(current, isType ? 'field' : 'var'), kind)); }); new Menu(items).openBelow(add); }; @@ -218,15 +239,19 @@ export function buildVariables( if (!inTab) header.add(openVariablesButton(host, { kind: 'asset', assetId: asset.id })); host.body.add(header); - if (asset.variables.length === 0) addNote(host, 'No variables yet.'); + if (asset.variables.length === 0) addNote(host, isType ? 'No fields yet.' : 'No variables yet.'); const values = shownValues(host, asset); - const run = (command: Command): void => void host.state.execute(command); + const renamed = (path: readonly string[], from: string, to: string): FieldRename | undefined => + isType ? { structType: asset.id, path, from, to } : undefined; for (const def of asset.variables) { const edit: VariableEditing = { - rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next)), + rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next), renamed([], def.key, next)), remove: () => run(new RemoveVariableCommand(asset.id, def.key)), renameField: (path, field, next) => - run(new RenameStructFieldCommand(asset.id, def.key, field.key, next, path)), + run( + new RenameStructFieldCommand(asset.id, def.key, field.key, next, path), + renamed([def.key, ...path], field.key, next), + ), removeField: (path, field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key, path)), addField: (path, kind) => run( @@ -246,13 +271,49 @@ export function buildVariables( } : undefined; const value = values[def.key] ?? def.default; + const typing = structTyping(host, asset, def.key); host.fields.set( def.key, - buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit, keep }), + buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit, typing, keep }), ); } } +/** + * The type picker of a struct variable and of its struct fields (#253): own fields, a Struct asset, + * or a new Struct asset made from the fields it holds now. Inside a Struct asset the types that + * hold that asset are not offered – the default of such a field would never end. + */ +function structTyping(host: InspectorHost, owner: VariableOwnerAsset, key: string): StructTyping { + const project = () => host.state.project; + const run = (command: Command): void => void host.state.execute(command); + return { + typeName: (structType) => structAssetOf(project(), structType)?.name ?? null, + menu: (path, structType) => { + const current = structAssetOf(project(), structType); + const target = { kind: 'variable', ownerId: owner.id, key, path } as const; + const types = project().assets.filter( + (a) => a.type === 'struct' && !(owner.type === 'struct' && containsType(project(), a.id, owner.id)), + ); + const entries: MenuEntry[] = [ + { label: 'Own fields', current: !current, onSelect: () => run(new SetStructTypeCommand(target, '')) }, + ...types.map((type) => ({ + label: type.name, + current: type.id === current?.id, + onSelect: () => run(new SetStructTypeCommand(target, type.id)), + })), + { separator: true }, + { + label: 'Make struct type', + disabled: current !== undefined, + onSelect: () => run(new MakeStructTypeCommand(owner.id, key, path)), + }, + ]; + return entries; + }, + }; +} + /** Opens the variables of `owner` in a Variables tab of their own, or brings up the one they have. */ export function openVariablesButton(host: InspectorHost, owner: VariablesOwner): Button { return new Button('Open', { diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index 2e0e6b09..1f0c8ade 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -18,13 +18,17 @@ import { resolveDef, STATE_TRANSITION, stateName, + STRUCT_TYPE_PARAM, + STRUCT_TYPE_SETTING, TRANSITION_PARAM, variablesSourceOf, } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; import { findGraphAsset, type Project } from '../../core/model/Project'; -import { textOf } from '../../core/model/Properties'; +import { type PropertyValue, textOf } from '../../core/model/Properties'; +import { isTypedNode, nodeStructType, structAssetOf } from '../../core/model/structTypes'; +import { SetStructTypeCommand } from '../../core/commands/structTypeCommands'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Label } from '../../ui'; import { createFieldRow, type FieldHandlers } from './fieldFactory'; @@ -63,7 +67,8 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string) .join(',') : `${textOf(node.params[TRANSITION_PARAM] ?? '')}:${textOf(node.params[MACHINE_PORT] ?? '')}`; // The source names which declarations the port menu offers, so switching it has to rebuild. - const source = variablesSourceOf(node.params); + const typeName = structAssetOf(project, nodeStructType(node))?.name ?? ''; + const source = `${variablesSourceOf(node.params)}:${nodeStructType(node)}:${typeName}`; const choices = variableChoices(project, assetId, node) .map((v) => v.key) .join(','); @@ -94,25 +99,34 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s addNote(host, def.description); const problem = callProblem(node, graphLookup(host.state.project), assetId); if (problem) addNote(host, problem, 'warning'); - const addSetting = (param: PropertyDef) => { - const row = createFieldRow( - host, - param, - node.params[param.key] ?? param.default, - paramHandlers(host, assetId, nodeId, param), - ); + const addSetting = (param: PropertyDef, handlers = paramHandlers(host, assetId, nodeId, param)) => { + const row = createFieldRow(host, param, node.params[param.key] ?? param.default, handlers); host.fields.set(`param:${param.key}`, row); host.body.add(row.row); }; // Both draw their settings inside the port section: a setting decides what the menu offers. const ownSettings = declaresPropertyPorts(node.type) || declaresVariablePorts(node.type); + // The type of a Pack/Expand decides its fields (#253), so it stands above them – and picking one + // rewrites the ports, which a plain param edit would not. + const typed = isTypedNode(node); + if (typed) { + const pick = (value: PropertyValue) => { + const target = { kind: 'node', assetId, nodeId } as const; + host.state.execute(new SetStructTypeCommand(target, textOf(value))); + }; + addSetting(STRUCT_TYPE_SETTING, { onInput: () => {}, onChange: pick }); + } if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, addSetting); else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting); - else if (def.declaresPorts) buildPortSection(host, assetId, node, variablesOf(asset)); + else if (def.declaresPorts) { + const typeName = structAssetOf(host.state.project, nodeStructType(node))?.name ?? null; + buildPortSection(host, assetId, node, variablesOf(asset), typeName); + } - if (!ownSettings && def.params.length > 0) { + const settings = def.params.filter((param) => !(typed && param.key === STRUCT_TYPE_PARAM)); + if (!ownSettings && settings.length > 0) { addTitle(host, 'SETTINGS'); - for (const param of def.params) { + for (const param of settings) { if (param.key === TRANSITION_PARAM && node.type === STATE_TRANSITION) buildTransitionPicker(host, assetId, node, asset.graph); else addSetting(param); diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index 3bf93395..c0cdc31b 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -202,6 +202,7 @@ export function buildPortSection( assetId: string, node: GraphNode, variables: readonly PropertyDef[] = [], + typeName: string | null = null, ): void { const onStart = node.type === BEHAVIOR_START; const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field'; @@ -209,6 +210,19 @@ export function buildPortSection( const header = new HStack({ gap: Theme.gap, align: 'center' }); header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1); + // With a Struct asset as its type the fields are that asset's (#253): changed there, not here. + if (typeName !== null) { + host.body.add(header); + addNote(host, `The fields of ${typeName}. Change them there.`, 'small'); + for (const port of node.ports ?? []) { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + row.add(markLabel(port.kind)); + row.add(new Label(port.key, { style: 'muted', width: NAME_WIDTH, padding: Theme.fieldPadding }), 1); + host.body.add(row); + } + return; + } if (noun === 'Field') addShapePicker(host, assetId, node, variables, header); const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' }); add.onClick = () => { diff --git a/src/editor/inspector/variableRows.ts b/src/editor/inspector/variableRows.ts index 35fcf85a..765c239c 100644 --- a/src/editor/inspector/variableRows.ts +++ b/src/editor/inspector/variableRows.ts @@ -8,6 +8,7 @@ import { Icon, Label, Menu, + type MenuEntry, type MenuItem, Spacer, TextField, @@ -56,8 +57,19 @@ export interface VariableKeep { set: (on: boolean) => void; } +/** + * The type of a struct (#253), offered where its declaration is edited. `path` names the struct + * from the variable down, as for `VariableEditing`. + */ +export interface StructTyping { + /** The Struct asset the struct follows, or null while it holds its own fields – a deleted type too. */ + typeName: (structType: string | undefined) => string | null; + menu: (path: readonly string[], structType: string | undefined) => MenuEntry[]; +} + export interface VariableOptions { edit?: VariableEditing; + typing?: StructTyping; keep?: VariableKeep; /** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */ actions?: RowActions; @@ -74,14 +86,16 @@ export function buildVariable( handlers: FieldHandlers, options: VariableOptions = {}, ): FieldRow { - const { edit, keep, actions } = options; + const { edit, typing, keep, actions } = options; const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next))); const head = gridRow(0, markLabel(def.kind), name); let setValue: (next: PropertyValue) => void = () => {}; let below: Widget | undefined; + // A struct with a type has the type's fields: they are changed on the Struct asset, not here. + const typed = def.kind === 'struct' ? (typing?.typeName(def.structType) ?? null) : null; if (def.kind === 'struct') { head.add(new Spacer(), 1); - if (edit) head.add(addFieldMenu(edit, [])); + if (edit && typed === null) head.add(addFieldMenu(edit, [])); } else { const field = createField(host, def, value, handlers); setValue = addValue(head, field); @@ -94,8 +108,18 @@ export function buildVariable( if (below) host.body.add(indentedBy(below, 1)); if (def.kind === 'struct') { + if (typing) host.body.add(typeRow(1, typed, () => typing.menu([], def.structType))); if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small'); - setValue = structFieldRows(host, def.fields, [], value, handlers, edit); + const own = typed === null; + setValue = structFieldRows( + host, + def.fields, + [], + value, + handlers, + own ? edit : undefined, + own ? typing : undefined, + ); } return { @@ -132,17 +156,33 @@ function structFieldRows( value: PropertyValue, handlers: FieldHandlers, edit: VariableEditing | undefined, + typing: StructTyping | undefined, ): (next: PropertyValue) => void { const setters = new Map void>(); for (const field of fields) { const row = fieldRow(field, path, edit); const item = structFieldValue(value, field); if (field.kind === 'struct') { + const inner = [...path, field.key]; + const typed = typing?.typeName(field.structType) ?? null; row.add(new Spacer(), 1); - if (edit) row.add(addFieldMenu(edit, [...path, field.key])); + if (edit && typed === null) row.add(addFieldMenu(edit, inner)); if (edit) row.add(removeButton(() => edit.removeField(path, field))); host.body.add(row); - setters.set(field.key, structFieldRows(host, field.fields, [...path, field.key], item, handlers, edit)); + if (typing) host.body.add(typeRow(inner.length + 1, typed, () => typing.menu(inner, field.structType))); + const own = typed === null; + setters.set( + field.key, + structFieldRows( + host, + field.fields, + inner, + item, + handlers, + own ? edit : undefined, + own ? typing : undefined, + ), + ); continue; } const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY; @@ -163,6 +203,16 @@ function fieldRow(field: StructFieldDef, path: readonly string[], edit: Variable return gridRow(depth, markLabel(field.kind), name); } +/** Which Struct asset a struct follows, as a row of its own under the struct's head. */ +function typeRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack { + const picker = new Dropdown(typeName ?? 'Own fields', { variant: 'ghost' }); + picker.onClick = () => new Menu(menu()).openBelow(picker); + const name = new Label('Type', { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding }); + const row = gridRow(depth, new Spacer(MARK_WIDTH), name); + row.add(picker, 1); + return row; +} + /** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */ function gridRow(depth: number, mark: Widget, name: Widget): HStack { const row = new HStack({ gap: Theme.gap, align: 'center' }); diff --git a/src/editor/panels/assetGroups.ts b/src/editor/panels/assetGroups.ts index 963d71f0..a62ba4d2 100644 --- a/src/editor/panels/assetGroups.ts +++ b/src/editor/panels/assetGroups.ts @@ -12,6 +12,7 @@ export const ASSET_GROUP_LABELS: Record = { text: 'Texts', behavior: 'Behaviors', variables: 'Variables', + struct: 'Structs', collider: 'Colliders', rigidbody: 'Rigidbodies', container: 'Containers', diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index 91392974..1ffe196d 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -23,6 +23,7 @@ import shader from './icons/shader.svg?raw'; import sound from './icons/sound.svg?raw'; import sprite from './icons/sprite.svg?raw'; import statemachine from './icons/statemachine.svg?raw'; +import struct from './icons/struct.svg?raw'; import text from './icons/text.svg?raw'; import texture from './icons/texture.svg?raw'; import tile from './icons/tile.svg?raw'; @@ -48,6 +49,7 @@ export const ASSET_ICON_SVG: Record = { sound, sprite, statemachine, + struct, text, texture, tile, diff --git a/src/editor/panels/icons/struct.svg b/src/editor/panels/icons/struct.svg new file mode 100644 index 00000000..49b8a7ec --- /dev/null +++ b/src/editor/panels/icons/struct.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/editor/variables/VariablesEditor.ts b/src/editor/variables/VariablesEditor.ts index b481a456..14511a3c 100644 --- a/src/editor/variables/VariablesEditor.ts +++ b/src/editor/variables/VariablesEditor.ts @@ -4,6 +4,7 @@ * One editor for every such tab, pointed at the target of the one showing, like the asset editors. */ import type { Container } from 'pixi.js'; +import { isVariableOwner } from '../../core/model/Asset'; import { findObject } from '../../core/model/hierarchy'; import { findAsset } from '../../core/model/Project'; import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui'; @@ -20,7 +21,7 @@ function ownerOf(state: EditorState, target: Extract behavior.variables.find((v) => v.key === 'drop') as StructDef; + +/** An edit of the type's fields, carried to its users the way the inspector does it. */ +const onType = ( + f: SceneFixture, + command: Command, + rename?: ConstructorParameters[1], +) => f.stack.execute(new WithStructTypes(command, rename)); + +describe('a struct typed by a Struct asset', () => { + it('takes on its fields and starts at its defaults', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = dropper(f, loot); + expect(drop(chest)).toMatchObject({ + structType: loot.id, + fields: [ + { key: 'Name', kind: 'text' }, + { key: 'Amount', kind: 'number' }, + ], + }); + expect(chest.props.drop).toEqual({ Name: 'Coin', Amount: 1 }); + expect(drop(chest).default).toEqual({ Name: 'Coin', Amount: 1 }); + }); + + it('gets a field added to the type, at the default the type declares', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = dropper(f, loot); + onType(f, new AddVariableCommand(loot.id, 'Rare', 'boolean')); + expect(drop(chest).fields.map((x) => x.key)).toEqual(['Name', 'Amount', 'Rare']); + expect(chest.props.drop).toEqual({ Name: 'Coin', Amount: 1, Rare: false }); + }); + + it('loses a field removed from the type, and gets it back with one undo', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = dropper(f, loot); + f.stack.execute(new SetAssetPropertyCommand(chest.id, 'drop', { Name: 'Gem', Amount: 7 })); + onType(f, new RemoveVariableCommand(loot.id, 'Amount')); + expect(chest.props.drop).toEqual({ Name: 'Gem' }); + expect(drop(chest).fields.map((x) => x.key)).toEqual(['Name']); + f.stack.undo(); + expect(chest.props.drop).toEqual({ Name: 'Gem', Amount: 7 }); + expect(drop(chest).fields.map((x) => x.key)).toEqual(['Name', 'Amount']); + }); + + it('keeps every value of a renamed field: the own one, the default and the override', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = dropper(f, loot); + f.stack.execute(new SetAssetPropertyCommand(chest.id, 'drop', { Name: 'Gem', Amount: 7 })); + const placed = place(f, chest.id, { x: 0, y: 0 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, placed, 'drop', { Name: 'Ruby', Amount: 3 })); + onType(f, new RenameVariableCommand(loot.id, 'Amount', 'Count'), { + structType: loot.id, + path: [], + from: 'Amount', + to: 'Count', + }); + expect(chest.props.drop).toEqual({ Name: 'Gem', Count: 7 }); + expect(f.object(placed).overrides.drop).toEqual({ Name: 'Ruby', Count: 3 }); + expect(loot.props).toEqual({ Name: 'Coin', Count: 1 }); + f.stack.undo(); + expect(chest.props.drop).toEqual({ Name: 'Gem', Amount: 7 }); + expect(f.object(placed).overrides.drop).toEqual({ Name: 'Ruby', Amount: 3 }); + }); + + it('keeps the values that fit when a struct is given a type', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = makeBehavior(f, 'Chest'); + f.stack.execute(new AddVariableCommand(chest.id, 'drop', 'struct')); + f.stack.execute(new AddStructFieldCommand(chest.id, 'drop', { key: 'Name', kind: 'text' })); + f.stack.execute(new AddStructFieldCommand(chest.id, 'drop', { key: 'Old', kind: 'number' })); + f.stack.execute(new SetAssetPropertyCommand(chest.id, 'drop', { Name: 'Gem', Old: 4 })); + f.stack.execute( + new SetStructTypeCommand({ kind: 'variable', ownerId: chest.id, key: 'drop', path: [] }, loot.id), + ); + expect(chest.props.drop).toEqual({ Name: 'Gem', Amount: 1 }); + f.stack.undo(); + expect(chest.props.drop).toEqual({ Name: 'Gem', Old: 4 }); + expect(drop(chest).fields.map((x) => x.key)).toEqual(['Name', 'Old']); + expect(drop(chest).structType).toBeUndefined(); + }); + + it('types a struct field deep inside a variable, and renames reach it there', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = makeBehavior(f, 'Chest'); + f.stack.execute(new AddVariableCommand(chest.id, 'room', 'struct')); + f.stack.execute( + new AddStructFieldCommand(chest.id, 'room', { key: 'prize', kind: 'struct', fields: [] }), + ); + f.stack.execute( + new SetStructTypeCommand( + { kind: 'variable', ownerId: chest.id, key: 'room', path: ['prize'] }, + loot.id, + ), + ); + expect(chest.props.room).toEqual({ prize: { Name: 'Coin', Amount: 1 } }); + onType(f, new RenameVariableCommand(loot.id, 'Name', 'Title'), { + structType: loot.id, + path: [], + from: 'Name', + to: 'Title', + }); + expect(chest.props.room).toEqual({ prize: { Title: 'Coin', Amount: 1 } }); + }); + + it('carries a type inside a type: Chest holds Loot, a rename in Loot reaches a Chest value', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chestType = new CreateAssetCommand('struct', 'ChestType'); + f.stack.execute(chestType); + const chestId = chestType.asset.id; + f.stack.execute(new AddVariableCommand(chestId, 'inside', 'struct')); + f.stack.execute( + new SetStructTypeCommand({ kind: 'variable', ownerId: chestId, key: 'inside', path: [] }, loot.id), + ); + const user = makeBehavior(f, 'User'); + f.stack.execute(new AddVariableCommand(user.id, 'box', 'struct')); + f.stack.execute( + new SetStructTypeCommand({ kind: 'variable', ownerId: user.id, key: 'box', path: [] }, chestId), + ); + expect(user.props.box).toEqual({ inside: { Name: 'Coin', Amount: 1 } }); + f.stack.execute(new SetAssetPropertyCommand(user.id, 'box', { inside: { Name: 'Gem', Amount: 9 } })); + onType(f, new RenameVariableCommand(loot.id, 'Amount', 'Count'), { + structType: loot.id, + path: [], + from: 'Amount', + to: 'Count', + }); + expect(user.props.box).toEqual({ inside: { Name: 'Gem', Count: 9 } }); + onType(f, new AddVariableCommand(loot.id, 'Rare', 'boolean')); + expect(user.props.box).toEqual({ inside: { Name: 'Gem', Count: 9, Rare: false } }); + }); + + it('renames a field of a struct field inside the type', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + onType(f, new AddVariableCommand(loot.id, 'pool', 'struct')); + onType(f, new AddStructFieldCommand(loot.id, 'pool', { key: 'min', kind: 'number' })); + const chest = dropper(f, loot); + f.stack.execute( + new SetAssetPropertyCommand(chest.id, 'drop', { Name: 'Gem', Amount: 2, pool: { min: 5 } }), + ); + onType(f, new RenameStructFieldCommand(loot.id, 'pool', 'min', 'low'), { + structType: loot.id, + path: ['pool'], + from: 'min', + to: 'low', + }); + expect(chest.props.drop).toEqual({ Name: 'Gem', Amount: 2, pool: { low: 5 } }); + }); + + it('refuses a type that would hold itself, directly or through another type', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + f.stack.execute(new AddVariableCommand(loot.id, 'self', 'struct')); + expect(() => + f.stack.execute( + new SetStructTypeCommand({ kind: 'variable', ownerId: loot.id, key: 'self', path: [] }, loot.id), + ), + ).toThrow(/cannot be a field/); + const bag = new CreateAssetCommand('struct', 'Bag'); + f.stack.execute(bag); + f.stack.execute(new AddVariableCommand(bag.asset.id, 'loot', 'struct')); + f.stack.execute( + new SetStructTypeCommand({ kind: 'variable', ownerId: bag.asset.id, key: 'loot', path: [] }, loot.id), + ); + expect(() => + f.stack.execute( + new SetStructTypeCommand({ kind: 'variable', ownerId: loot.id, key: 'self', path: [] }, bag.asset.id), + ), + ).toThrow(/cannot be a field/); + }); + + it('keeps the last fields as its own when the type is deleted', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = dropper(f, loot); + expect(structTypeUsages(f.project, loot.id)).toEqual({ places: 1, nodes: 0 }); + f.stack.execute(new DeleteAssetCommand(loot.id)); + expect(drop(chest).fields.map((x) => x.key)).toEqual(['Name', 'Amount']); + expect(chest.props.drop).toEqual({ Name: 'Coin', Amount: 1 }); + const loaded = deserializeProject(serializeProject(f.project)); + const reloaded = loaded.assets.find((a) => a.id === chest.id) as BehaviorAsset; + expect(reloaded.props.drop).toEqual({ Name: 'Coin', Amount: 1 }); + }); + + it('survives saving and loading, and a stale copy in a file is brought in line on load', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = dropper(f, loot); + const raw = serializeProject(f.project) as { assets: Record[] }; + const stored = raw.assets.find((a) => a.id === chest.id) as { + variables: Record[]; + props: Record; + }; + expect(stored.variables[0]).toMatchObject({ structType: loot.id }); + stored.variables[0]!.fields = [{ key: 'Name', kind: 'text' }]; + stored.props.drop = { Name: 'Gem' }; + const loaded = deserializeProject(raw); + const reloaded = loaded.assets.find((a) => a.id === chest.id) as BehaviorAsset; + expect(reloaded.variables[0]).toMatchObject({ + structType: loot.id, + fields: [{ key: 'Name' }, { key: 'Amount' }], + }); + expect(reloaded.props.drop).toEqual({ Name: 'Gem', Amount: 1 }); + }); + + it('makes a type out of a struct that holds its own fields, reading as it did before', () => { + const f = makeSpriteScene(); + const chest = makeBehavior(f, 'Chest'); + f.stack.execute(new AddVariableCommand(chest.id, 'drop', 'struct')); + f.stack.execute(new AddStructFieldCommand(chest.id, 'drop', { key: 'Name', kind: 'text' })); + f.stack.execute(new AddStructFieldCommand(chest.id, 'drop', { key: 'enabled', kind: 'boolean' })); + f.stack.execute(new SetAssetPropertyCommand(chest.id, 'drop', { Name: 'Gem', enabled: true })); + const make = new MakeStructTypeCommand(chest.id, 'drop', []); + f.stack.execute(make); + const type = f.project.assets.find((a) => a.id === make.assetId) as StructAsset; + expect(type).toMatchObject({ type: 'struct', name: 'drop', props: { Name: 'Gem', enabled: true } }); + expect(type.variables.map((v) => `${v.key}:${v.kind}`)).toEqual(['Name:text', 'enabled:boolean']); + expect(drop(chest).structType).toBe(type.id); + expect(chest.props.drop).toEqual({ Name: 'Gem', enabled: true }); + f.stack.undo(); + expect(f.project.assets.some((a) => a.id === make.assetId)).toBe(false); + expect(drop(chest).structType).toBeUndefined(); + f.stack.redo(); + expect(drop(chest).structType).toBe(make.assetId); + }); +}); + +describe('Pack and Expand with a type', () => { + it('take the fields as ports, and a rename keeps the edges and the typed-in value', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const behavior = makeBehavior(f, 'G'); + const g = graphEditor(f, behavior); + const pack = g.addNode('PackStruct'); + const expand = g.addNode('ExpandStruct'); + f.stack.execute( + new SetStructTypeCommand({ kind: 'node', assetId: behavior.id, nodeId: pack.id }, loot.id), + ); + f.stack.execute( + new SetStructTypeCommand({ kind: 'node', assetId: behavior.id, nodeId: expand.id }, loot.id), + ); + expect(pack.ports).toEqual([ + { key: 'Name', kind: 'text' }, + { key: 'Amount', kind: 'number' }, + ]); + expect(expand.params[STRUCT_TYPE_PARAM]).toBe(loot.id); + const log = g.addNode('Log'); + g.connect(expand, 'Amount', log, 'message'); + const number = g.addNode('Number'); + g.connect(number, 'value', pack, 'Amount'); + pack.params.Name = 'Gem'; + onType(f, new RenameVariableCommand(loot.id, 'Amount', 'Count'), { + structType: loot.id, + path: [], + from: 'Amount', + to: 'Count', + }); + expect(pack.ports?.map((p) => p.key)).toEqual(['Name', 'Count']); + expect(behavior.graph.edges.map((e) => `${e.from.port}>${e.to.port}`).sort()).toEqual([ + 'Count>message', + 'value>Count', + ]); + onType(f, new RemoveVariableCommand(loot.id, 'Name')); + expect(pack.ports?.map((p) => p.key)).toEqual(['Count']); + expect(pack.params.Name).toBeUndefined(); + f.stack.undo(); + expect(pack.params.Name).toBe('Gem'); + onType(f, new RemoveVariableCommand(loot.id, 'Count')); + expect(behavior.graph.edges).toHaveLength(0); + f.stack.undo(); + expect(behavior.graph.edges).toHaveLength(2); + }); +}); + +describe('typed places', () => { + it('are found at any depth, and a typed struct is not looked into', () => { + const f = makeSpriteScene(); + const loot = lootType(f); + const chest = dropper(f, loot); + f.stack.execute(new AddVariableCommand(chest.id, 'room', 'struct')); + f.stack.execute( + new AddStructFieldCommand(chest.id, 'room', { key: 'prize', kind: 'struct', fields: [] }), + ); + f.stack.execute( + new SetStructTypeCommand( + { kind: 'variable', ownerId: chest.id, key: 'room', path: ['prize'] }, + loot.id, + ), + ); + expect(typedSites(f.project).map((s) => [s.def.key, s.path.join('.')])).toEqual([ + ['drop', ''], + ['room', 'prize'], + ]); + }); +}); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 6dcb8fb1..3f705a28 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -94,7 +94,7 @@ export async function assets(t) { ); check( 'every asset type is offered once, each group sorted by label', - placeable.length + library.length === 22 && + placeable.length + library.length === 23 && String(placeable) === String([...placeable].sort()) && String(library) === String([...library].sort()), JSON.stringify({ placeable, library }), @@ -453,7 +453,7 @@ export async function assets(t) { async function addMenuOnRightClick(t) { const { check, json, shot, click, clickText, press, undo, showTab } = t; const addRows = (rows) => - Array.isArray(rows) && rows.filter(Boolean).length === 22 && rows.includes('Sprite'); + Array.isArray(rows) && rows.filter(Boolean).length === 23 && rows.includes('Sprite'); const heading = await json("__gm.assetRowShown('group:scene')"); const sceneFolded = async () => !(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded; diff --git a/tools/e2e/54-struct-types.mjs b/tools/e2e/54-struct-types.mjs new file mode 100644 index 00000000..e57e8a87 --- /dev/null +++ b/tools/e2e/54-struct-types.mjs @@ -0,0 +1,280 @@ +/** + * Struct assets as types (#253), with the real mouse: a Struct asset made from the + menu and its + * fields declared in the inspector, a behavior's struct variable typed by it, an `Expand` pointed at + * it through its Type setting – then a field of the asset renamed, and the new name has to show up + * in the variable, in a placed object's override and on the `Expand` port with its edge. "Make + * struct type" turns a struct's own fields into an asset; deleting the asset leaves the fields. + * + * Everything it adds carries an `st54` id or is an asset it made, and goes again at the end. + */ +import { MAKE_OBJECT } from './lib/scaffold.mjs'; + +export async function structTypes(t) { + const { + check, + json, + evaluate, + mutate, + click, + clickText, + press, + typeText, + sleep, + settle, + shot, + select, + undo, + } = t; + const inOverlay = async (text) => { + const box = await json('__gm.overlayRect()'); + const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? []; + return ( + hits.find((h) => h.x >= box.x && h.x <= box.x + box.w && h.y >= box.y && h.y <= box.y + box.h) ?? null + ); + }; + const press_ = async (at, what) => { + if (!at) throw new Error(`nothing to click: ${what}`); + await click(at.x, at.y); + await sleep(150); + }; + /** A menu entry, always the last drawn: the panels behind a menu carry the same words. */ + const pick = async (label) => press_(await json(`__gm.locate(${JSON.stringify(label)}, true)`), label); + /** + * Types into the text field showing `text`, replacing it. The rightmost hit: the inspector is the + * right-hand panel, and an open graph shows the same names on its ports. + */ + const retype = async (text, next) => { + const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? []; + await press_(hits.sort((a, b) => b.x - a.x)[0], text); + await press('a', { ctrl: true }); + await typeText(next); + await press('Enter'); + await sleep(150); + }; + const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}) ?? null`); + const chest = () => asset('st54-chest'); + const variable = async (key) => (await chest()).variables.find((v) => v.key === key); + const override = () => + json("__gm.state.activeScene.objects.find((o) => o.id === 'st54-obj')?.overrides.drop ?? null"); + const expand = async () => (await chest()).graph.nodes.find((n) => n.id === 'st54-expand'); + const edgeFrom = async () => + (await chest()).graph.edges.find((e) => e.id === 'st54-edge')?.from.port ?? null; + + // A behavior with two structs holding their own fields, an Expand and a Log in its graph, and one + // placed object overriding `drop`. + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + p.assets.push({ + id: 'st54-chest', + type: 'behavior', + name: 'Chest 54', + props: { enabled: true, runInEditor: false, drop: { Title: 'Gem' }, bag: { Gold: 5 } }, + variables: [ + { key: 'drop', label: 'drop', kind: 'struct', default: { Title: '' }, fields: [{ key: 'Title', kind: 'text' }] }, + { key: 'bag', label: 'bag', kind: 'struct', default: { Gold: 0 }, fields: [{ key: 'Gold', kind: 'number' }] }, + ], + graph: { + nodes: [ + { id: 'st54-expand', type: 'ExpandStruct', x: 0, y: 0, params: {} }, + { id: 'st54-log', type: 'Log', x: 240, y: 0, params: {} }, + ], + edges: [], + }, + }); + ${MAKE_OBJECT} + const placed = make('st54-obj', 'st54-chest', '', 400, 'Chest 54'); + placed.overrides = { drop: { Title: 'Ruby' } }; + scene.objects.push(placed); + `); + + // 54a. The + menu makes a Struct; its inspector declares fields as a Variables asset declares + // variables, and each field has a default. + await t.showTab('scene'); + await clickText('+'); + const offered = (await json('__gm.menuRows()')) ?? []; + const entry = await inOverlay('Struct'); + check('the + menu offers Struct', offered.includes('Struct') && Boolean(entry), JSON.stringify(offered)); + if (!entry) return; + await press_(entry, 'Struct'); + await settle(); + const lootId = ( + await json("__gm.state.project.assets.filter((a) => a.type === 'struct').map((a) => a.id)") + ).at(-1); + await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(lootId)}).name = 'Loot 54'`); + const row = await json(`__gm.assetRow(${JSON.stringify(lootId)})`); + await press_(row, 'the Loot 54 row'); + check( + 'a new Struct selects itself and says it has no fields', + (await t.has('FIELDS')) && (await t.has('+ Field')) && (await t.has('No fields yet.')), + ); + await clickText('+ Field'); + await pick('Text'); + await retype('field1', 'Title'); + await clickText('+ Field'); + await pick('Number'); + await retype('field1', 'Amount'); + await press_(await json("__gm.inspectorField('Title')"), 'Title value'); + await typeText('Coin'); + await press('Enter'); + await press_(await json("__gm.inspectorField('Amount')"), 'Amount value'); + await press('a', { ctrl: true }); + await typeText('7'); + await press('Enter'); + await sleep(150); + const loot = await asset(lootId); + check( + '+ Field, a new name and a value give the struct its fields and their defaults', + JSON.stringify(loot.variables.map((v) => `${v.key}:${v.kind}`)) === '["Title:text","Amount:number"]' && + JSON.stringify(loot.props) === '{"Title":"Coin","Amount":7}', + JSON.stringify({ variables: loot.variables.map((v) => v.key), props: loot.props }), + ); + await shot('01-struct-fields'); + + // 54b. A struct variable picks the type under its head. It takes the type's fields, keeps what + // fits of its value and cannot grow fields of its own any more. + await select({ kind: 'asset', assetId: 'st54-chest' }); + await sleep(150); + const addsBefore = ((await json("__gm.locateAll('+ Field')")) ?? []).length; + const typeRows = ((await json("__gm.locateAll('Own fields')")) ?? []).sort((a, b) => a.y - b.y); + check( + 'each struct variable has a Type row reading "Own fields"', + typeRows.length === 2, + JSON.stringify(typeRows), + ); + await press_(typeRows[0], 'Type of drop'); + await pick('Loot 54'); + const drop = await variable('drop'); + const props = (await chest()).props; + check( + 'picking Loot 54 gives drop its fields, keeps "Gem" and fills Amount with the default 7', + drop.structType === lootId && + JSON.stringify(drop.fields.map((f) => f.key)) === '["Title","Amount"]' && + JSON.stringify(props.drop) === '{"Title":"Gem","Amount":7}', + JSON.stringify({ drop, value: props.drop }), + ); + check( + 'and the placed override takes the shape as well', + JSON.stringify(await override()) === '{"Title":"Ruby","Amount":7}', + JSON.stringify(await override()), + ); + const addsAfter = ((await json("__gm.locateAll('+ Field')")) ?? []).length; + check( + 'a typed struct offers no + Field, its row names the type', + addsBefore === 2 && addsAfter === 1 && (await t.has('Loot 54')), + JSON.stringify({ addsBefore, addsAfter }), + ); + await shot('02-typed-variable'); + + // 54c. Expand's Type setting points it at the asset: its ports are the fields. + await select({ kind: 'node', assetId: 'st54-chest', nodeId: 'st54-expand' }); + await sleep(150); + await press_(await json("__gm.inspectorField('Type')"), 'Type of Expand'); + // The asset picker names the kind after each asset. + await pick('Loot 54 (struct)'); + const ports = (await expand()).ports ?? []; + check( + 'Expand with the type has one output per field, and its fields are not edited there', + JSON.stringify(ports.map((p) => p.key)) === '["Title","Amount"]' && + (await t.has('The fields of Loot 54. Change them there.')) && + !(await t.has('+ Field')), + JSON.stringify(ports), + ); + await mutate(` + const graph = __gm.state.project.assets.find((a) => a.id === 'st54-chest').graph; + graph.edges.push({ id: 'st54-edge', from: { node: 'st54-expand', port: 'Amount' }, to: { node: 'st54-log', port: 'message' } }); + `); + await shot('03-typed-expand'); + + // 54d. Renaming a field on the asset reaches every place of the type in one step, values kept. + await select({ kind: 'asset', assetId: lootId }); + await sleep(150); + await retype('Amount', 'Count'); + const renamed = { + fields: (await variable('drop')).fields.map((f) => f.key), + value: (await chest()).props.drop, + override: await override(), + ports: ((await expand()).ports ?? []).map((p) => p.key), + edge: await edgeFrom(), + }; + check( + 'renaming Amount to Count reaches the variable, its value, the override, the port and its edge', + JSON.stringify(renamed) === + JSON.stringify({ + fields: ['Title', 'Count'], + value: { Title: 'Gem', Count: 7 }, + override: { Title: 'Ruby', Count: 7 }, + ports: ['Title', 'Count'], + edge: 'Count', + }), + JSON.stringify(renamed), + ); + await undo(); + const back = { + fields: (await variable('drop')).fields.map((f) => f.key), + value: (await chest()).props.drop, + override: await override(), + edge: await edgeFrom(), + }; + check( + 'one undo takes the rename back everywhere', + JSON.stringify(back) === + JSON.stringify({ + fields: ['Title', 'Amount'], + value: { Title: 'Gem', Amount: 7 }, + override: { Title: 'Ruby', Amount: 7 }, + edge: 'Amount', + }), + JSON.stringify(back), + ); + + // 54e. "Make struct type" turns the fields bag holds by itself into a Struct asset. + await select({ kind: 'asset', assetId: 'st54-chest' }); + await sleep(150); + await press_(await json("__gm.locate('Own fields')"), 'Type of bag'); + await pick('Make struct type'); + const bag = await variable('bag'); + const made = await asset(bag.structType ?? ''); + check( + 'Make struct type makes a Struct asset of bag, its value becoming the defaults', + made?.type === 'struct' && + made.name === 'bag' && + JSON.stringify(made.props) === '{"Gold":5}' && + JSON.stringify((await chest()).props.bag) === '{"Gold":5}', + JSON.stringify({ bag, made }), + ); + + // 54f. Deleting the type asks first and says what happens; the places keep the fields. + const lootRow = await json(`__gm.assetRow(${JSON.stringify(lootId)})`); + await click(lootRow.x, lootRow.y, 'right'); + await sleep(150); + await pick('Delete…'); + const detail = '1 struct(s) and 1 Pack/Expand node(s) use it and keep its fields as their own.'; + check('the delete dialog counts the places using the type', await t.has(detail)); + await shot('04-delete-dialog'); + await pick('Delete'); + const kept = await variable('drop'); + check( + 'after deleting, drop keeps Title and Amount and its value', + (await asset(lootId)) === null && + JSON.stringify(kept.fields.map((f) => f.key)) === '["Title","Amount"]' && + JSON.stringify((await chest()).props.drop) === '{"Title":"Gem","Amount":7}', + JSON.stringify(kept), + ); + await select({ kind: 'asset', assetId: 'st54-chest' }); + await sleep(150); + check( + 'and its Type row reads "Own fields" again, with + Field back', + ((await json("__gm.locateAll('+ Field')")) ?? []).length === 1 && (await t.has('Own fields')), + ); + + // Clean up. + await evaluate('__gm.state.select(null)'); + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => o.id !== 'st54-obj'); + const made = ${JSON.stringify(made?.id ?? '')}; + p.assets = p.assets.filter((a) => a.id !== 'st54-chest' && a.id !== made && a.id !== ${JSON.stringify(lootId)}); + `); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index f2c1bfff..e8821341 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -56,6 +56,7 @@ import { snapshots } from './50-snapshots.mjs'; import { clipboard } from './51-clipboard.mjs'; import { hierarchyGroups } from './52-hierarchy-groups.mjs'; import { curves } from './53-curves.mjs'; +import { structTypes } from './54-struct-types.mjs'; export const STEPS = [ hierarchy, @@ -111,4 +112,5 @@ export const STEPS = [ clipboard, hierarchyGroups, curves, + structTypes, ];