Files
feuerwerk/src/editor/inspector/assetSection.ts
T
adminandClaude Opus 5.5 454d202109
game-maker / gate (push) Successful in 3m13s
game-maker / suite (push) Failing after 8m46s
Let each variable of a behavior choose whether calls keep it (#229)
The first version of #229 put a Keep state switch on the Run behavior
node. The user wanted it where the variable is declared instead. Whether
a value lives on belongs to the variable, not to every call site.

- The node switch keepState is gone again. RunBehavior, CallResult and
  nested.ts are back to what they were before #229.
- BehaviorAsset.keptVariables lists the kept names. It lives on the
  asset and not in PropertyDef, because that is the schema of every
  inspector row.
- A variable added from now on starts with Keep on. Variables in
  existing projects start with Keep off, so old projects run unchanged.
- SetVariableKeptCommand flips the switch as one undo step. Add, remove
  and rename carry the list along. Loading drops names that are not
  declared.
- The interpreter keeps only the listed variables, per Run behavior
  node and per object as before. The others start at their defaults.
- Local state machines in a called behavior start empty on every call
  again, as before #229. Global and scene machines are unchanged.
- The inspector shows a Keep button in each variable row of a behavior.
  Its state is part of the asset signature, so a click redraws the row.
  Without that the button kept its old look until the inspector was
  reopened.

Found: the project Garden of Sacrifices still has keepState = true on
the call Player -> Character Movement. It is ignored now. Direction
there predates the switch, so Keep is off until set by hand.

Tests:
- ./check.sh: 163 files, 2392 tests, CHECK EXIT 0 (before 2388)
- tests/core/keep-state.test.ts rewritten, 15 tests
- e2e 19: ALL PASSED, 35 checks. 19c-ii now clicks the Keep button
  of a new variable: on when added, off by a click, on again, off
  after undo.
- Counter-checks, each fails its own test: keep list ignored (2),
  states kept (1), new variable not kept (8), rename drops the list
  (1), no Keep button (e2e 19c-ii).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:50:59 +02:00

321 lines
14 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 {
RenameAssetCommand,
ResetAssetPropertyCommand,
SetAssetPropertyCommand,
SetMachineScopeCommand,
SetVariablesPersistentCommand,
SetVariablesScopeCommand,
} from '../../core/commands/assetCommands';
import { isPersistent } from '../../core/graph/savedValues';
import { nextFieldName, nextVariableName } from '../../core/commands/naming';
import {
AddStructFieldCommand,
AddVariableCommand,
RemoveStructFieldCommand,
RemoveVariableCommand,
RenameStructFieldCommand,
RenameVariableCommand,
SetVariableKeptCommand,
} from '../../core/commands/variableCommands';
import type { Command } from '../../core/commands/Command';
import {
type Asset,
type BehaviorAsset,
isKeptVariable,
type StateMachineAsset,
type VariablesAsset,
} from '../../core/model/Asset';
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';
import { type StructFieldKind, VARIABLE_KINDS } from '../../core/properties/kinds';
import {
isRowShown,
type PropertyDef,
schemaForAsset,
type StructFieldDef,
} 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 { FontPreview } from '../font/FontPreview';
import { clearSaved, hasSaved } from '../saves';
import { SoundPreview } from '../sound/SoundPreview';
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 { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { assetValue } from './valueSpecs';
const newField = (key: string, kind: StructFieldKind): StructFieldDef => ({ key, kind });
/**
* Structure-defining facts of an asset selection: the schema keys and kinds of the rows shown, plus
* the fields of a struct – those are rows of their own, so adding one has to rebuild the section.
*/
export function assetSignature(project: Project, assetId: string): string {
const asset = findAsset(project, assetId);
if (!asset) return 'missing';
// A machine's own rows are none, so its scope has to be named here or switching it rebuilds nothing.
const machine = asset.type === 'statemachine' ? `|${scopeOf(asset)}` : '';
// The same for the switch that keeps shared values between runs and for the scope beside it: the
// notes, the button and whether the switch is shown at all depend on the two.
const kept = asset.type === 'variables' ? `|${isPersistent(asset)}|${sharedScopeOf(asset)}` : '';
// A behavior's Keep buttons are drawn once per build and read their switch from it (#229).
const keeps = asset.type === 'behavior' ? `|keep:${[...(asset.keptVariables ?? [])].sort().join(',')}` : '';
// Vector art shows its shapes under its rows (#129), so a shape added or renamed is a new structure.
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;
}
export function defSignature(def: PropertyDef): string {
// Only a struct has rows of its own; a list is one row whatever its items are.
const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : '';
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
}
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
export function buildAssetSection(host: InspectorHost, assetId: string): void {
const asset = findAsset(host.state.project, assetId);
if (!asset) return;
addTitle(host, `${asset.type.toUpperCase()} ASSET`);
addNameField(host, asset.name, {
onChange: (name) => {
if (name.trim()) host.state.execute(new RenameAssetCommand(assetId, name.trim()));
else host.nameField?.setValue(asset.name);
},
});
buildProps(host, asset, markedAssetSchema(host.state.project, asset));
if (asset.type === 'behavior' || asset.type === 'variables') buildVariables(host, asset);
if (asset.type === 'variables') {
buildVariablesScope(host, asset);
buildPersistence(host, asset);
}
if (asset.type === 'statemachine') buildMachineScope(host, asset);
// The drawing itself: its shapes are picked here as a texture's frames are picked in its tab (#129).
if (asset.type === 'vectorart') buildShapeListSection(host, asset);
// A font has no properties at all, so without this its inspector would be the name and nothing.
if (asset.type === 'font')
host.body.add(
asset.file ? new FontPreview(asset.file) : new Label('Not uploaded.', { style: 'warning' }),
);
// A sound has two rows above this; the preview is the only way to find out which sound it is.
if (asset.type === 'sound')
host.body.add(
asset.file ? new SoundPreview(asset.file) : new Label('Not uploaded.', { style: 'warning' }),
);
}
/** Pushes the current asset values into the rows. False when the asset is gone (rebuild). */
export function refreshAssetSection(host: InspectorHost, assetId: string): boolean {
const asset = findAsset(host.state.project, assetId);
if (!asset) return false;
host.nameField?.setValue(asset.name);
const project = host.state.project;
const values = shownValues(host, asset);
for (const [key, row] of host.fields) {
row.setValue(values[key] ?? 0);
row.setModified(isModified(project, asset, key));
}
return true;
}
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[]): void {
const project = () => host.state.project;
const values = resolveAssetProps(project(), asset);
for (const def of schema.filter((d) => isRowShown(d, values))) {
const row = createFieldRow(
host,
def,
values[def.key] ?? def.default,
assetHandlers(host, asset.id, def),
{
extra: aspectActions(
host,
asset,
def,
() => {
const target = findAsset(host.state.project, asset.id);
return target ? resolveAssetProps(project(), target) : null;
},
(values) =>
host.state.execute(
sizeCommand(values, (axis, value) => new SetAssetPropertyCommand(asset.id, axis, value)),
),
),
reset: () => {
const target = findAsset(host.state.project, asset.id);
return actionIf(target && isModified(project(), target, def.key), 'Reset to default', () =>
host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)),
);
},
},
);
row.setModified(isModified(project(), asset, def.key));
host.fields.set(def.key, row);
host.body.add(row.row);
}
}
/** Previews straight into the asset while editing; the command records the value from before the preview. */
function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers {
const spec = assetValue(host.state, assetId, def.key);
return {
...previewingHandlers<Asset>(host, spec),
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
...structFields<Asset>(host, def, spec, (field) => ({ kind: 'asset', assetId, key: def.key, field })),
};
}
/**
* What the rows show: the values of the run while one is on, else what the document declares – so a
* value a graph counts up is visible without ever being saved.
*/
export function shownValues(host: InspectorHost, asset: Asset): PropertyBag {
const values = resolveAssetProps(host.state.project, asset);
const live = asset.type === 'variables' ? host.liveVariables(asset.id) : null;
return live ? { ...values, ...live } : values;
}
/** Declared variables, each with its declaration editable – plus an "add" menu. */
function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAsset): void {
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1);
const add = new Dropdown('+ Variable', { variant: 'ghost' });
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));
});
new Menu(items).openBelow(add);
};
header.add(add);
host.body.add(header);
if (asset.variables.length === 0) addNote(host, 'No variables yet.');
const values = shownValues(host, asset);
const run = (command: Command): void => void host.state.execute(command);
for (const def of asset.variables) {
const edit: VariableEditing = {
rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next)),
remove: () => run(new RemoveVariableCommand(asset.id, def.key)),
renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)),
removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
addField: (kind) =>
run(
new AddStructFieldCommand(
asset.id,
def.key,
newField(nextFieldName(def.kind === 'struct' ? def.fields : []), kind),
),
),
};
const keep =
asset.type === 'behavior'
? {
on: isKeptVariable(asset, def.key),
set: (on: boolean) => run(new SetVariableKeptCommand(asset.id, def.key, on)),
}
: undefined;
const value = values[def.key] ?? def.default;
host.fields.set(
def.key,
buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit, keep }),
);
}
}
/** Not in the schema table either: a row there would share the bag with the declared values. */
const PERSISTENT: PropertyDef = { key: 'persistent', label: 'Persistent', kind: 'boolean', default: false };
/** Whether the values outlive the run (#166), what that means, and the way back to a first start. */
function buildPersistence(host: InspectorHost, asset: VariablesAsset): void {
const set = (value: PropertyValue): void =>
void host.state.execute(new SetVariablesPersistentCommand(asset.id, value === true));
// Nothing to keep between runs when every scene throws the values away – the switch is not shown.
if (sharedScopeOf(asset) === 'scene') {
addNote(host, 'One value each, built again for', 'small');
addNote(host, 'every scene that is loaded.', 'small');
return;
}
const row = createFieldRow(host, PERSISTENT, isPersistent(asset), { onInput: set, onChange: set });
host.body.add(row.row);
// Two lines rather than one: the panel is narrow enough to cut a longer note off mid-sentence.
if (!isPersistent(asset)) {
addNote(host, 'One value each for the whole run.', 'small');
addNote(host, 'Every graph pointing here shares them.', 'small');
return;
}
addNote(host, 'Kept between runs, in this browser.', 'small');
addNote(host, 'The next run starts where one ended.', 'small');
host.body.add(
new Button('Clear saved values', {
variant: 'ghost',
onClick: () => {
const projectId = host.state.project.id;
const had = hasSaved(projectId, asset.id);
clearSaved(projectId, asset.id);
const name = findAsset(host.state.project, asset.id)?.name ?? asset.name;
host.state.setNotice(had ? `Saved values of ${name} cleared` : 'Nothing saved yet');
},
}),
);
}
/** Not in the schema table: the scope lives on the asset itself, not in its property bag. */
const MACHINE_SCOPE: PropertyDef = {
key: 'scope',
label: 'Scope',
kind: 'enum',
default: 'local',
options: [
{ value: 'local', label: 'Local' },
{ value: 'global', label: 'Global' },
{ value: 'scene', label: 'Scene' },
],
};
const MACHINE_SCOPE_NOTES: Record<Scope, string[]> = {
local: ['Each behavior instance keeps its own current state.'],
global: ['Every behavior using this machine shares its', 'current state, for the whole run.'],
scene: ['Shared like global, but it starts over', 'whenever a scene is loaded.'],
};
/** A state machine has no properties of its own, so the inspector shows just its name and this. */
function buildMachineScope(host: InspectorHost, asset: StateMachineAsset): void {
const set = (value: PropertyValue): void =>
void host.state.execute(new SetMachineScopeCommand(asset.id, readScope(value)));
const row = createFieldRow(host, MACHINE_SCOPE, scopeOf(asset), { onInput: set, onChange: set });
host.body.add(row.row);
// Short lines rather than one: the panel is narrow enough to cut a longer note off mid-sentence.
for (const line of MACHINE_SCOPE_NOTES[scopeOf(asset)]) addNote(host, line, 'small');
}
/** The same switch for a Variables asset, which has no `local`: the asset is the shared thing. */
const VARIABLES_SCOPE: PropertyDef = {
key: 'scope',
label: 'Scope',
kind: 'enum',
default: 'global',
options: [
{ value: 'global', label: 'Global' },
{ value: 'scene', label: 'Scene' },
],
};
function buildVariablesScope(host: InspectorHost, asset: VariablesAsset): void {
const set = (value: PropertyValue): void =>
void host.state.execute(new SetVariablesScopeCommand(asset.id, readSharedScope(value)));
const row = createFieldRow(host, VARIABLES_SCOPE, sharedScopeOf(asset), {
onInput: set,
onChange: set,
});
host.body.add(row.row);
}