Keep behavior variables private to their own graph (#257)
A behavior often needs working state such as a timer or a counter. Until now every variable showed up on each placed object and in the Properties menu, so the inspector filled up with values nobody should touch. A private variable works like any other inside its graph but is not offered anywhere outside. - Each behavior variable gets a lock switch next to Keep. It starts off, so existing projects stay as they are (`privateVariables`). - A placed object shows no private variable, neither in the inspector nor while the game runs. If all are private, it says so. - Switching a variable to private drops its overrides on every placed object in one undo step. Switching back does not restore them. - A running game does not write a private value back as an override, and a project change during the run does not reset it. - `+ Property` no longer offers private variables. A port that names one by hand still reads and writes it without a warning. - The default value of a private variable is read-only in the asset inspector. Its shape (name, fields, type, delete) stays editable. - Keep is now a pin icon instead of a word. With the lock added, the header of a list variable had no room left and the lock covered `+ Item`. Not done: the animation track picker still offers private variables. Tests: ./check.sh -> 186 files, 2711 tests, CHECK EXIT 0. e2e 1,19,20,21,56 -> ALL PASSED, 119 checks. New module 56 has 7 checks with a real mouse. Counter-checks fail as expected: unit without the write-back filter, the sync guard, dropping overrides or loading the flag. Browser with all variables on the object, an unfiltered menu, or no value lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf6565d108
commit
e8b3ac0a3b
16 files changed
+455
-49
No files matched your search
@@ -17,11 +17,13 @@ import {
|
||||
RenameStructFieldCommand,
|
||||
RenameVariableCommand,
|
||||
SetVariableKeptCommand,
|
||||
SetVariablePrivateCommand,
|
||||
} from '../../core/commands/variableCommands';
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import {
|
||||
type Asset,
|
||||
isKeptVariable,
|
||||
isPrivateVariable,
|
||||
isVariableOwner,
|
||||
type StateMachineAsset,
|
||||
type VariableOwnerAsset,
|
||||
@@ -76,8 +78,12 @@ export function assetSignature(project: Project, assetId: string): string {
|
||||
// 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(',')}` : '';
|
||||
// A behavior's Keep and Private buttons are drawn once per build and read their switch from it.
|
||||
const keeps =
|
||||
asset.type === 'behavior'
|
||||
? `|keep:${[...(asset.keptVariables ?? [])].sort().join(',')}` +
|
||||
`|private:${[...(asset.privateVariables ?? [])].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);
|
||||
@@ -273,18 +279,24 @@ export function buildVariables(host: InspectorHost, asset: VariableOwnerAsset, i
|
||||
),
|
||||
),
|
||||
};
|
||||
const keep =
|
||||
asset.type === 'behavior'
|
||||
? {
|
||||
on: isKeptVariable(asset, def.key),
|
||||
set: (on: boolean) => run(new SetVariableKeptCommand(asset.id, def.key, on)),
|
||||
}
|
||||
: undefined;
|
||||
const behavior = asset.type === 'behavior';
|
||||
const keep = behavior
|
||||
? {
|
||||
on: isKeptVariable(asset, def.key),
|
||||
set: (on: boolean) => run(new SetVariableKeptCommand(asset.id, def.key, on)),
|
||||
}
|
||||
: undefined;
|
||||
const hidden = behavior
|
||||
? {
|
||||
on: isPrivateVariable(asset, def.key),
|
||||
set: (on: boolean) => run(new SetVariablePrivateCommand(asset.id, def.key, on)),
|
||||
}
|
||||
: 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, typing, keep }),
|
||||
buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit, typing, keep, hidden }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SetEditorOffsetCommand,
|
||||
SetOverrideCommand,
|
||||
} from '../../core/commands/sceneObjectCommands';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { type Asset, publicVariables } from '../../core/model/Asset';
|
||||
import { findObject } from '../../core/model/hierarchy';
|
||||
import {
|
||||
type InstanceTarget,
|
||||
@@ -66,12 +66,12 @@ import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf }
|
||||
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
|
||||
|
||||
/**
|
||||
* The rows a placed object shows for what it was placed from: a behavior's declared variables, any
|
||||
* other asset's schema minus the common switches.
|
||||
* The rows a placed object shows for what it was placed from: a behavior's declared variables but
|
||||
* the private ones (#257), any other asset's schema minus the common switches.
|
||||
*/
|
||||
export function objectRows(project: Project, asset: Asset, object: SceneObject): PropertyDef[] {
|
||||
return asset.type === 'behavior'
|
||||
? asset.variables
|
||||
? publicVariables(asset)
|
||||
: schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key));
|
||||
}
|
||||
|
||||
@@ -98,7 +98,9 @@ export function objectSignature(
|
||||
const rows = schemaForObject(project, asset, object).filter((def) => isRowShown(def, values));
|
||||
// A tilemap warns when it is placed more than once, so the number of placements shapes it too.
|
||||
const placed = asset.type === 'tilemap' ? `|placed:${findUsages(project, asset.id).length}` : '';
|
||||
return `${object.parentId}|${kind}${placed}|` + rows.map(defSignature).join(',');
|
||||
// A variable turned private leaves the rows, with nothing else about it changed (#257).
|
||||
const hidden = asset.type === 'behavior' ? `|private:${(asset.privateVariables ?? []).join(',')}` : '';
|
||||
return `${object.parentId}|${kind}${placed}${hidden}|` + rows.map(defSignature).join(',');
|
||||
}
|
||||
|
||||
/** Several objects: what they are, and the actions that apply to all of them at once. */
|
||||
@@ -240,7 +242,11 @@ export function buildOwnRows(host: InspectorHost, holderId: string, objectId: st
|
||||
if (!inTab) header.add(openVariablesButton(host, { kind: 'object', holderId, objectId }));
|
||||
host.body.add(header);
|
||||
} else if (own.length > 0) addTitle(host, 'PROPERTIES');
|
||||
if (own.length === 0 && behavior) addNote(host, 'The behavior declares no variables.');
|
||||
if (own.length === 0 && behavior)
|
||||
addNote(
|
||||
host,
|
||||
asset.variables.length > 0 ? 'All its variables are private.' : 'The behavior declares no variables.',
|
||||
);
|
||||
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, behavior);
|
||||
if (inTab) markObjectRows(host, object, asset, targetOf(project, holderId, objectId));
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
targetLabel,
|
||||
targetProperties,
|
||||
} from '../../core/graph';
|
||||
import { isPrivateVariable } from '../../core/model/Asset';
|
||||
import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import { DECLARED_PORT_KINDS, VARIABLE_KINDS } from '../../core/properties/kinds';
|
||||
@@ -115,6 +116,8 @@ export function buildPropertyPortSection(
|
||||
choices: behavior ? schemaForAsset(host.state.project, behavior) : [],
|
||||
owner: behavior ? `"${behavior.name}"` : 'a behavior',
|
||||
empty: id ? 'That behavior is gone.' : 'Pick a behavior first.',
|
||||
// Not offered, yet a port naming one by hand is one of its properties all the same (#257).
|
||||
hidden: (def) => behavior !== undefined && isPrivateVariable(behavior, def.key),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,14 +131,17 @@ interface PickedPorts {
|
||||
custom?: boolean;
|
||||
/** Where a choice comes from, shown after its key in the menu; nothing for one that speaks for itself. */
|
||||
note?: (def: PropertyDef) => string | undefined;
|
||||
/** A choice left out of the menus that is still valid on a port. */
|
||||
hidden?: (def: PropertyDef) => boolean;
|
||||
}
|
||||
|
||||
function buildPickedPortSection(
|
||||
host: InspectorHost,
|
||||
assetId: string,
|
||||
node: GraphNode,
|
||||
{ noun, choices, owner, empty, custom = false, note }: PickedPorts,
|
||||
{ noun, choices, owner, empty, custom = false, note, hidden }: PickedPorts,
|
||||
): void {
|
||||
const offered = hidden ? choices.filter((def) => !hidden(def)) : choices;
|
||||
const plural = noun === 'Property' ? 'properties' : 'variables';
|
||||
const ports = node.ports ?? [];
|
||||
const taken = new Set(ports.map((p) => p.key));
|
||||
@@ -145,7 +151,7 @@ function buildPickedPortSection(
|
||||
return where ? `${def.key} (${where})` : def.key;
|
||||
};
|
||||
const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] =>
|
||||
choices.map((def) => ({
|
||||
offered.map((def) => ({
|
||||
label: choiceLabel(def),
|
||||
disabled: taken.has(def.key) && def.key !== own,
|
||||
onSelect: () => onPick(portOf(def)),
|
||||
@@ -165,7 +171,7 @@ function buildPickedPortSection(
|
||||
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
|
||||
new Menu(custom ? typed() : items, { searchable }).openBelow(add);
|
||||
};
|
||||
add.setEnabled(custom || choices.some((def) => !taken.has(def.key)));
|
||||
add.setEnabled(custom || offered.some((def) => !taken.has(def.key)));
|
||||
header.add(add);
|
||||
host.body.add(header);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
attachRowActions,
|
||||
createField,
|
||||
FIELD_INDENT,
|
||||
type Field,
|
||||
type FieldHandlers,
|
||||
fieldDef,
|
||||
type FieldRow,
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
type RowActions,
|
||||
structFieldValue,
|
||||
} from './fieldFactory';
|
||||
import { KIND_ICON_SVG } from '../panels/markIcons';
|
||||
import { KIND_ICON_SVG, VARIABLE_SWITCH_ICON_SVG } from '../panels/markIcons';
|
||||
import { addNote, type InspectorHost } from './host';
|
||||
|
||||
/** The menu every "add a kind" button opens: one entry per kind, named as the kind table names it. */
|
||||
@@ -51,8 +52,11 @@ export interface VariableEditing {
|
||||
addField: (path: readonly string[], kind: NestedKind) => void;
|
||||
}
|
||||
|
||||
/** A behavior's switch for one variable: carried on from one `Run behavior` call to the next (#229). */
|
||||
export interface VariableKeep {
|
||||
/**
|
||||
* A behavior's switch for one variable: carried on from one `Run behavior` call to the next (#229),
|
||||
* or hidden from everything outside its graph (#257).
|
||||
*/
|
||||
export interface VariableSwitch {
|
||||
on: boolean;
|
||||
set: (on: boolean) => void;
|
||||
}
|
||||
@@ -72,7 +76,8 @@ export interface StructTyping {
|
||||
export interface VariableOptions {
|
||||
edit?: VariableEditing;
|
||||
typing?: StructTyping;
|
||||
keep?: VariableKeep;
|
||||
keep?: VariableSwitch;
|
||||
hidden?: VariableSwitch;
|
||||
/** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */
|
||||
actions?: RowActions;
|
||||
}
|
||||
@@ -88,13 +93,15 @@ export function buildVariable(
|
||||
handlers: FieldHandlers,
|
||||
options: VariableOptions = {},
|
||||
): FieldRow {
|
||||
const { edit, typing, keep, actions } = options;
|
||||
const { edit, typing, keep, hidden, 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;
|
||||
// A private value is set in its own graph only (#257): shown, but not edited here.
|
||||
const locked = hidden?.on ?? false;
|
||||
if (def.kind === 'struct') {
|
||||
head.add(new Spacer(), 1);
|
||||
if (edit && typed === null) head.add(addFieldMenu(edit, []));
|
||||
@@ -102,8 +109,10 @@ export function buildVariable(
|
||||
const field = createField(host, def, value, handlers);
|
||||
setValue = addValue(head, field);
|
||||
below = field.below;
|
||||
lockValue(field, locked);
|
||||
}
|
||||
if (keep) head.add(keepButton(keep));
|
||||
if (hidden) head.add(switchButton('private', hidden));
|
||||
if (keep) head.add(switchButton('keep', keep));
|
||||
if (edit) head.add(removeButton(edit.remove));
|
||||
const refreshActions = actions ? attachRowActions(head, actions) : () => {};
|
||||
host.body.add(head);
|
||||
@@ -123,6 +132,7 @@ export function buildVariable(
|
||||
handlers,
|
||||
own ? edit : undefined,
|
||||
own ? typing : undefined,
|
||||
locked,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,18 +146,26 @@ export function buildVariable(
|
||||
};
|
||||
}
|
||||
|
||||
/** Lit while the variable is kept; a click flips it. */
|
||||
function keepButton(keep: VariableKeep): Button {
|
||||
const button = new Button('Keep', {
|
||||
/** Lit while the switch is on – the lock while private, the pin while kept; a click flips it. */
|
||||
function switchButton(icon: keyof typeof VARIABLE_SWITCH_ICON_SVG, of: VariableSwitch): Button {
|
||||
const button = new Button('', {
|
||||
variant: 'ghost',
|
||||
style: 'small',
|
||||
toggle: true,
|
||||
onClick: () => keep.set(!keep.on),
|
||||
icon: VARIABLE_SWITCH_ICON_SVG[icon],
|
||||
padding: 4,
|
||||
onClick: () => of.set(!of.on),
|
||||
});
|
||||
button.setToggled(keep.on);
|
||||
button.setToggled(of.on);
|
||||
return button;
|
||||
}
|
||||
|
||||
/** A value – and the items below it – greyed out and deaf to the pointer while `locked`. */
|
||||
function lockValue(field: Field, locked: boolean): void {
|
||||
if (!locked) return;
|
||||
field.widget.setEnabled(false);
|
||||
field.below?.setEnabled(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per field, each with its own value; a field that is a struct again is a head row with its
|
||||
* fields indented below it (#239). `path` names the struct these fields belong to. Returns the
|
||||
@@ -161,6 +179,7 @@ function structFieldRows(
|
||||
handlers: FieldHandlers,
|
||||
edit: VariableEditing | undefined,
|
||||
typing: StructTyping | undefined,
|
||||
locked: boolean,
|
||||
): (next: PropertyValue) => void {
|
||||
const setters = new Map<string, (item: PropertyValue) => void>();
|
||||
for (const field of fields) {
|
||||
@@ -185,6 +204,7 @@ function structFieldRows(
|
||||
handlers,
|
||||
own ? edit : undefined,
|
||||
own ? typing : undefined,
|
||||
locked,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
@@ -192,6 +212,7 @@ function structFieldRows(
|
||||
const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY;
|
||||
const sub = createField(host, fieldDef(field), item, own);
|
||||
setters.set(field.key, addValue(row, sub));
|
||||
lockValue(sub, locked);
|
||||
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
|
||||
host.body.add(row);
|
||||
if (field.kind === 'list' && typing) {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="keep" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<path d="M-3 -7 H3 L2 -2 L4.5 1 H-4.5 L-2 -2 Z" fill="currentColor" />
|
||||
<line x1="0" y1="1" x2="0" y2="7" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 268 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="private" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<path d="M-3 -1 V-3.5 A3 3 0 0 1 3 -3.5 V-1" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="-5" y="-1" width="10" height="7.5" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 285 B |
@@ -19,6 +19,8 @@ import sound from './icons/kind-sound.svg?raw';
|
||||
import statemachine from './icons/kind-statemachine.svg?raw';
|
||||
import struct from './icons/kind-struct.svg?raw';
|
||||
import texture from './icons/kind-texture.svg?raw';
|
||||
import keep from './icons/keep.svg?raw';
|
||||
import privateLock from './icons/private.svg?raw';
|
||||
import reset from './icons/reset.svg?raw';
|
||||
|
||||
/** Kinds shown as a picture; the rest keep their mark in letters (`#`, `Aa`). */
|
||||
@@ -38,5 +40,11 @@ export const KIND_ICON_SVG: Partial<Record<Kind, string>> = {
|
||||
|
||||
export const ROW_ACTION_ICON_SVG = { apply, reset, fitWidth, fitHeight };
|
||||
|
||||
/**
|
||||
* A behavior variable's two switches: kept from one call to the next (#229), hidden from everything
|
||||
* outside its graph (#257). Pictures, not words: a list's head has no room for both.
|
||||
*/
|
||||
export const VARIABLE_SWITCH_ICON_SVG = { keep, private: privateLock };
|
||||
|
||||
/** A transition row's direction: one leaving the state, one coming in. */
|
||||
export const ARROW_ICON_SVG = { out: arrowOut, in: arrowIn };
|
||||
Reference in new issue
Block a user