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

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

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

Differs from the plan:

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

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

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

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

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

No files matched your search

+2 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```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 # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.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 | | [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 | | [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 | | [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 | | [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 | | [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 | | [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, nine-slice, the filter, upload limits |
+2
View File
@@ -49,6 +49,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() }; return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
case 'variables': case 'variables':
return { ...base, type: 'variables', variables: [] }; return { ...base, type: 'variables', variables: [] };
case 'struct':
return { ...base, type: 'struct', variables: [] };
case 'collider': case 'collider':
return { ...base, type: 'collider' }; return { ...base, type: 'collider' };
case 'rigidbody': case 'rigidbody':
+2 -2
View File
@@ -1,4 +1,4 @@
import type { BehaviorAsset, VariablesAsset } from '../model/Asset'; import type { VariableOwnerAsset } from '../model/Asset';
import type { Project } from '../model/Project'; import type { Project } from '../model/Project';
function firstFree(taken: ReadonlySet<string>, candidate: (i: number) => string, from: number): string { function firstFree(taken: ReadonlySet<string>, 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. */ /** "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); return firstFree(new Set(asset.variables.map((v) => v.key)), (i) => `${base}${i}`, 1);
} }
+7 -4
View File
@@ -9,7 +9,7 @@ import type {
StateMachineAsset, StateMachineAsset,
TilemapAsset, TilemapAsset,
TilemapLayer, TilemapLayer,
VariablesAsset, VariableOwnerAsset,
VectorArtAsset, VectorArtAsset,
VectorShape, VectorShape,
} from '../model/Asset'; } from '../model/Asset';
@@ -84,10 +84,13 @@ export function requireBehavior(project: Project, assetId: string): BehaviorAsse
return asset; 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); 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`); throw new CommandError(`Variable owner ${assetId} not found`);
return asset; return asset;
} }
+178
View File
@@ -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);
}
}
+11 -3
View File
@@ -5,7 +5,7 @@ import {
type PropertyValue, type PropertyValue,
type StructValue, type StructValue,
} from '../model/Properties'; } from '../model/Properties';
import { isKeptVariable, setKeptVariable } from '../model/Asset'; import { isKeptVariable, setKeptVariable, type VariableOwnerAsset } from '../model/Asset';
import { overrideSites } from '../model/instance'; import { overrideSites } from '../model/instance';
import type { Project } from '../model/Project'; import type { Project } from '../model/Project';
import type { VariableKind } from '../properties/kinds'; import type { VariableKind } from '../properties/kinds';
@@ -42,7 +42,7 @@ export class AddVariableCommand implements Command {
execute(project: Project): void { execute(project: Project): void {
const asset = requireVariableOwner(project, this.assetId); 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)) if (asset.variables.some((v) => v.key === this.def.key))
throw new CommandError(`Variable "${this.def.key}" already exists`); throw new CommandError(`Variable "${this.def.key}" already exists`);
asset.variables.push(this.def); asset.variables.push(this.def);
@@ -139,7 +139,7 @@ export class RenameVariableCommand extends RenameCommand {
protected rename(project: Project, from: string, to: string): void { protected rename(project: Project, from: string, to: string): void {
const asset = requireVariableOwner(project, this.assetId); const asset = requireVariableOwner(project, this.assetId);
if (from === to) return; 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); const def = asset.variables.find((v) => v.key === from);
if (!def) throw new CommandError(`Variable "${from}" not found`); if (!def) throw new CommandError(`Variable "${from}" not found`);
if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`); 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_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 _'; const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
+11
View File
@@ -35,6 +35,17 @@ export const VARIABLES_SETTING: PropertyDef = {
assetTypes: ['variables'], 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 { export function variablesSourceOf(params: PropertyBag): string {
return textOf(params[VARIABLES_PARAM] ?? ''); return textOf(params[VARIABLES_PARAM] ?? '');
} }
+2 -1
View File
@@ -1,5 +1,6 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { asStruct, unplaced, type Value } from '../../values'; import { asStruct, unplaced, type Value } from '../../values';
import { STRUCT_TYPE_SETTING } from '../../nested';
import { struct } from '../shared'; import { struct } from '../shared';
/** /**
@@ -16,7 +17,7 @@ export const ExpandStruct: NodeDef = {
'reads as empty.', 'reads as empty.',
inputs: [struct('value')], inputs: [struct('value')],
outputs: [], outputs: [],
params: [], params: [STRUCT_TYPE_SETTING],
declaresPorts: 'outputs', declaresPorts: 'outputs',
// A declared output the struct has no field for is simply absent from the cache, and reads as empty. // A declared output the struct has no field for is simply absent from the cache, and reads as empty.
run: (_ctx, inputs) => { run: (_ctx, inputs) => {
+2 -1
View File
@@ -1,6 +1,7 @@
import type { FieldValue } from '../../../model/Properties'; import type { FieldValue } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { asField } from '../../values'; import { asField } from '../../values';
import { STRUCT_TYPE_SETTING } from '../../nested';
import { struct } from '../shared'; import { struct } from '../shared';
/** /**
@@ -14,7 +15,7 @@ export const PackStruct: NodeDef = {
description: 'Bundles named values into one struct, one input per field.', description: 'Bundles named values into one struct, one input per field.',
inputs: [], inputs: [],
outputs: [struct('value')], outputs: [struct('value')],
params: [], params: [STRUCT_TYPE_SETTING],
declaresPorts: 'inputs', declaresPorts: 'inputs',
run: (_ctx, inputs) => { run: (_ctx, inputs) => {
const fields: Record<string, FieldValue> = {}; const fields: Record<string, FieldValue> = {};
+18
View File
@@ -77,6 +77,16 @@ export interface VariablesAsset extends AssetBase {
scope?: Scope; 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. */ /** Placed as a child of the object it belongs to; its position is the offset from that body. */
export interface ColliderAsset extends AssetBase { export interface ColliderAsset extends AssetBase {
type: 'collider'; type: 'collider';
@@ -458,6 +468,7 @@ export type Asset =
| SceneAsset | SceneAsset
| BehaviorAsset | BehaviorAsset
| VariablesAsset | VariablesAsset
| StructAsset
| ColliderAsset | ColliderAsset
| RigidbodyAsset | RigidbodyAsset
| ContainerAsset | ContainerAsset
@@ -475,6 +486,13 @@ export type Asset =
| SoundAsset | SoundAsset
| ParticlesAsset; | 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. */ /** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset | ParticlesAsset; export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset | ParticlesAsset;
+5
View File
@@ -5,6 +5,7 @@ export const ASSET_TYPES = [
'text', 'text',
'behavior', 'behavior',
'variables', 'variables',
'struct',
'collider', 'collider',
'rigidbody', 'rigidbody',
'container', 'container',
@@ -32,6 +33,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
text: 'Text', text: 'Text',
behavior: 'Behavior', behavior: 'Behavior',
variables: 'Variables', variables: 'Variables',
struct: 'Struct',
collider: 'Collider', collider: 'Collider',
rigidbody: 'Rigidbody', rigidbody: 'Rigidbody',
container: 'Container', container: 'Container',
@@ -57,6 +59,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
* sound is nowhere on screen at all – a graph plays it, so there is nothing to place. A tile is * 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 * 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. * 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< export type PlaceableAssetType = Exclude<
AssetType, AssetType,
@@ -73,6 +76,7 @@ export type PlaceableAssetType = Exclude<
| 'font' | 'font'
| 'sound' | 'sound'
| 'variables' | 'variables'
| 'struct'
>; >;
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
@@ -89,6 +93,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'font', 'font',
'sound', 'sound',
'variables', 'variables',
'struct',
]); ]);
export function isPlaceableType(type: AssetType): type is PlaceableAssetType { export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
+374
View File
@@ -0,0 +1,374 @@
/**
* Struct assets as types (#253). A struct variable or field naming one in `structType` keeps a copy
* of its fields, so everything reading `fields` works as it did; `syncStructTypes` brings every such
* copy – and every value and `Pack`/`Expand` port hanging on it – back in line after the asset
* changed. A type that is gone leaves the copy as it was: the place keeps its last fields as its own.
*/
import type { DeclaredPort, Graph, GraphNode } from '../graph/Graph';
import { fixedPortKeys, isValidPortName, STRUCT_TYPE_PARAM } from '../graph/nested';
import { nodeDef } from '../graph/NodeRegistry';
import { isKindWith } from '../properties/kinds';
import {
coerceFieldValue,
emptyFieldValue,
type PropertyDef,
type StructDef,
type StructFieldDef,
structOf,
} from '../properties/PropertySchema';
import { valueAt, withValueAt } from '../properties/valuePath';
import type { Recorder } from '../util/recorder';
import { isGraphAsset, isVariableOwner, type StructAsset, type VariableOwnerAsset } from './Asset';
import { overrideSites } from './instance';
import { type FieldValue, isStruct, type PropertyValue, type StructValue, tagOf, textOf } from './Properties';
import { findAssetOf, type Project } from './Project';
export const PACK_STRUCT = 'PackStruct';
export const EXPAND_STRUCT = 'ExpandStruct';
/** Deeper than this only a cycle written by hand can go; it is cut there. */
const MAX_DEPTH = 32;
export function structAssetOf(project: Project, id: string | undefined): StructAsset | undefined {
return id ? findAssetOf(project, id, 'struct') : undefined;
}
export function isTypedNode(node: GraphNode): boolean {
return node.type === PACK_STRUCT || node.type === EXPAND_STRUCT;
}
export function nodeStructType(node: GraphNode): string {
return isTypedNode(node) ? textOf(node.params[STRUCT_TYPE_PARAM] ?? '') : '';
}
/** A declared variable as the struct field it becomes in every value of the type. */
export function fieldOfVariable(def: PropertyDef): StructFieldDef | undefined {
if (def.kind === 'struct')
return {
key: def.key,
kind: 'struct',
fields: def.fields,
...(def.structType ? { structType: def.structType } : {}),
};
if (def.kind === 'list') return { key: def.key, kind: 'list' };
return isKindWith('field', def.kind) ? { key: def.key, kind: def.kind } : undefined;
}
export function typeFields(asset: StructAsset): StructFieldDef[] {
return asset.variables.flatMap((def) => fieldOfVariable(def) ?? []);
}
/** A new value of the type: every field at the default the asset declares for it. */
export function typeDefault(asset: StructAsset): StructValue {
return structOf(typeFields(asset), asset.props);
}
/** The Struct assets named anywhere inside these fields; a typed field is not looked into. */
function typesIn(fields: readonly StructFieldDef[]): string[] {
return fields.flatMap((f) =>
f.kind !== 'struct' ? [] : f.structType ? [f.structType] : typesIn(f.fields),
);
}
/**
* Whether a value of type `from` holds one of type `target` anywhere inside, or is one. Such a
* type cannot be a field of `target`: its default would never end.
*/
export function containsType(project: Project, from: string, target: string, depth = 0): boolean {
if (from === target) return true;
const asset = structAssetOf(project, from);
if (!asset || depth > MAX_DEPTH) return false;
return typesIn(typeFields(asset)).some((id) => containsType(project, id, target, depth + 1));
}
/** Where values of type `target` sit inside a value of type `from`: `[]` when it is one itself. */
function typePaths(project: Project, from: string, target: string, depth = 0): string[][] {
if (from === target) return [[]];
const asset = structAssetOf(project, from);
if (!asset || depth > MAX_DEPTH) return [];
return pathsIn(project, typeFields(asset), target, depth + 1);
}
function pathsIn(
project: Project,
fields: readonly StructFieldDef[],
target: string,
depth: number,
): string[][] {
if (depth > MAX_DEPTH) return [];
const paths: string[][] = [];
for (const f of fields) {
if (f.kind !== 'struct') continue;
const inner = f.structType
? typePaths(project, f.structType, target, depth + 1)
: pathsIn(project, f.fields, target, depth + 1);
for (const path of inner) paths.push([f.key, ...path]);
}
return paths;
}
/**
* A place that takes on a type: the struct at `path` inside the variable `def` of `owner`. Below it
* nothing is a place of its own – the type's fields say what is there.
*/
export interface TypedSite {
owner: VariableOwnerAsset;
def: StructDef;
path: string[];
structType: string;
}
export function typedSites(project: Project): TypedSite[] {
const sites: TypedSite[] = [];
const visit = (
owner: VariableOwnerAsset,
def: StructDef,
at: { structType?: string; fields: readonly StructFieldDef[] },
path: string[],
): void => {
if (at.structType) {
sites.push({ owner, def, path, structType: at.structType });
return;
}
for (const f of at.fields) if (f.kind === 'struct') visit(owner, def, f, [...path, f.key]);
};
for (const asset of project.assets)
if (isVariableOwner(asset))
for (const def of asset.variables) if (def.kind === 'struct') visit(asset, def, def, []);
return sites;
}
/** Everywhere the value of a variable is kept: its declared default, the owner's, the overrides. */
interface Holder {
read(): PropertyValue | undefined;
write(value: PropertyValue): void;
}
function holdersOf(project: Project, site: TypedSite, rec: Recorder): Holder[] {
const { owner, def } = site;
const holders: Holder[] = [
{ read: () => def.default, write: (value) => rec.set(def, 'default', value as StructValue) },
];
if (owner.props[def.key] !== undefined)
holders.push({
read: () => owner.props[def.key],
write: (value) => rec.set(owner.props, def.key, value),
});
for (const { bag } of overrideSites(project, (id) => id === owner.id))
if (bag[def.key] !== undefined)
holders.push({ read: () => bag[def.key], write: (value) => rec.set(bag, def.key, value) });
return holders;
}
/**
* `value` in the shape of `fields`: what it has keeps its value (brought to the field's kind), what
* it lacks takes the type's default, and what the type does not declare falls away.
*/
export function shapedStruct(
project: Project,
fields: readonly StructFieldDef[],
value: PropertyValue | undefined,
defaults: StructValue,
depth = 0,
): StructValue {
const source = isStruct(value) && tagOf(value) === undefined ? value : {};
const shaped: Record<string, FieldValue> = {};
for (const field of fields) {
const own = source[field.key];
const fallback = defaults[field.key];
const inner = field.kind === 'struct' ? structAssetOf(project, field.structType) : undefined;
if (inner && field.kind === 'struct' && depth < MAX_DEPTH) {
const innerDefaults = isStruct(fallback) ? fallback : typeDefault(inner);
shaped[field.key] = shapedStruct(project, field.fields, own ?? innerDefaults, innerDefaults, depth + 1);
} else if (own !== undefined) shaped[field.key] = coerceFieldValue(own, field);
else
shaped[field.key] = fallback !== undefined ? coerceFieldValue(fallback, field) : emptyFieldValue(field);
}
return shaped;
}
/** The declaration with the fields of the struct at `path` replaced – and nothing else touched. */
function withFieldsAt(
fields: readonly StructFieldDef[],
path: readonly string[],
change: (field: Extract<StructFieldDef, { kind: 'struct' }>) => StructFieldDef,
): readonly StructFieldDef[] {
const [step, ...rest] = path;
return fields.map((f) =>
f.key !== step || f.kind !== 'struct'
? f
: rest.length === 0
? change(f)
: { ...f, fields: withFieldsAt(f.fields, rest, change) },
);
}
/** The struct field at `path` of a variable, or the variable itself for none. */
export function structAt(
def: StructDef,
path: readonly string[],
): { fields: readonly StructFieldDef[]; structType?: string } | undefined {
let at: { fields: readonly StructFieldDef[]; structType?: string } = def;
for (const step of path) {
const next = at.fields.find((f) => f.key === step);
if (next?.kind !== 'struct') return undefined;
at = next;
}
return at;
}
function setFields(
def: StructDef,
path: readonly string[],
fields: readonly StructFieldDef[],
rec: Recorder,
) {
if (path.length === 0) rec.set(def, 'fields', fields);
else
rec.set(
def,
'fields',
withFieldsAt(def.fields, path, (f) => ({ ...f, fields })),
);
}
/** Names the type of the struct at `path`; `''` makes it hold its own fields again. */
export function setStructType(def: StructDef, path: readonly string[], typeId: string, rec: Recorder): void {
if (path.length === 0) {
if (typeId) rec.set(def, 'structType', typeId);
else rec.delete(def, 'structType');
return;
}
rec.set(
def,
'fields',
withFieldsAt(def.fields, path, ({ structType: _dropped, ...f }) =>
typeId ? { ...f, structType: typeId } : f,
),
);
}
/** A field of a Struct asset renamed: `path` leads through the type's value to the struct holding it. */
export interface FieldRename {
structType: string;
path: readonly string[];
from: string;
to: string;
}
function renamedKey(value: StructValue, from: string, to: string): StructValue {
const next: Record<string, FieldValue> = {};
for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item;
return next;
}
/** Carries a renamed field's values over, before the shapes are rebuilt – which would drop them. */
function applyRename(project: Project, rec: Recorder, rename: FieldRename): void {
const { from, to } = rename;
for (const site of typedSites(project))
for (const inside of typePaths(project, site.structType, rename.structType)) {
const path = [...site.path, ...inside, ...rename.path];
for (const holder of holdersOf(project, site, rec)) {
const value = holder.read();
const at = valueAt(value, path);
if (!isStruct(at) || !(from in at)) continue;
holder.write(withValueAt(value, path, renamedKey(at, from, to)) ?? value!);
}
}
if (rename.path.length > 0) return;
for (const { graph, node } of typedNodes(project)) {
if (nodeStructType(node) !== rename.structType) continue;
const ports = node.ports ?? [];
if (!ports.some((p) => p.key === from)) continue;
rec.set(
node,
'ports',
ports.map((p) => (p.key === from ? { ...p, key: to } : p)),
);
for (const edge of graph.edges) {
if (edge.to.node === node.id && edge.to.port === from) rec.set(edge.to, 'port', to);
if (edge.from.node === node.id && edge.from.port === from) rec.set(edge.from, 'port', to);
}
const inline = node.params[from];
if (inline !== undefined) {
rec.set(node.params, to, inline);
rec.delete(node.params, from);
}
}
}
function typedNodes(project: Project): { graph: Graph; node: GraphNode }[] {
return project.assets.flatMap((asset) =>
isGraphAsset(asset)
? asset.graph.nodes.filter(isTypedNode).map((node) => ({ graph: asset.graph, node }))
: [],
);
}
/** The ports follow the fields; an edge on a port that is gone goes with it, as its typed-in value does. */
function syncNodePorts(
graph: Graph,
node: GraphNode,
fields: readonly StructFieldDef[],
rec: Recorder,
): void {
const def = nodeDef(node.type);
if (!def) return;
const fixed = new Set(fixedPortKeys(def));
const ports: DeclaredPort[] = fields
.filter((f) => isValidPortName(f.key) && !fixed.has(f.key))
.map((f) => ({ key: f.key, kind: f.kind }));
const kept = new Set(ports.map((p) => p.key));
const gone = new Set((node.ports ?? []).map((p) => p.key).filter((key) => !kept.has(key)));
rec.set(node, 'ports', ports);
if (gone.size === 0) return;
const inputs = def.declaresPorts === 'inputs';
const hangs = (end: { node: string; port: string }) => end.node === node.id && gone.has(end.port);
if (graph.edges.some((e) => (inputs ? hangs(e.to) : hangs(e.from))))
rec.set(
graph,
'edges',
graph.edges.filter((e) => !(inputs ? hangs(e.to) : hangs(e.from))),
);
if (inputs) for (const key of gone) rec.delete(node.params, key);
}
/**
* Brings every typed place in line with its Struct asset: the copied fields, every value kept for
* it, the ports of typed `Pack`/`Expand` nodes. A type holding another type is only right once that
* one is, so the passes repeat until one changes nothing. Every write goes through `rec`, so the
* whole reach of an edit is undone in one go.
*/
export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldRename): void {
if (rename) applyRename(project, rec, rename);
const passes = project.assets.filter((a) => a.type === 'struct').length + 2;
for (let pass = 0; pass < passes; pass++) {
const before = rec.count;
for (const site of typedSites(project)) {
const type = structAssetOf(project, site.structType);
// A struct asset naming a type that holds it is a cycle written by hand: left as it is.
if (!type || (site.owner.type === 'struct' && containsType(project, type.id, site.owner.id))) continue;
const fields = typeFields(type);
setFields(site.def, site.path, fields, rec);
const defaults = typeDefault(type);
for (const holder of holdersOf(project, site, rec)) {
const value = holder.read();
const next = shapedStruct(project, fields, valueAt(value, site.path), defaults);
holder.write(withValueAt(value, site.path, next) ?? next);
}
}
for (const { graph, node } of typedNodes(project)) {
const type = structAssetOf(project, nodeStructType(node));
if (type) syncNodePorts(graph, node, typeFields(type), rec);
}
if (rec.count === before) return;
}
}
/** How many places name the type – what deleting it would cut loose. */
export function structTypeUsages(project: Project, typeId: string): { places: number; nodes: number } {
return {
places: typedSites(project).filter((site) => site.structType === typeId).length,
nodes: typedNodes(project).filter(({ node }) => nodeStructType(node) === typeId).length,
};
}
+6 -2
View File
@@ -168,7 +168,8 @@ export type PropertyDef =
| { key: string; label: string; kind: 'list'; default: ListValue } | { key: string; label: string; kind: 'list'; default: ListValue }
/** /**
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct * `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; key: string;
@@ -176,6 +177,7 @@ export type PropertyDef =
kind: 'struct'; kind: 'struct';
default: StructValue; default: StructValue;
fields: readonly StructFieldDef[]; fields: readonly StructFieldDef[];
structType?: string;
} }
/** An object variable holds a live handle during a run, and nothing at rest (`NO_OBJECT`). */ /** An object variable holds a live handle during a run, and nothing at rest (`NO_OBJECT`). */
| ObjectDef; | ObjectDef;
@@ -205,6 +207,7 @@ export interface StructFieldStructDef {
key: string; key: string;
kind: 'struct'; kind: 'struct';
fields: readonly StructFieldDef[]; fields: readonly StructFieldDef[];
structType?: string;
} }
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef | StructFieldStructDef; export type StructFieldDef = PrimitiveFieldDef | ListFieldDef | StructFieldStructDef;
@@ -513,6 +516,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
], ],
// Every row is declared on the asset itself – see `schemaForBag`. // Every row is declared on the asset itself – see `schemaForBag`.
variables: [], variables: [],
struct: [],
// The states are the asset's graph (see StateMachineAsset), not a property. // The states are the asset's graph (see StateMachineAsset), not a property.
statemachine: [], statemachine: [],
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it. // 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 { function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {
if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables]; if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables];
// Nothing but declarations: the starting values live in `props` under the very same keys. // 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))]; 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. // 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') { if (asset.type === 'particles') {
+1 -1
View File
@@ -12,7 +12,7 @@ import { deviatesFromAsset, isModified } from './resolve';
*/ */
export function markedAssetSchema(project: Project, asset: Asset): PropertySchema { export function markedAssetSchema(project: Project, asset: Asset): PropertySchema {
// Declared variables are drawn by the inspector's own section, not as asset rows. // 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) ? schemaFor(asset.type)
: schemaForAsset(project, asset); : schemaForAsset(project, asset);
} }
+18 -2
View File
@@ -70,6 +70,8 @@ import { newId } from '../ids';
import { SHAPE_FILL_DEFAULT } from '../model/vectorart'; import { SHAPE_FILL_DEFAULT } from '../model/vectorart';
import { sanitizedCurve } from '../model/curve'; import { sanitizedCurve } from '../model/curve';
import { hasParentCycle } from '../model/walk'; import { hasParentCycle } from '../model/walk';
import { syncStructTypes } from '../model/structTypes';
import { Recorder } from '../util/recorder';
import { import {
DECLARED_PORT_KINDS, DECLARED_PORT_KINDS,
FIELD_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; if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
rawProps.set(a.id, asRecord(a.props)); rawProps.set(a.id, asRecord(a.props));
if (a.type === 'sprite' || a.type === 'particles') wearers.set(a.id, a.type); 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); const vars = normalizeVariables(a.variables);
variables.set(a.id, vars); variables.set(a.id, vars);
// The full schema, not just the common rows: this is what pass 2 coerces `props` against, and // 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'; if (readSharedScope(a.scope) === 'scene') shared.scope = 'scene';
else if (a.persistent === true) shared.persistent = true; else if (a.persistent === true) shared.persistent = true;
assets.push(shared); assets.push(shared);
} else if (type === 'struct') {
assets.push({ id, type, name, props, variables: variables.get(id) ?? [] });
} else if (type === 'statemachine') { } else if (type === 'statemachine') {
const machine: StateMachineAsset = { const machine: StateMachineAsset = {
id, id,
@@ -271,6 +275,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
gameScale: gameScaleOf(r), gameScale: gameScaleOf(r),
sceneVisibility: sceneVisibilityOf(r), sceneVisibility: sceneVisibilityOf(r),
}; };
// A hand-edited file may have copies that no longer match their Struct asset.
syncStructTypes(project, new Recorder());
return project; return project;
} }
@@ -386,6 +392,7 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
const fields = normalizeStructFields(v.fields); const fields = normalizeStructFields(v.fields);
const def = variableDef(key, kind, undefined, fields); const def = variableDef(key, kind, undefined, fields);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue); def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
if (def.kind === 'struct') readStructType(def, v.structType);
defs.push(def); defs.push(def);
} }
return defs; return defs;
@@ -410,10 +417,19 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null { function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value)); const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
if (f.kind === 'list') return { key, kind: 'list' }; 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; 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[] { function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolean): DeclaredPort[] {
const ports: DeclaredPort[] = []; const ports: DeclaredPort[] = [];
const seen = new Set(fixed); const seen = new Set(fixed);
+41
View File
@@ -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<T extends object, K extends keyof T & string>(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<T extends object, K extends keyof T & string>(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);
}
+4
View File
@@ -16,6 +16,7 @@ import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import type { AssetType } from '../core/model/AssetTypes'; import type { AssetType } from '../core/model/AssetTypes';
import { curveUsages } from '../core/model/curve'; import { curveUsages } from '../core/model/curve';
import { structTypeUsages } from '../core/model/structTypes';
import { generatesPictures } from '../core/model/generated'; import { generatesPictures } from '../core/model/generated';
import { isTemplateContent } from '../core/model/instance'; import { isTemplateContent } from '../core/model/instance';
import type { Command } from '../core/commands/Command'; import type { Command } from '../core/commands/Command';
@@ -133,11 +134,14 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
const animations = picture ? animationsUsing(state.project, ids).length : 0; const animations = picture ? animationsUsing(state.project, ids).length : 0;
const tiles = picture ? tilesUsing(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 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 = const detail =
asset.type === 'scene' asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.` ? `The scene and its ${asset.objects.length} object(s) are removed.`
: eased.keys > 0 || eased.nodes > 0 : eased.keys > 0 || eased.nodes > 0
? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.` ? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.`
: 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 : references > 0 || animations > 0 || tiles > 0
? wornDetail(references, animations, tiles, asset.type) ? wornDetail(references, animations, tiles, asset.type)
: usages === 0 : usages === 0
+82 -21
View File
@@ -21,11 +21,18 @@ import {
import type { Command } from '../../core/commands/Command'; import type { Command } from '../../core/commands/Command';
import { import {
type Asset, type Asset,
type BehaviorAsset,
isKeptVariable, isKeptVariable,
isVariableOwner,
type StateMachineAsset, type StateMachineAsset,
type VariableOwnerAsset,
type VariablesAsset, type VariablesAsset,
} from '../../core/model/Asset'; } 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 { findAsset, type Project } from '../../core/model/Project';
import type { PropertyBag, PropertyValue } from '../../core/model/Properties'; import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope'; import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope';
@@ -38,7 +45,7 @@ import {
} from '../../core/properties/PropertySchema'; } from '../../core/properties/PropertySchema';
import { markedAssetSchema } from '../../core/properties/deviation'; import { markedAssetSchema } from '../../core/properties/deviation';
import { isModified, resolveAssetProps } from '../../core/properties/resolve'; 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 { FontPreview } from '../font/FontPreview';
import { clearSaved, hasSaved } from '../saves'; import { clearSaved, hasSaved } from '../saves';
import { SoundPreview } from '../sound/SoundPreview'; import { SoundPreview } from '../sound/SoundPreview';
@@ -47,7 +54,7 @@ import { aspectActions, sizeCommand } from './aspectAction';
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory'; import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
import { addNameField, addNote, addTitle, type InspectorHost } from './host'; import { addNameField, addNote, addTitle, type InspectorHost } from './host';
import { buildShapeListSection, shapeListSignature } from './shapeSection'; 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 { listSource, previewingHandlers, structFields } from './previewHandlers';
import { assetValue } from './valueSpecs'; import { assetValue } from './valueSpecs';
@@ -73,13 +80,27 @@ export function assetSignature(project: Project, assetId: string): string {
const shapes = asset.type === 'vectorart' ? `|${shapeListSignature(asset)}` : ''; const shapes = asset.type === 'vectorart' ? `|${shapeListSignature(asset)}` : '';
const values = resolveAssetProps(project, asset); const values = resolveAssetProps(project, asset);
const rows = schemaForAsset(project, asset).filter((def) => isRowShown(def, values)); 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 { 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). // 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('+') : ''; 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. */ /** 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)); 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') { if (asset.type === 'variables') {
buildVariablesScope(host, asset); buildVariablesScope(host, asset);
buildPersistence(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 * 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( export function buildVariables(host: InspectorHost, asset: VariableOwnerAsset, inTab = false): void {
host: InspectorHost, const isType = asset.type === 'struct';
asset: BehaviorAsset | VariablesAsset,
inTab = false,
): void {
const header = new HStack({ gap: Theme.gap, align: 'center' }); const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1); header.add(new Label(isType ? 'FIELDS' : 'VARIABLES', { style: 'title' }), 1);
const add = new Dropdown('+ Variable', { variant: 'ghost' }); 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 = () => { add.onClick = () => {
const items = kindMenu(VARIABLE_KINDS, (kind) => { const items = kindMenu(VARIABLE_KINDS, (kind) => {
const current = findAsset(host.state.project, asset.id); const current = findAsset(host.state.project, asset.id);
if (current?.type === 'behavior' || current?.type === 'variables') if (isVariableOwner(current))
host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind)); run(new AddVariableCommand(asset.id, nextVariableName(current, isType ? 'field' : 'var'), kind));
}); });
new Menu(items).openBelow(add); new Menu(items).openBelow(add);
}; };
@@ -218,15 +239,19 @@ export function buildVariables(
if (!inTab) header.add(openVariablesButton(host, { kind: 'asset', assetId: asset.id })); if (!inTab) header.add(openVariablesButton(host, { kind: 'asset', assetId: asset.id }));
host.body.add(header); 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 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) { for (const def of asset.variables) {
const edit: VariableEditing = { 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)), remove: () => run(new RemoveVariableCommand(asset.id, def.key)),
renameField: (path, field, next) => 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)), removeField: (path, field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key, path)),
addField: (path, kind) => addField: (path, kind) =>
run( run(
@@ -246,13 +271,49 @@ export function buildVariables(
} }
: undefined; : undefined;
const value = values[def.key] ?? def.default; const value = values[def.key] ?? def.default;
const typing = structTyping(host, asset, def.key);
host.fields.set( host.fields.set(
def.key, 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. */ /** 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 { export function openVariablesButton(host: InspectorHost, owner: VariablesOwner): Button {
return new Button('Open', { return new Button('Open', {
+26 -12
View File
@@ -18,13 +18,17 @@ import {
resolveDef, resolveDef,
STATE_TRANSITION, STATE_TRANSITION,
stateName, stateName,
STRUCT_TYPE_PARAM,
STRUCT_TYPE_SETTING,
TRANSITION_PARAM, TRANSITION_PARAM,
variablesSourceOf, variablesSourceOf,
} from '../../core/graph'; } from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset'; import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
import { findGraphAsset, type Project } from '../../core/model/Project'; 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 type { PropertyDef } from '../../core/properties/PropertySchema';
import { Label } from '../../ui'; import { Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory'; import { createFieldRow, type FieldHandlers } from './fieldFactory';
@@ -63,7 +67,8 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
.join(',') .join(',')
: `${textOf(node.params[TRANSITION_PARAM] ?? '')}:${textOf(node.params[MACHINE_PORT] ?? '')}`; : `${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. // 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) const choices = variableChoices(project, assetId, node)
.map((v) => v.key) .map((v) => v.key)
.join(','); .join(',');
@@ -94,25 +99,34 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
addNote(host, def.description); addNote(host, def.description);
const problem = callProblem(node, graphLookup(host.state.project), assetId); const problem = callProblem(node, graphLookup(host.state.project), assetId);
if (problem) addNote(host, problem, 'warning'); if (problem) addNote(host, problem, 'warning');
const addSetting = (param: PropertyDef) => { const addSetting = (param: PropertyDef, handlers = paramHandlers(host, assetId, nodeId, param)) => {
const row = createFieldRow( const row = createFieldRow(host, param, node.params[param.key] ?? param.default, handlers);
host,
param,
node.params[param.key] ?? param.default,
paramHandlers(host, assetId, nodeId, param),
);
host.fields.set(`param:${param.key}`, row); host.fields.set(`param:${param.key}`, row);
host.body.add(row.row); host.body.add(row.row);
}; };
// Both draw their settings inside the port section: a setting decides what the menu offers. // Both draw their settings inside the port section: a setting decides what the menu offers.
const ownSettings = declaresPropertyPorts(node.type) || declaresVariablePorts(node.type); 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); if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, addSetting);
else if (ownSettings) buildPropertyPortSection(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'); addTitle(host, 'SETTINGS');
for (const param of def.params) { for (const param of settings) {
if (param.key === TRANSITION_PARAM && node.type === STATE_TRANSITION) if (param.key === TRANSITION_PARAM && node.type === STATE_TRANSITION)
buildTransitionPicker(host, assetId, node, asset.graph); buildTransitionPicker(host, assetId, node, asset.graph);
else addSetting(param); else addSetting(param);
+14
View File
@@ -202,6 +202,7 @@ export function buildPortSection(
assetId: string, assetId: string,
node: GraphNode, node: GraphNode,
variables: readonly PropertyDef[] = [], variables: readonly PropertyDef[] = [],
typeName: string | null = null,
): void { ): void {
const onStart = node.type === BEHAVIOR_START; const onStart = node.type === BEHAVIOR_START;
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field'; const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
@@ -209,6 +210,19 @@ export function buildPortSection(
const header = new HStack({ gap: Theme.gap, align: 'center' }); const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1); 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); if (noun === 'Field') addShapePicker(host, assetId, node, variables, header);
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' }); const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
add.onClick = () => { add.onClick = () => {
+55 -5
View File
@@ -8,6 +8,7 @@ import {
Icon, Icon,
Label, Label,
Menu, Menu,
type MenuEntry,
type MenuItem, type MenuItem,
Spacer, Spacer,
TextField, TextField,
@@ -56,8 +57,19 @@ export interface VariableKeep {
set: (on: boolean) => void; 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 { export interface VariableOptions {
edit?: VariableEditing; edit?: VariableEditing;
typing?: StructTyping;
keep?: VariableKeep; keep?: VariableKeep;
/** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */ /** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */
actions?: RowActions; actions?: RowActions;
@@ -74,14 +86,16 @@ export function buildVariable(
handlers: FieldHandlers, handlers: FieldHandlers,
options: VariableOptions = {}, options: VariableOptions = {},
): FieldRow { ): FieldRow {
const { edit, keep, actions } = options; const { edit, typing, keep, actions } = options;
const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next))); const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
const head = gridRow(0, markLabel(def.kind), name); const head = gridRow(0, markLabel(def.kind), name);
let setValue: (next: PropertyValue) => void = () => {}; let setValue: (next: PropertyValue) => void = () => {};
let below: Widget | undefined; 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') { if (def.kind === 'struct') {
head.add(new Spacer(), 1); head.add(new Spacer(), 1);
if (edit) head.add(addFieldMenu(edit, [])); if (edit && typed === null) head.add(addFieldMenu(edit, []));
} else { } else {
const field = createField(host, def, value, handlers); const field = createField(host, def, value, handlers);
setValue = addValue(head, field); setValue = addValue(head, field);
@@ -94,8 +108,18 @@ export function buildVariable(
if (below) host.body.add(indentedBy(below, 1)); if (below) host.body.add(indentedBy(below, 1));
if (def.kind === 'struct') { 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'); 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 { return {
@@ -132,17 +156,33 @@ function structFieldRows(
value: PropertyValue, value: PropertyValue,
handlers: FieldHandlers, handlers: FieldHandlers,
edit: VariableEditing | undefined, edit: VariableEditing | undefined,
typing: StructTyping | undefined,
): (next: PropertyValue) => void { ): (next: PropertyValue) => void {
const setters = new Map<string, (item: PropertyValue) => void>(); const setters = new Map<string, (item: PropertyValue) => void>();
for (const field of fields) { for (const field of fields) {
const row = fieldRow(field, path, edit); const row = fieldRow(field, path, edit);
const item = structFieldValue(value, field); const item = structFieldValue(value, field);
if (field.kind === 'struct') { if (field.kind === 'struct') {
const inner = [...path, field.key];
const typed = typing?.typeName(field.structType) ?? null;
row.add(new Spacer(), 1); 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))); if (edit) row.add(removeButton(() => edit.removeField(path, field)));
host.body.add(row); 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; continue;
} }
const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY; 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); 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. */ /** 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 { function gridRow(depth: number, mark: Widget, name: Widget): HStack {
const row = new HStack({ gap: Theme.gap, align: 'center' }); const row = new HStack({ gap: Theme.gap, align: 'center' });
+1
View File
@@ -12,6 +12,7 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
text: 'Texts', text: 'Texts',
behavior: 'Behaviors', behavior: 'Behaviors',
variables: 'Variables', variables: 'Variables',
struct: 'Structs',
collider: 'Colliders', collider: 'Colliders',
rigidbody: 'Rigidbodies', rigidbody: 'Rigidbodies',
container: 'Containers', container: 'Containers',
+2
View File
@@ -23,6 +23,7 @@ import shader from './icons/shader.svg?raw';
import sound from './icons/sound.svg?raw'; import sound from './icons/sound.svg?raw';
import sprite from './icons/sprite.svg?raw'; import sprite from './icons/sprite.svg?raw';
import statemachine from './icons/statemachine.svg?raw'; import statemachine from './icons/statemachine.svg?raw';
import struct from './icons/struct.svg?raw';
import text from './icons/text.svg?raw'; import text from './icons/text.svg?raw';
import texture from './icons/texture.svg?raw'; import texture from './icons/texture.svg?raw';
import tile from './icons/tile.svg?raw'; import tile from './icons/tile.svg?raw';
@@ -48,6 +49,7 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
sound, sound,
sprite, sprite,
statemachine, statemachine,
struct,
text, text,
texture, texture,
tile, tile,
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- A record: a box of rows, one per field, each a name and its value. -->
<rect x="-6.5" y="-6.5" width="13" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.25" />
<path d="M-6.5 -2.2 L6.5 -2.2 M-6.5 2.2 L6.5 2.2 M-1 -6.5 L-1 6.5" fill="none" stroke="currentColor" stroke-width="1" />
<circle cx="-3.8" cy="-4.35" r="0.9" fill="currentColor" />
<circle cx="-3.8" cy="0" r="0.9" fill="currentColor" />
<circle cx="-3.8" cy="4.35" r="0.9" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 590 B

+4 -2
View File
@@ -4,6 +4,7 @@
* One editor for every such tab, pointed at the target of the one showing, like the asset editors. * 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 type { Container } from 'pixi.js';
import { isVariableOwner } from '../../core/model/Asset';
import { findObject } from '../../core/model/hierarchy'; import { findObject } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project'; import { findAsset } from '../../core/model/Project';
import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui'; import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui';
@@ -20,7 +21,7 @@ function ownerOf(state: EditorState, target: Extract<ValuesTarget, { kind: 'vari
const { owner } = target; const { owner } = target;
if (owner.kind === 'asset') { if (owner.kind === 'asset') {
const asset = findAsset(state.project, owner.assetId); const asset = findAsset(state.project, owner.assetId);
return asset?.type === 'behavior' || asset?.type === 'variables' ? { asset, name: asset.name } : null; return isVariableOwner(asset) ? { asset, name: asset.name } : null;
} }
const view = viewOf(state.project, owner.holderId); const view = viewOf(state.project, owner.holderId);
const object = view && findObject(view, owner.objectId); const object = view && findObject(view, owner.objectId);
@@ -32,7 +33,8 @@ function ownerOf(state: EditorState, target: Extract<ValuesTarget, { kind: 'vari
export function valuesTitle(state: EditorState, target: ValuesTarget, live?: LiveVariables): string | null { export function valuesTitle(state: EditorState, target: ValuesTarget, live?: LiveVariables): string | null {
if (target.kind === 'list') return resolveList(state, target.slot, live)?.title ?? null; if (target.kind === 'list') return resolveList(state, target.slot, live)?.title ?? null;
const owner = ownerOf(state, target); const owner = ownerOf(state, target);
return owner ? `${owner.name} · Variables` : null; if (!owner) return null;
return `${owner.name} · ${owner.asset.type === 'struct' ? 'Fields' : 'Variables'}`;
} }
export class VariablesEditor extends VStack { export class VariablesEditor extends VStack {
+356
View File
@@ -0,0 +1,356 @@
/**
* Struct assets as types (#253): a struct variable, a struct field or a `Pack`/`Expand` naming one
* takes on its fields, and every change to the asset reaches all of them – values, overrides,
* ports and edges – as one undo step.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
DeleteAssetCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import {
MakeStructTypeCommand,
SetStructTypeCommand,
WithStructTypes,
} from '../../src/core/commands/structTypeCommands';
import {
AddStructFieldCommand,
AddVariableCommand,
RemoveVariableCommand,
RenameStructFieldCommand,
RenameVariableCommand,
} from '../../src/core/commands/variableCommands';
import type { Command } from '../../src/core/commands/Command';
import { STRUCT_TYPE_PARAM } from '../../src/core/graph/nested';
import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset';
import { structTypeUsages, typedSites } from '../../src/core/model/structTypes';
import type { StructDef } from '../../src/core/properties/PropertySchema';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture } from '../helpers';
/** `Loot` = { Name: "Coin", Amount: 1 } */
function lootType(f: SceneFixture): StructAsset {
const create = new CreateAssetCommand('struct', 'Loot');
f.stack.execute(create);
const id = create.asset.id;
f.stack.execute(new AddVariableCommand(id, 'Name', 'text'));
f.stack.execute(new AddVariableCommand(id, 'Amount', 'number'));
f.stack.execute(new SetAssetPropertyCommand(id, 'Name', 'Coin'));
f.stack.execute(new SetAssetPropertyCommand(id, 'Amount', 1));
return create.asset as StructAsset;
}
/** A behavior with a struct variable `drop` typed `Loot`. */
function dropper(f: SceneFixture, loot: StructAsset): BehaviorAsset {
const behavior = makeBehavior(f, 'Chest');
f.stack.execute(new AddVariableCommand(behavior.id, 'drop', 'struct'));
f.stack.execute(
new SetStructTypeCommand({ kind: 'variable', ownerId: behavior.id, key: 'drop', path: [] }, loot.id),
);
return behavior;
}
const drop = (behavior: BehaviorAsset) => 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<typeof WithStructTypes>[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<string, unknown>[] };
const stored = raw.assets.find((a) => a.id === chest.id) as {
variables: Record<string, unknown>[];
props: Record<string, unknown>;
};
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'],
]);
});
});
+2 -2
View File
@@ -94,7 +94,7 @@ export async function assets(t) {
); );
check( check(
'every asset type is offered once, each group sorted by label', '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(placeable) === String([...placeable].sort()) &&
String(library) === String([...library].sort()), String(library) === String([...library].sort()),
JSON.stringify({ placeable, library }), JSON.stringify({ placeable, library }),
@@ -453,7 +453,7 @@ export async function assets(t) {
async function addMenuOnRightClick(t) { async function addMenuOnRightClick(t) {
const { check, json, shot, click, clickText, press, undo, showTab } = t; const { check, json, shot, click, clickText, press, undo, showTab } = t;
const addRows = (rows) => 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 heading = await json("__gm.assetRowShown('group:scene')");
const sceneFolded = async () => const sceneFolded = async () =>
!(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded; !(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded;
+280
View File
@@ -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)});
`);
}
+2
View File
@@ -56,6 +56,7 @@ import { snapshots } from './50-snapshots.mjs';
import { clipboard } from './51-clipboard.mjs'; import { clipboard } from './51-clipboard.mjs';
import { hierarchyGroups } from './52-hierarchy-groups.mjs'; import { hierarchyGroups } from './52-hierarchy-groups.mjs';
import { curves } from './53-curves.mjs'; import { curves } from './53-curves.mjs';
import { structTypes } from './54-struct-types.mjs';
export const STEPS = [ export const STEPS = [
hierarchy, hierarchy,
@@ -111,4 +112,5 @@ export const STEPS = [
clipboard, clipboard,
hierarchyGroups, hierarchyGroups,
curves, curves,
structTypes,
]; ];