Define a struct once as an asset and reuse its shape (#253)
A struct used to declare its fields wherever it stood. The same shape in two variables meant typing it twice, and Pack/Expand copied it once without staying in sync. A Struct asset now holds the shape in one place, and every struct that picks it as its type follows it. - New asset type "Struct" (not placeable, group Structs, own icon). The inspector shows its fields with name, kind and default value, and "Open" shows them in the variables tab. - Struct variables and struct fields get a Type row: own fields, any Struct asset, or "Make struct type". With a type the fields come from the asset and cannot be added, renamed or removed there. The values stay per variable, overrides on placed objects still work. - Pack and Expand get the setting Type. With a type their ports are the fields of the asset and read-only in the inspector. - A change to the asset reaches every typed place in one undo step (syncStructTypes). A new field arrives with its default. A removed field goes along with its port, edge and typed value. A renamed field keeps its value in variables, overrides and on ports with edges, also through nested types. - Undo for these edits records plain assignments (Recorder), because the reach of a change is not known in advance. - Cycles such as Loot in Loot, also through another type, are refused and not offered in the picker. - Deleting the asset names how many places use it. Afterwards each place keeps its fields as its own. - Loading reshapes a stale copy in a project file to its type. - "Make struct type" creates an asset from an existing struct and links the place to it. Differs from the plan: - A changed default value applies to new fields and newly typed places only. Existing values stay. - Removing a single field shows no warning. Undo brings it back. Typed lists are left for #254. Lists stay dynamic for now. Found by e2e: after deleting or renaming the type the inspector did not rebuild and kept showing the old name. Fixed, the check was red before. Tests: - ./check.sh: 182 files, 2661 tests, CHECK EXIT 0 - GM_STEPS=1,4,21,22,54: ALL PASSED, 176 checks - 14 unit tests in tests/core/struct-types.test.ts - New e2e module 54 with 14 checks, real mouse. Module 04 counts 23 asset types. - Counter-probes: without the rename pass 4 unit tests fail, without reshaping values 8, without the cycle rule 1. In the browser, without spreading changes and with + Field despite a type, 3 checks fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4857bb75e6
commit
61bbb96a96
29 files changed
+1523
-66
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1486 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1500 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
@@ -64,6 +64,7 @@ CI. Details, the GPU requirement and the detached runner are in
|
||||
| [Scenes](https://git.nockal.com/meleki/game-maker/wiki/Scenes) | The edited scene, the start scene, and switching scenes from a graph |
|
||||
| [Scene editing](https://git.nockal.com/meleki/game-maker/wiki/Scene-editing) | Placing, z-order, hierarchy, the editor offset |
|
||||
| [Behaviors](https://git.nockal.com/meleki/game-maker/wiki/Behaviors) | Node graphs: variables, the node set, structs and lists, loops, nesting, editing |
|
||||
| [Structs](https://git.nockal.com/meleki/game-maker/wiki/Structs) | Struct assets: a named shape that struct variables, fields and `Pack`/`Expand` follow |
|
||||
| [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Shapes (box, circle, capsule, polygon), collision events, blocking movement, one-way colliders and raycasts |
|
||||
| [Physics](https://git.nockal.com/meleki/game-maker/wiki/Physics) | Rigidbodies, the collider material, gravity, the fixed step |
|
||||
| [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, nine-slice, the filter, upload limits |
|
||||
|
||||
@@ -49,6 +49,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
|
||||
case 'variables':
|
||||
return { ...base, type: 'variables', variables: [] };
|
||||
case 'struct':
|
||||
return { ...base, type: 'struct', variables: [] };
|
||||
case 'collider':
|
||||
return { ...base, type: 'collider' };
|
||||
case 'rigidbody':
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BehaviorAsset, VariablesAsset } from '../model/Asset';
|
||||
import type { VariableOwnerAsset } from '../model/Asset';
|
||||
import type { Project } from '../model/Project';
|
||||
|
||||
function firstFree(taken: ReadonlySet<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. */
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
StateMachineAsset,
|
||||
TilemapAsset,
|
||||
TilemapLayer,
|
||||
VariablesAsset,
|
||||
VariableOwnerAsset,
|
||||
VectorArtAsset,
|
||||
VectorShape,
|
||||
} from '../model/Asset';
|
||||
@@ -84,10 +84,13 @@ export function requireBehavior(project: Project, assetId: string): BehaviorAsse
|
||||
return asset;
|
||||
}
|
||||
|
||||
/** An asset that declares variables: a behavior, or a Variables asset shared by the whole run. */
|
||||
export function requireVariableOwner(project: Project, assetId: string): BehaviorAsset | VariablesAsset {
|
||||
/**
|
||||
* An asset that declares variables: a behavior, a Variables asset shared by the whole run, or a
|
||||
* Struct asset, whose fields are declared the same way (#253).
|
||||
*/
|
||||
export function requireVariableOwner(project: Project, assetId: string): VariableOwnerAsset {
|
||||
const asset = findAsset(project, assetId);
|
||||
if (asset?.type !== 'behavior' && asset?.type !== 'variables')
|
||||
if (asset?.type !== 'behavior' && asset?.type !== 'variables' && asset?.type !== 'struct')
|
||||
throw new CommandError(`Variable owner ${assetId} not found`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
} from '../model/Properties';
|
||||
import { isKeptVariable, setKeptVariable } from '../model/Asset';
|
||||
import { isKeptVariable, setKeptVariable, type VariableOwnerAsset } from '../model/Asset';
|
||||
import { overrideSites } from '../model/instance';
|
||||
import type { Project } from '../model/Project';
|
||||
import type { VariableKind } from '../properties/kinds';
|
||||
@@ -42,7 +42,7 @@ export class AddVariableCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireVariableOwner(project, this.assetId);
|
||||
if (!isValidVariableName(this.def.key)) throw new CommandError(INVALID_NAME);
|
||||
if (!isValidName(asset, this.def.key)) throw new CommandError(INVALID_NAME);
|
||||
if (asset.variables.some((v) => v.key === this.def.key))
|
||||
throw new CommandError(`Variable "${this.def.key}" already exists`);
|
||||
asset.variables.push(this.def);
|
||||
@@ -139,7 +139,7 @@ export class RenameVariableCommand extends RenameCommand {
|
||||
protected rename(project: Project, from: string, to: string): void {
|
||||
const asset = requireVariableOwner(project, this.assetId);
|
||||
if (from === to) return;
|
||||
if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME);
|
||||
if (!isValidName(asset, to)) throw new CommandError(INVALID_NAME);
|
||||
const def = asset.variables.find((v) => v.key === from);
|
||||
if (!def) throw new CommandError(`Variable "${from}" not found`);
|
||||
if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`);
|
||||
@@ -187,6 +187,14 @@ export class SetVariableKeptCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A struct's fields are no properties of anything placed, so the reserved property names are free
|
||||
* there – as they are for a struct field.
|
||||
*/
|
||||
function isValidName(asset: VariableOwnerAsset, name: string): boolean {
|
||||
return asset.type === 'struct' ? isValidFieldName(name) : isValidVariableName(name);
|
||||
}
|
||||
|
||||
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
|
||||
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
|
||||
|
||||
|
||||
@@ -35,6 +35,17 @@ export const VARIABLES_SETTING: PropertyDef = {
|
||||
assetTypes: ['variables'],
|
||||
};
|
||||
|
||||
export const STRUCT_TYPE_PARAM = 'structType';
|
||||
|
||||
/** The Struct asset whose fields a `Pack` or `Expand` takes as its ports (#253); empty is by hand. */
|
||||
export const STRUCT_TYPE_SETTING: PropertyDef = {
|
||||
key: STRUCT_TYPE_PARAM,
|
||||
label: 'Type',
|
||||
kind: 'asset',
|
||||
default: '',
|
||||
assetTypes: ['struct'],
|
||||
};
|
||||
|
||||
export function variablesSourceOf(params: PropertyBag): string {
|
||||
return textOf(params[VARIABLES_PARAM] ?? '');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asStruct, unplaced, type Value } from '../../values';
|
||||
import { STRUCT_TYPE_SETTING } from '../../nested';
|
||||
import { struct } from '../shared';
|
||||
|
||||
/**
|
||||
@@ -16,7 +17,7 @@ export const ExpandStruct: NodeDef = {
|
||||
'reads as empty.',
|
||||
inputs: [struct('value')],
|
||||
outputs: [],
|
||||
params: [],
|
||||
params: [STRUCT_TYPE_SETTING],
|
||||
declaresPorts: 'outputs',
|
||||
// A declared output the struct has no field for is simply absent from the cache, and reads as empty.
|
||||
run: (_ctx, inputs) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FieldValue } from '../../../model/Properties';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asField } from '../../values';
|
||||
import { STRUCT_TYPE_SETTING } from '../../nested';
|
||||
import { struct } from '../shared';
|
||||
|
||||
/**
|
||||
@@ -14,7 +15,7 @@ export const PackStruct: NodeDef = {
|
||||
description: 'Bundles named values into one struct, one input per field.',
|
||||
inputs: [],
|
||||
outputs: [struct('value')],
|
||||
params: [],
|
||||
params: [STRUCT_TYPE_SETTING],
|
||||
declaresPorts: 'inputs',
|
||||
run: (_ctx, inputs) => {
|
||||
const fields: Record<string, FieldValue> = {};
|
||||
|
||||
@@ -77,6 +77,16 @@ export interface VariablesAsset extends AssetBase {
|
||||
scope?: Scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named shape for structs (#253). Its fields are declared as a Variables asset declares its
|
||||
* variables, so the same rows and commands edit them, and `props` holds their defaults. A struct
|
||||
* variable or field naming it in `structType` takes on these fields (see `model/structTypes.ts`).
|
||||
*/
|
||||
export interface StructAsset extends AssetBase {
|
||||
type: 'struct';
|
||||
variables: PropertyDef[];
|
||||
}
|
||||
|
||||
/** Placed as a child of the object it belongs to; its position is the offset from that body. */
|
||||
export interface ColliderAsset extends AssetBase {
|
||||
type: 'collider';
|
||||
@@ -458,6 +468,7 @@ export type Asset =
|
||||
| SceneAsset
|
||||
| BehaviorAsset
|
||||
| VariablesAsset
|
||||
| StructAsset
|
||||
| ColliderAsset
|
||||
| RigidbodyAsset
|
||||
| ContainerAsset
|
||||
@@ -475,6 +486,13 @@ export type Asset =
|
||||
| SoundAsset
|
||||
| ParticlesAsset;
|
||||
|
||||
/** The assets whose `variables` are declared by the user – see `requireVariableOwner`. */
|
||||
export type VariableOwnerAsset = BehaviorAsset | VariablesAsset | StructAsset;
|
||||
|
||||
export function isVariableOwner(asset: Asset | undefined): asset is VariableOwnerAsset {
|
||||
return asset?.type === 'behavior' || asset?.type === 'variables' || asset?.type === 'struct';
|
||||
}
|
||||
|
||||
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
|
||||
export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset | ParticlesAsset;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export const ASSET_TYPES = [
|
||||
'text',
|
||||
'behavior',
|
||||
'variables',
|
||||
'struct',
|
||||
'collider',
|
||||
'rigidbody',
|
||||
'container',
|
||||
@@ -32,6 +33,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
text: 'Text',
|
||||
behavior: 'Behavior',
|
||||
variables: 'Variables',
|
||||
struct: 'Struct',
|
||||
collider: 'Collider',
|
||||
rigidbody: 'Rigidbody',
|
||||
container: 'Container',
|
||||
@@ -57,6 +59,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
* sound is nowhere on screen at all – a graph plays it, so there is nothing to place. A tile is
|
||||
* what a tilemap draws with, so it is placed no more directly than a texture is – what is placed
|
||||
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
|
||||
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
@@ -73,6 +76,7 @@ export type PlaceableAssetType = Exclude<
|
||||
| 'font'
|
||||
| 'sound'
|
||||
| 'variables'
|
||||
| 'struct'
|
||||
>;
|
||||
|
||||
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
@@ -89,6 +93,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'font',
|
||||
'sound',
|
||||
'variables',
|
||||
'struct',
|
||||
]);
|
||||
|
||||
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -168,7 +168,8 @@ export type PropertyDef =
|
||||
| { key: string; label: string; kind: 'list'; default: ListValue }
|
||||
/**
|
||||
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
|
||||
* keep its shape while single fields are written (`mergeStruct`).
|
||||
* keep its shape while single fields are written (`mergeStruct`). With a `structType` the fields
|
||||
* are a copy of that Struct asset's (#253), kept in step by `syncStructTypes`.
|
||||
*/
|
||||
| {
|
||||
key: string;
|
||||
@@ -176,6 +177,7 @@ export type PropertyDef =
|
||||
kind: 'struct';
|
||||
default: StructValue;
|
||||
fields: readonly StructFieldDef[];
|
||||
structType?: string;
|
||||
}
|
||||
/** An object variable holds a live handle during a run, and nothing at rest (`NO_OBJECT`). */
|
||||
| ObjectDef;
|
||||
@@ -205,6 +207,7 @@ export interface StructFieldStructDef {
|
||||
key: string;
|
||||
kind: 'struct';
|
||||
fields: readonly StructFieldDef[];
|
||||
structType?: string;
|
||||
}
|
||||
|
||||
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef | StructFieldStructDef;
|
||||
@@ -513,6 +516,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
],
|
||||
// Every row is declared on the asset itself – see `schemaForBag`.
|
||||
variables: [],
|
||||
struct: [],
|
||||
// The states are the asset's graph (see StateMachineAsset), not a property.
|
||||
statemachine: [],
|
||||
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
|
||||
@@ -694,7 +698,7 @@ export function schemaForObject(project: Project, asset: Asset, object: SceneObj
|
||||
function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {
|
||||
if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables];
|
||||
// Nothing but declarations: the starting values live in `props` under the very same keys.
|
||||
if (asset.type === 'variables') return asset.variables;
|
||||
if (asset.type === 'variables' || asset.type === 'struct') return asset.variables;
|
||||
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
|
||||
// Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up.
|
||||
if (asset.type === 'particles') {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { deviatesFromAsset, isModified } from './resolve';
|
||||
*/
|
||||
export function markedAssetSchema(project: Project, asset: Asset): PropertySchema {
|
||||
// Declared variables are drawn by the inspector's own section, not as asset rows.
|
||||
return asset.type === 'behavior' || asset.type === 'variables'
|
||||
return asset.type === 'behavior' || asset.type === 'variables' || asset.type === 'struct'
|
||||
? schemaFor(asset.type)
|
||||
: schemaForAsset(project, asset);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ import { newId } from '../ids';
|
||||
import { SHAPE_FILL_DEFAULT } from '../model/vectorart';
|
||||
import { sanitizedCurve } from '../model/curve';
|
||||
import { hasParentCycle } from '../model/walk';
|
||||
import { syncStructTypes } from '../model/structTypes';
|
||||
import { Recorder } from '../util/recorder';
|
||||
import {
|
||||
DECLARED_PORT_KINDS,
|
||||
FIELD_KINDS,
|
||||
@@ -114,7 +116,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
|
||||
rawProps.set(a.id, asRecord(a.props));
|
||||
if (a.type === 'sprite' || a.type === 'particles') wearers.set(a.id, a.type);
|
||||
if (a.type === 'behavior' || a.type === 'variables') {
|
||||
if (a.type === 'behavior' || a.type === 'variables' || a.type === 'struct') {
|
||||
const vars = normalizeVariables(a.variables);
|
||||
variables.set(a.id, vars);
|
||||
// The full schema, not just the common rows: this is what pass 2 coerces `props` against, and
|
||||
@@ -191,6 +193,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
if (readSharedScope(a.scope) === 'scene') shared.scope = 'scene';
|
||||
else if (a.persistent === true) shared.persistent = true;
|
||||
assets.push(shared);
|
||||
} else if (type === 'struct') {
|
||||
assets.push({ id, type, name, props, variables: variables.get(id) ?? [] });
|
||||
} else if (type === 'statemachine') {
|
||||
const machine: StateMachineAsset = {
|
||||
id,
|
||||
@@ -271,6 +275,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
gameScale: gameScaleOf(r),
|
||||
sceneVisibility: sceneVisibilityOf(r),
|
||||
};
|
||||
// A hand-edited file may have copies that no longer match their Struct asset.
|
||||
syncStructTypes(project, new Recorder());
|
||||
return project;
|
||||
}
|
||||
|
||||
@@ -386,6 +392,7 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
const fields = normalizeStructFields(v.fields);
|
||||
const def = variableDef(key, kind, undefined, fields);
|
||||
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
|
||||
if (def.kind === 'struct') readStructType(def, v.structType);
|
||||
defs.push(def);
|
||||
}
|
||||
return defs;
|
||||
@@ -410,10 +417,19 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
||||
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
|
||||
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
|
||||
if (f.kind === 'list') return { key, kind: 'list' };
|
||||
if (f.kind === 'struct') return { key, kind: 'struct', fields: normalizeStructFields(f.fields) };
|
||||
if (f.kind === 'struct') {
|
||||
const field: StructFieldDef = { key, kind: 'struct', fields: normalizeStructFields(f.fields) };
|
||||
readStructType(field, f.structType);
|
||||
return field;
|
||||
}
|
||||
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
|
||||
}
|
||||
|
||||
/** A dangling id is kept, as a curve's is: the struct keeps the fields it last had (#253). */
|
||||
function readStructType(def: { structType?: string }, raw: unknown): void {
|
||||
if (typeof raw === 'string' && raw !== '') def.structType = raw;
|
||||
}
|
||||
|
||||
function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolean): DeclaredPort[] {
|
||||
const ports: DeclaredPort[] = [];
|
||||
const seen = new Set(fixed);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+11
-7
@@ -16,6 +16,7 @@ import { RemoveNodeCommand } from '../core/commands/graphCommands';
|
||||
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import { curveUsages } from '../core/model/curve';
|
||||
import { structTypeUsages } from '../core/model/structTypes';
|
||||
import { generatesPictures } from '../core/model/generated';
|
||||
import { isTemplateContent } from '../core/model/instance';
|
||||
import type { Command } from '../core/commands/Command';
|
||||
@@ -133,18 +134,21 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
const animations = picture ? animationsUsing(state.project, ids).length : 0;
|
||||
const tiles = picture ? tilesUsing(state.project, ids).length : 0;
|
||||
const eased = asset.type === 'curve' ? curveUsages(state.project, assetId) : { keys: 0, nodes: 0 };
|
||||
const typed = asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, nodes: 0 };
|
||||
const detail =
|
||||
asset.type === 'scene'
|
||||
? `The scene and its ${asset.objects.length} object(s) are removed.`
|
||||
: eased.keys > 0 || eased.nodes > 0
|
||||
? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.`
|
||||
: references > 0 || animations > 0 || tiles > 0
|
||||
? wornDetail(references, animations, tiles, asset.type)
|
||||
: usages === 0
|
||||
? 'This cannot be undone with Ctrl+Z after reload.'
|
||||
: asset.type === 'container'
|
||||
? `${usages} placed container(s) are removed together with their content.`
|
||||
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
|
||||
: typed.places > 0 || typed.nodes > 0
|
||||
? `${typed.places} struct(s) and ${typed.nodes} Pack/Expand node(s) use it and keep its fields as their own.`
|
||||
: references > 0 || animations > 0 || tiles > 0
|
||||
? wornDetail(references, animations, tiles, asset.type)
|
||||
: usages === 0
|
||||
? 'This cannot be undone with Ctrl+Z after reload.'
|
||||
: asset.type === 'container'
|
||||
? `${usages} placed container(s) are removed together with their content.`
|
||||
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
|
||||
new Dialog(`Delete "${asset.name}"?`, detail, [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
|
||||
|
||||
@@ -21,11 +21,18 @@ import {
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import {
|
||||
type Asset,
|
||||
type BehaviorAsset,
|
||||
isKeptVariable,
|
||||
isVariableOwner,
|
||||
type StateMachineAsset,
|
||||
type VariableOwnerAsset,
|
||||
type VariablesAsset,
|
||||
} from '../../core/model/Asset';
|
||||
import {
|
||||
MakeStructTypeCommand,
|
||||
SetStructTypeCommand,
|
||||
WithStructTypes,
|
||||
} from '../../core/commands/structTypeCommands';
|
||||
import { containsType, type FieldRename, structAssetOf } from '../../core/model/structTypes';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
|
||||
import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope';
|
||||
@@ -38,7 +45,7 @@ import {
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { markedAssetSchema } from '../../core/properties/deviation';
|
||||
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { Button, Dropdown, HStack, Label, Menu, Theme } from '../../ui';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuEntry, Theme } from '../../ui';
|
||||
import { FontPreview } from '../font/FontPreview';
|
||||
import { clearSaved, hasSaved } from '../saves';
|
||||
import { SoundPreview } from '../sound/SoundPreview';
|
||||
@@ -47,7 +54,7 @@ import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { buildShapeListSection, shapeListSignature } from './shapeSection';
|
||||
import { buildVariable, kindMenu, type VariableEditing } from './variableRows';
|
||||
import { buildVariable, kindMenu, type StructTyping, type VariableEditing } from './variableRows';
|
||||
import { listSource, previewingHandlers, structFields } from './previewHandlers';
|
||||
import { assetValue } from './valueSpecs';
|
||||
|
||||
@@ -73,13 +80,27 @@ export function assetSignature(project: Project, assetId: string): string {
|
||||
const shapes = asset.type === 'vectorart' ? `|${shapeListSignature(asset)}` : '';
|
||||
const values = resolveAssetProps(project, asset);
|
||||
const rows = schemaForAsset(project, asset).filter((def) => isRowShown(def, values));
|
||||
return rows.map(defSignature).join(',') + machine + kept + keeps + shapes;
|
||||
// A Type row shows the name of its Struct asset, or "Own fields" once that asset is gone (#253).
|
||||
const types = `|types:${typeNames(project, rows)}`;
|
||||
return rows.map(defSignature).join(',') + machine + kept + keeps + shapes + types;
|
||||
}
|
||||
|
||||
function typeNames(project: Project, rows: readonly (PropertyDef | StructFieldDef)[]): string {
|
||||
return rows
|
||||
.flatMap((def): string[] =>
|
||||
def.kind === 'struct'
|
||||
? [structAssetOf(project, def.structType)?.name ?? '', typeNames(project, def.fields)]
|
||||
: [],
|
||||
)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
export function defSignature(def: PropertyDef | StructFieldDef): string {
|
||||
// Only a struct has rows of its own, at any depth; a list's items rebuild themselves (#252).
|
||||
const fields = def.kind === 'struct' ? def.fields.map(defSignature).join('+') : '';
|
||||
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
|
||||
// A type decides which of those rows may be changed, and names itself in a row (#253).
|
||||
const type = def.kind === 'struct' && def.structType ? `@${def.structType}` : '';
|
||||
return `${def.key}:${def.kind}${type}${fields ? `(${fields})` : ''}`;
|
||||
}
|
||||
|
||||
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
|
||||
@@ -95,7 +116,7 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void {
|
||||
});
|
||||
|
||||
buildProps(host, asset, markedAssetSchema(host.state.project, asset));
|
||||
if (asset.type === 'behavior' || asset.type === 'variables') buildVariables(host, asset);
|
||||
if (isVariableOwner(asset)) buildVariables(host, asset);
|
||||
if (asset.type === 'variables') {
|
||||
buildVariablesScope(host, asset);
|
||||
buildPersistence(host, asset);
|
||||
@@ -196,21 +217,21 @@ function fieldsAt(fields: readonly StructFieldDef[], path: readonly string[]): r
|
||||
|
||||
/**
|
||||
* Declared variables, each with its declaration editable – plus an "add" menu, and in the inspector
|
||||
* the way to the same rows in a Variables tab (#252).
|
||||
* the way to the same rows in a Variables tab (#252). A Struct asset's fields are declared the same
|
||||
* way (#253); every change to them is carried on to the places of that type.
|
||||
*/
|
||||
export function buildVariables(
|
||||
host: InspectorHost,
|
||||
asset: BehaviorAsset | VariablesAsset,
|
||||
inTab = false,
|
||||
): void {
|
||||
export function buildVariables(host: InspectorHost, asset: VariableOwnerAsset, inTab = false): void {
|
||||
const isType = asset.type === 'struct';
|
||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
header.add(new Label('VARIABLES', { style: 'title' }), 1);
|
||||
const add = new Dropdown('+ Variable', { variant: 'ghost' });
|
||||
header.add(new Label(isType ? 'FIELDS' : 'VARIABLES', { style: 'title' }), 1);
|
||||
const add = new Dropdown(isType ? '+ Field' : '+ Variable', { variant: 'ghost' });
|
||||
const run = (command: Command, rename?: FieldRename): void =>
|
||||
void host.state.execute(isType ? new WithStructTypes(command, rename) : command);
|
||||
add.onClick = () => {
|
||||
const items = kindMenu(VARIABLE_KINDS, (kind) => {
|
||||
const current = findAsset(host.state.project, asset.id);
|
||||
if (current?.type === 'behavior' || current?.type === 'variables')
|
||||
host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
|
||||
if (isVariableOwner(current))
|
||||
run(new AddVariableCommand(asset.id, nextVariableName(current, isType ? 'field' : 'var'), kind));
|
||||
});
|
||||
new Menu(items).openBelow(add);
|
||||
};
|
||||
@@ -218,15 +239,19 @@ export function buildVariables(
|
||||
if (!inTab) header.add(openVariablesButton(host, { kind: 'asset', assetId: asset.id }));
|
||||
host.body.add(header);
|
||||
|
||||
if (asset.variables.length === 0) addNote(host, 'No variables yet.');
|
||||
if (asset.variables.length === 0) addNote(host, isType ? 'No fields yet.' : 'No variables yet.');
|
||||
const values = shownValues(host, asset);
|
||||
const run = (command: Command): void => void host.state.execute(command);
|
||||
const renamed = (path: readonly string[], from: string, to: string): FieldRename | undefined =>
|
||||
isType ? { structType: asset.id, path, from, to } : undefined;
|
||||
for (const def of asset.variables) {
|
||||
const edit: VariableEditing = {
|
||||
rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next)),
|
||||
rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next), renamed([], def.key, next)),
|
||||
remove: () => run(new RemoveVariableCommand(asset.id, def.key)),
|
||||
renameField: (path, field, next) =>
|
||||
run(new RenameStructFieldCommand(asset.id, def.key, field.key, next, path)),
|
||||
run(
|
||||
new RenameStructFieldCommand(asset.id, def.key, field.key, next, path),
|
||||
renamed([def.key, ...path], field.key, next),
|
||||
),
|
||||
removeField: (path, field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key, path)),
|
||||
addField: (path, kind) =>
|
||||
run(
|
||||
@@ -246,13 +271,49 @@ export function buildVariables(
|
||||
}
|
||||
: undefined;
|
||||
const value = values[def.key] ?? def.default;
|
||||
const typing = structTyping(host, asset, def.key);
|
||||
host.fields.set(
|
||||
def.key,
|
||||
buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit, keep }),
|
||||
buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit, typing, keep }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The type picker of a struct variable and of its struct fields (#253): own fields, a Struct asset,
|
||||
* or a new Struct asset made from the fields it holds now. Inside a Struct asset the types that
|
||||
* hold that asset are not offered – the default of such a field would never end.
|
||||
*/
|
||||
function structTyping(host: InspectorHost, owner: VariableOwnerAsset, key: string): StructTyping {
|
||||
const project = () => host.state.project;
|
||||
const run = (command: Command): void => void host.state.execute(command);
|
||||
return {
|
||||
typeName: (structType) => structAssetOf(project(), structType)?.name ?? null,
|
||||
menu: (path, structType) => {
|
||||
const current = structAssetOf(project(), structType);
|
||||
const target = { kind: 'variable', ownerId: owner.id, key, path } as const;
|
||||
const types = project().assets.filter(
|
||||
(a) => a.type === 'struct' && !(owner.type === 'struct' && containsType(project(), a.id, owner.id)),
|
||||
);
|
||||
const entries: MenuEntry[] = [
|
||||
{ label: 'Own fields', current: !current, onSelect: () => run(new SetStructTypeCommand(target, '')) },
|
||||
...types.map((type) => ({
|
||||
label: type.name,
|
||||
current: type.id === current?.id,
|
||||
onSelect: () => run(new SetStructTypeCommand(target, type.id)),
|
||||
})),
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Make struct type',
|
||||
disabled: current !== undefined,
|
||||
onSelect: () => run(new MakeStructTypeCommand(owner.id, key, path)),
|
||||
},
|
||||
];
|
||||
return entries;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens the variables of `owner` in a Variables tab of their own, or brings up the one they have. */
|
||||
export function openVariablesButton(host: InspectorHost, owner: VariablesOwner): Button {
|
||||
return new Button('Open', {
|
||||
|
||||
@@ -18,13 +18,17 @@ import {
|
||||
resolveDef,
|
||||
STATE_TRANSITION,
|
||||
stateName,
|
||||
STRUCT_TYPE_PARAM,
|
||||
STRUCT_TYPE_SETTING,
|
||||
TRANSITION_PARAM,
|
||||
variablesSourceOf,
|
||||
} from '../../core/graph';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { findGraphAsset, type Project } from '../../core/model/Project';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import { isTypedNode, nodeStructType, structAssetOf } from '../../core/model/structTypes';
|
||||
import { SetStructTypeCommand } from '../../core/commands/structTypeCommands';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Label } from '../../ui';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
@@ -63,7 +67,8 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
|
||||
.join(',')
|
||||
: `${textOf(node.params[TRANSITION_PARAM] ?? '')}:${textOf(node.params[MACHINE_PORT] ?? '')}`;
|
||||
// The source names which declarations the port menu offers, so switching it has to rebuild.
|
||||
const source = variablesSourceOf(node.params);
|
||||
const typeName = structAssetOf(project, nodeStructType(node))?.name ?? '';
|
||||
const source = `${variablesSourceOf(node.params)}:${nodeStructType(node)}:${typeName}`;
|
||||
const choices = variableChoices(project, assetId, node)
|
||||
.map((v) => v.key)
|
||||
.join(',');
|
||||
@@ -94,25 +99,34 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
addNote(host, def.description);
|
||||
const problem = callProblem(node, graphLookup(host.state.project), assetId);
|
||||
if (problem) addNote(host, problem, 'warning');
|
||||
const addSetting = (param: PropertyDef) => {
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
param,
|
||||
node.params[param.key] ?? param.default,
|
||||
paramHandlers(host, assetId, nodeId, param),
|
||||
);
|
||||
const addSetting = (param: PropertyDef, handlers = paramHandlers(host, assetId, nodeId, param)) => {
|
||||
const row = createFieldRow(host, param, node.params[param.key] ?? param.default, handlers);
|
||||
host.fields.set(`param:${param.key}`, row);
|
||||
host.body.add(row.row);
|
||||
};
|
||||
// Both draw their settings inside the port section: a setting decides what the menu offers.
|
||||
const ownSettings = declaresPropertyPorts(node.type) || declaresVariablePorts(node.type);
|
||||
// The type of a Pack/Expand decides its fields (#253), so it stands above them – and picking one
|
||||
// rewrites the ports, which a plain param edit would not.
|
||||
const typed = isTypedNode(node);
|
||||
if (typed) {
|
||||
const pick = (value: PropertyValue) => {
|
||||
const target = { kind: 'node', assetId, nodeId } as const;
|
||||
host.state.execute(new SetStructTypeCommand(target, textOf(value)));
|
||||
};
|
||||
addSetting(STRUCT_TYPE_SETTING, { onInput: () => {}, onChange: pick });
|
||||
}
|
||||
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, addSetting);
|
||||
else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting);
|
||||
else if (def.declaresPorts) buildPortSection(host, assetId, node, variablesOf(asset));
|
||||
else if (def.declaresPorts) {
|
||||
const typeName = structAssetOf(host.state.project, nodeStructType(node))?.name ?? null;
|
||||
buildPortSection(host, assetId, node, variablesOf(asset), typeName);
|
||||
}
|
||||
|
||||
if (!ownSettings && def.params.length > 0) {
|
||||
const settings = def.params.filter((param) => !(typed && param.key === STRUCT_TYPE_PARAM));
|
||||
if (!ownSettings && settings.length > 0) {
|
||||
addTitle(host, 'SETTINGS');
|
||||
for (const param of def.params) {
|
||||
for (const param of settings) {
|
||||
if (param.key === TRANSITION_PARAM && node.type === STATE_TRANSITION)
|
||||
buildTransitionPicker(host, assetId, node, asset.graph);
|
||||
else addSetting(param);
|
||||
|
||||
@@ -202,6 +202,7 @@ export function buildPortSection(
|
||||
assetId: string,
|
||||
node: GraphNode,
|
||||
variables: readonly PropertyDef[] = [],
|
||||
typeName: string | null = null,
|
||||
): void {
|
||||
const onStart = node.type === BEHAVIOR_START;
|
||||
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
|
||||
@@ -209,6 +210,19 @@ export function buildPortSection(
|
||||
|
||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1);
|
||||
// With a Struct asset as its type the fields are that asset's (#253): changed there, not here.
|
||||
if (typeName !== null) {
|
||||
host.body.add(header);
|
||||
addNote(host, `The fields of ${typeName}. Change them there.`, 'small');
|
||||
for (const port of node.ports ?? []) {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
row.add(markLabel(port.kind));
|
||||
row.add(new Label(port.key, { style: 'muted', width: NAME_WIDTH, padding: Theme.fieldPadding }), 1);
|
||||
host.body.add(row);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (noun === 'Field') addShapePicker(host, assetId, node, variables, header);
|
||||
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Icon,
|
||||
Label,
|
||||
Menu,
|
||||
type MenuEntry,
|
||||
type MenuItem,
|
||||
Spacer,
|
||||
TextField,
|
||||
@@ -56,8 +57,19 @@ export interface VariableKeep {
|
||||
set: (on: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of a struct (#253), offered where its declaration is edited. `path` names the struct
|
||||
* from the variable down, as for `VariableEditing`.
|
||||
*/
|
||||
export interface StructTyping {
|
||||
/** The Struct asset the struct follows, or null while it holds its own fields – a deleted type too. */
|
||||
typeName: (structType: string | undefined) => string | null;
|
||||
menu: (path: readonly string[], structType: string | undefined) => MenuEntry[];
|
||||
}
|
||||
|
||||
export interface VariableOptions {
|
||||
edit?: VariableEditing;
|
||||
typing?: StructTyping;
|
||||
keep?: VariableKeep;
|
||||
/** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */
|
||||
actions?: RowActions;
|
||||
@@ -74,14 +86,16 @@ export function buildVariable(
|
||||
handlers: FieldHandlers,
|
||||
options: VariableOptions = {},
|
||||
): FieldRow {
|
||||
const { edit, keep, actions } = options;
|
||||
const { edit, typing, keep, actions } = options;
|
||||
const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
|
||||
const head = gridRow(0, markLabel(def.kind), name);
|
||||
let setValue: (next: PropertyValue) => void = () => {};
|
||||
let below: Widget | undefined;
|
||||
// A struct with a type has the type's fields: they are changed on the Struct asset, not here.
|
||||
const typed = def.kind === 'struct' ? (typing?.typeName(def.structType) ?? null) : null;
|
||||
if (def.kind === 'struct') {
|
||||
head.add(new Spacer(), 1);
|
||||
if (edit) head.add(addFieldMenu(edit, []));
|
||||
if (edit && typed === null) head.add(addFieldMenu(edit, []));
|
||||
} else {
|
||||
const field = createField(host, def, value, handlers);
|
||||
setValue = addValue(head, field);
|
||||
@@ -94,8 +108,18 @@ export function buildVariable(
|
||||
if (below) host.body.add(indentedBy(below, 1));
|
||||
|
||||
if (def.kind === 'struct') {
|
||||
if (typing) host.body.add(typeRow(1, typed, () => typing.menu([], def.structType)));
|
||||
if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small');
|
||||
setValue = structFieldRows(host, def.fields, [], value, handlers, edit);
|
||||
const own = typed === null;
|
||||
setValue = structFieldRows(
|
||||
host,
|
||||
def.fields,
|
||||
[],
|
||||
value,
|
||||
handlers,
|
||||
own ? edit : undefined,
|
||||
own ? typing : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -132,17 +156,33 @@ function structFieldRows(
|
||||
value: PropertyValue,
|
||||
handlers: FieldHandlers,
|
||||
edit: VariableEditing | undefined,
|
||||
typing: StructTyping | undefined,
|
||||
): (next: PropertyValue) => void {
|
||||
const setters = new Map<string, (item: PropertyValue) => void>();
|
||||
for (const field of fields) {
|
||||
const row = fieldRow(field, path, edit);
|
||||
const item = structFieldValue(value, field);
|
||||
if (field.kind === 'struct') {
|
||||
const inner = [...path, field.key];
|
||||
const typed = typing?.typeName(field.structType) ?? null;
|
||||
row.add(new Spacer(), 1);
|
||||
if (edit) row.add(addFieldMenu(edit, [...path, field.key]));
|
||||
if (edit && typed === null) row.add(addFieldMenu(edit, inner));
|
||||
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
|
||||
host.body.add(row);
|
||||
setters.set(field.key, structFieldRows(host, field.fields, [...path, field.key], item, handlers, edit));
|
||||
if (typing) host.body.add(typeRow(inner.length + 1, typed, () => typing.menu(inner, field.structType)));
|
||||
const own = typed === null;
|
||||
setters.set(
|
||||
field.key,
|
||||
structFieldRows(
|
||||
host,
|
||||
field.fields,
|
||||
inner,
|
||||
item,
|
||||
handlers,
|
||||
own ? edit : undefined,
|
||||
own ? typing : undefined,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY;
|
||||
@@ -163,6 +203,16 @@ function fieldRow(field: StructFieldDef, path: readonly string[], edit: Variable
|
||||
return gridRow(depth, markLabel(field.kind), name);
|
||||
}
|
||||
|
||||
/** Which Struct asset a struct follows, as a row of its own under the struct's head. */
|
||||
function typeRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack {
|
||||
const picker = new Dropdown(typeName ?? 'Own fields', { variant: 'ghost' });
|
||||
picker.onClick = () => new Menu(menu()).openBelow(picker);
|
||||
const name = new Label('Type', { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding });
|
||||
const row = gridRow(depth, new Spacer(MARK_WIDTH), name);
|
||||
row.add(picker, 1);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */
|
||||
function gridRow(depth: number, mark: Widget, name: Widget): HStack {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
|
||||
@@ -12,6 +12,7 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
|
||||
text: 'Texts',
|
||||
behavior: 'Behaviors',
|
||||
variables: 'Variables',
|
||||
struct: 'Structs',
|
||||
collider: 'Colliders',
|
||||
rigidbody: 'Rigidbodies',
|
||||
container: 'Containers',
|
||||
|
||||
@@ -23,6 +23,7 @@ import shader from './icons/shader.svg?raw';
|
||||
import sound from './icons/sound.svg?raw';
|
||||
import sprite from './icons/sprite.svg?raw';
|
||||
import statemachine from './icons/statemachine.svg?raw';
|
||||
import struct from './icons/struct.svg?raw';
|
||||
import text from './icons/text.svg?raw';
|
||||
import texture from './icons/texture.svg?raw';
|
||||
import tile from './icons/tile.svg?raw';
|
||||
@@ -48,6 +49,7 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
|
||||
sound,
|
||||
sprite,
|
||||
statemachine,
|
||||
struct,
|
||||
text,
|
||||
texture,
|
||||
tile,
|
||||
|
||||
@@ -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,6 +4,7 @@
|
||||
* One editor for every such tab, pointed at the target of the one showing, like the asset editors.
|
||||
*/
|
||||
import type { Container } from 'pixi.js';
|
||||
import { isVariableOwner } from '../../core/model/Asset';
|
||||
import { findObject } from '../../core/model/hierarchy';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui';
|
||||
@@ -20,7 +21,7 @@ function ownerOf(state: EditorState, target: Extract<ValuesTarget, { kind: 'vari
|
||||
const { owner } = target;
|
||||
if (owner.kind === 'asset') {
|
||||
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 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 {
|
||||
if (target.kind === 'list') return resolveList(state, target.slot, live)?.title ?? null;
|
||||
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 {
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,7 @@ export async function assets(t) {
|
||||
);
|
||||
check(
|
||||
'every asset type is offered once, each group sorted by label',
|
||||
placeable.length + library.length === 22 &&
|
||||
placeable.length + library.length === 23 &&
|
||||
String(placeable) === String([...placeable].sort()) &&
|
||||
String(library) === String([...library].sort()),
|
||||
JSON.stringify({ placeable, library }),
|
||||
@@ -453,7 +453,7 @@ export async function assets(t) {
|
||||
async function addMenuOnRightClick(t) {
|
||||
const { check, json, shot, click, clickText, press, undo, showTab } = t;
|
||||
const addRows = (rows) =>
|
||||
Array.isArray(rows) && rows.filter(Boolean).length === 22 && rows.includes('Sprite');
|
||||
Array.isArray(rows) && rows.filter(Boolean).length === 23 && rows.includes('Sprite');
|
||||
const heading = await json("__gm.assetRowShown('group:scene')");
|
||||
const sceneFolded = async () =>
|
||||
!(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded;
|
||||
|
||||
@@ -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)});
|
||||
`);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import { snapshots } from './50-snapshots.mjs';
|
||||
import { clipboard } from './51-clipboard.mjs';
|
||||
import { hierarchyGroups } from './52-hierarchy-groups.mjs';
|
||||
import { curves } from './53-curves.mjs';
|
||||
import { structTypes } from './54-struct-types.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -111,4 +112,5 @@ export const STEPS = [
|
||||
clipboard,
|
||||
hierarchyGroups,
|
||||
curves,
|
||||
structTypes,
|
||||
];
|
||||
Reference in new issue
Block a user