Files
feuerwerk/src/core/properties/deviation.ts
T
adminandClaude Opus 5.5 61bbb96a96
game-maker / gate (push) Successful in 3m30s
game-maker / suite (push) Successful in 10m33s
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>
2026-09-28 06:04:40 +02:00

60 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Asset } from '../model/Asset';
import { ancestorIdsOf, type ObjectTree } from '../model/hierarchy';
import { instanceTargetOf, isEmptyEdit, resolveObjects } from '../model/instance';
import { findAsset, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { type PropertySchema, schemaFor, schemaForAsset, schemaForObject } from './PropertySchema';
import { deviatesFromAsset, isModified } from './resolve';
/**
* The rows the asset inspector marks as modified. A behavior's variables are left out: their
* default is declared on the variable itself, so the asset value cannot differ from it.
*/
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' || asset.type === 'struct'
? schemaFor(asset.type)
: schemaForAsset(project, asset);
}
/** Whether any asset property the inspector would mark gold differs from its default. */
export function assetDeviates(project: Project, asset: Asset): boolean {
return markedAssetSchema(project, asset).some((def) => isModified(project, asset, def.key));
}
/**
* Whether a placed object deviates from whatever it follows: the container template it was
* resolved from, or its asset. `holder` must be the stored holder – what an object follows is
* decided by where it lives, and in a resolved view everything looks stored.
*/
export function objectDeviates(project: Project, holder: ObjectTree, object: SceneObject): boolean {
const target = instanceTargetOf(project, holder, object.id);
if (target) {
// Instance edits are pruned to what really deviates, so their presence is the answer.
const edit = target.instance.instanceEdits[target.path];
return edit !== undefined && !isEmptyEdit(edit);
}
if (object.editorOffset.x !== 0 || object.editorOffset.y !== 0) return true;
const asset = findAsset(project, object.assetId);
if (!asset) return false;
return schemaForObject(project, asset, object).some((def) =>
deviatesFromAsset(project, object, asset, def.key),
);
}
/**
* Ids the hierarchy marks: everything deviating itself, plus every ancestor of one. The ancestors
* matter because subtrees fold – without them a collapsed row would hide that something below it
* was changed.
*/
export function deviationMarks(project: Project, holder: ObjectTree): Set<string> {
const view: ObjectTree = { objects: resolveObjects(project, holder) };
const marks = new Set<string>();
for (const object of view.objects) {
if (!objectDeviates(project, holder, object)) continue;
marks.add(object.id);
for (const ancestorId of ancestorIdsOf(view, object.id)) marks.add(ancestorId);
}
return marks;
}