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>
321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
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);
|
||
}
|