Keep behavior variables private to their own graph (#257)
game-maker / gate (push) Successful in 3m40s
game-maker / suite (push) Successful in 10m56s

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:
adminandClaude Opus 5.5 committed 2026-09-28 13:54:57 +02:00
1 parent cf6565d108
commit e8b3ac0a3b
16 files changed
+455 -49

No files matched your search

+1 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1520 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1527 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+61 -8
View File
@@ -5,7 +5,13 @@ import {
type PropertyValue, type PropertyValue,
type StructValue, type StructValue,
} from '../model/Properties'; } from '../model/Properties';
import { isKeptVariable, setKeptVariable, type VariableOwnerAsset } from '../model/Asset'; import {
isKeptVariable,
isPrivateVariable,
setKeptVariable,
setPrivateVariable,
type VariableOwnerAsset,
} from '../model/Asset';
import { overrideSites } from '../model/instance'; import { overrideSites } from '../model/instance';
import type { Project } from '../model/Project'; import type { Project } from '../model/Project';
import type { VariableKind } from '../properties/kinds'; import type { VariableKind } from '../properties/kinds';
@@ -93,7 +99,12 @@ function restoreOverrides(
/** Removes a variable together with its default and every instance override. */ /** Removes a variable together with its default and every instance override. */
export class RemoveVariableCommand implements Command { export class RemoveVariableCommand implements Command {
readonly label: string; readonly label: string;
private removed?: { def: Removed<PropertyDef>; value: PropertyValue | undefined; kept: boolean }; private removed?: {
def: Removed<PropertyDef>;
value: PropertyValue | undefined;
kept: boolean;
hidden: boolean;
};
private overrides: TakenOverride[] = []; private overrides: TakenOverride[] = [];
constructor( constructor(
@@ -108,9 +119,13 @@ export class RemoveVariableCommand implements Command {
const def = removeFirst(asset.variables, (v) => v.key === this.key); const def = removeFirst(asset.variables, (v) => v.key === this.key);
if (!def) throw new CommandError(`Variable "${this.key}" not found`); if (!def) throw new CommandError(`Variable "${this.key}" not found`);
const kept = asset.type === 'behavior' && isKeptVariable(asset, this.key); const kept = asset.type === 'behavior' && isKeptVariable(asset, this.key);
this.removed = { def, value: asset.props[this.key], kept }; const hidden = isPrivateVariable(asset, this.key);
this.removed = { def, value: asset.props[this.key], kept, hidden };
delete asset.props[this.key]; delete asset.props[this.key];
if (asset.type === 'behavior') setKeptVariable(asset, this.key, false); if (asset.type === 'behavior') {
setKeptVariable(asset, this.key, false);
setPrivateVariable(asset, this.key, false);
}
this.overrides = takeOverrides(project, this.assetId, this.key); this.overrides = takeOverrides(project, this.assetId, this.key);
} }
@@ -119,7 +134,10 @@ export class RemoveVariableCommand implements Command {
const asset = requireVariableOwner(project, this.assetId); const asset = requireVariableOwner(project, this.assetId);
restoreAt(asset.variables, [this.removed.def]); restoreAt(asset.variables, [this.removed.def]);
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value; if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
if (this.removed.kept && asset.type === 'behavior') setKeptVariable(asset, this.key, true); if (asset.type === 'behavior') {
if (this.removed.kept) setKeptVariable(asset, this.key, true);
if (this.removed.hidden) setPrivateVariable(asset, this.key, true);
}
restoreOverrides(project, this.assetId, this.key, this.overrides); restoreOverrides(project, this.assetId, this.key, this.overrides);
} }
} }
@@ -143,9 +161,12 @@ export class RenameVariableCommand extends RenameCommand {
if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`); if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`);
def.key = to; def.key = to;
def.label = to; def.label = to;
// Renamed in place, so the switch keeps its spot in the list. // Renamed in place, so the switches keep their spot in the list.
if (asset.type === 'behavior' && asset.keptVariables) if (asset.type === 'behavior') {
asset.keptVariables = asset.keptVariables.map((k) => (k === from ? to : k)); if (asset.keptVariables) asset.keptVariables = asset.keptVariables.map((k) => (k === from ? to : k));
if (asset.privateVariables)
asset.privateVariables = asset.privateVariables.map((k) => (k === from ? to : k));
}
if (asset.props[from] !== undefined) { if (asset.props[from] !== undefined) {
asset.props[to] = asset.props[from]!; asset.props[to] = asset.props[from]!;
delete asset.props[from]; delete asset.props[from];
@@ -185,6 +206,38 @@ export class SetVariableKeptCommand implements Command {
} }
} }
/**
* Whether a behavior's variable is seen outside its own graph (#257). Going private drops the
* overrides of every placed object: nobody would see them any more, yet they would still act.
*/
export class SetVariablePrivateCommand implements Command {
readonly label: string;
private previous = false;
private overrides: TakenOverride[] = [];
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly hidden: boolean,
) {
this.label = hidden ? `Make ${key} private` : `Make ${key} public`;
}
execute(project: Project): void {
const asset = requireBehavior(project, this.assetId);
if (!asset.variables.some((v) => v.key === this.key))
throw new CommandError(`Variable "${this.key}" not found`);
this.previous = isPrivateVariable(asset, this.key);
setPrivateVariable(asset, this.key, this.hidden);
this.overrides = this.hidden ? takeOverrides(project, this.assetId, this.key) : [];
}
undo(project: Project): void {
setPrivateVariable(requireBehavior(project, this.assetId), this.key, this.previous);
restoreOverrides(project, this.assetId, this.key, this.overrides);
}
}
/** /**
* A struct's fields are no properties of anything placed, so the reserved property names are free * 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. * there – as they are for a struct field.
+27 -3
View File
@@ -37,14 +37,20 @@ export interface SceneAsset extends AssetBase {
* `keptVariables` names the variables a `Run behavior` call hands on to the next call from the same * `keptVariables` names the variables a `Run behavior` call hands on to the next call from the same
* node on the same object (#229); the others start at their default every call. Absent keeps none, * node on the same object (#229); the others start at their default every call. Absent keeps none,
* so a project from before the switch runs as it did, and a new variable starts out not kept. * so a project from before the switch runs as it did, and a new variable starts out not kept.
*
* `privateVariables` names the variables only the behavior's own graph sees (#257): a placed object
* neither shows nor overrides them, and a `Properties` node does not offer them. Absent is none.
*/ */
export interface BehaviorAsset extends AssetBase { export interface BehaviorAsset extends AssetBase {
type: 'behavior'; type: 'behavior';
variables: PropertyDef[]; variables: PropertyDef[];
keptVariables?: string[]; keptVariables?: string[];
privateVariables?: string[];
graph: Graph; graph: Graph;
} }
type VariableSwitch = 'keptVariables' | 'privateVariables';
/** Whether a call of this behavior hands `key` on to the next call (#229). */ /** Whether a call of this behavior hands `key` on to the next call (#229). */
export function isKeptVariable(asset: BehaviorAsset, key: string): boolean { export function isKeptVariable(asset: BehaviorAsset, key: string): boolean {
return asset.keptVariables?.includes(key) === true; return asset.keptVariables?.includes(key) === true;
@@ -52,10 +58,28 @@ export function isKeptVariable(asset: BehaviorAsset, key: string): boolean {
/** Writes the switch for one variable; an empty list is dropped rather than stored. */ /** Writes the switch for one variable; an empty list is dropped rather than stored. */
export function setKeptVariable(asset: BehaviorAsset, key: string, on: boolean): void { export function setKeptVariable(asset: BehaviorAsset, key: string, on: boolean): void {
const rest = (asset.keptVariables ?? []).filter((k) => k !== key); setSwitch(asset, 'keptVariables', key, on);
}
/** Whether `key` is hidden from everything outside the behavior's own graph (#257). */
export function isPrivateVariable(asset: Asset, key: string): boolean {
return asset.type === 'behavior' && asset.privateVariables?.includes(key) === true;
}
export function setPrivateVariable(asset: BehaviorAsset, key: string, on: boolean): void {
setSwitch(asset, 'privateVariables', key, on);
}
/** The variables a placed object shows and overrides: all but the private ones. */
export function publicVariables(asset: BehaviorAsset): PropertyDef[] {
return asset.variables.filter((def) => !isPrivateVariable(asset, def.key));
}
function setSwitch(asset: BehaviorAsset, list: VariableSwitch, key: string, on: boolean): void {
const rest = (asset[list] ?? []).filter((k) => k !== key);
const next = on ? [...rest, key] : rest; const next = on ? [...rest, key] : rest;
if (next.length > 0) asset.keptVariables = next; if (next.length > 0) asset[list] = next;
else delete asset.keptVariables; else delete asset[list];
} }
/** /**
+7 -2
View File
@@ -184,9 +184,14 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
graph: normalizeGraph(a.graph), graph: normalizeGraph(a.graph),
}; };
// Only names that are declared, each once, in declaration order. // Only names that are declared, each once, in declaration order.
const kept = new Set(Array.isArray(a.keptVariables) ? a.keptVariables : []); const declaredOf = (list: unknown): string[] => {
const keptVariables = declared.map((v) => v.key).filter((key) => kept.has(key)); const named = new Set(Array.isArray(list) ? list : []);
return declared.map((v) => v.key).filter((key) => named.has(key));
};
const keptVariables = declaredOf(a.keptVariables);
if (keptVariables.length > 0) behavior.keptVariables = keptVariables; if (keptVariables.length > 0) behavior.keptVariables = keptVariables;
const privateVariables = declaredOf(a.privateVariables);
if (privateVariables.length > 0) behavior.privateVariables = privateVariables;
assets.push(behavior); assets.push(behavior);
} else if (type === 'variables') { } else if (type === 'variables') {
const shared: VariablesAsset = { id, type, name, props, variables: variables.get(id) ?? [] }; const shared: VariablesAsset = { id, type, name, props, variables: variables.get(id) ?? [] };
+22 -10
View File
@@ -17,11 +17,13 @@ import {
RenameStructFieldCommand, RenameStructFieldCommand,
RenameVariableCommand, RenameVariableCommand,
SetVariableKeptCommand, SetVariableKeptCommand,
SetVariablePrivateCommand,
} from '../../core/commands/variableCommands'; } from '../../core/commands/variableCommands';
import type { Command } from '../../core/commands/Command'; import type { Command } from '../../core/commands/Command';
import { import {
type Asset, type Asset,
isKeptVariable, isKeptVariable,
isPrivateVariable,
isVariableOwner, isVariableOwner,
type StateMachineAsset, type StateMachineAsset,
type VariableOwnerAsset, 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 // 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. // notes, the button and whether the switch is shown at all depend on the two.
const kept = asset.type === 'variables' ? `|${isPersistent(asset)}|${sharedScopeOf(asset)}` : ''; 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). // 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(',')}` : ''; 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. // 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 shapes = asset.type === 'vectorart' ? `|${shapeListSignature(asset)}` : '';
const values = resolveAssetProps(project, asset); const values = resolveAssetProps(project, asset);
@@ -273,18 +279,24 @@ export function buildVariables(host: InspectorHost, asset: VariableOwnerAsset, i
), ),
), ),
}; };
const keep = const behavior = asset.type === 'behavior';
asset.type === 'behavior' const keep = behavior
? { ? {
on: isKeptVariable(asset, def.key), on: isKeptVariable(asset, def.key),
set: (on: boolean) => run(new SetVariableKeptCommand(asset.id, def.key, on)), set: (on: boolean) => run(new SetVariableKeptCommand(asset.id, def.key, on)),
} }
: undefined; : 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 value = values[def.key] ?? def.default;
const typing = structTyping(host, asset, def.key); const typing = structTyping(host, asset, def.key);
host.fields.set( host.fields.set(
def.key, 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 }),
); );
} }
} }
+12 -6
View File
@@ -17,7 +17,7 @@ import {
SetEditorOffsetCommand, SetEditorOffsetCommand,
SetOverrideCommand, SetOverrideCommand,
} from '../../core/commands/sceneObjectCommands'; } 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 { findObject } from '../../core/model/hierarchy';
import { import {
type InstanceTarget, type InstanceTarget,
@@ -66,12 +66,12 @@ import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf }
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows'; 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 * The rows a placed object shows for what it was placed from: a behavior's declared variables but
* other asset's schema minus the common switches. * the private ones (#257), any other asset's schema minus the common switches.
*/ */
export function objectRows(project: Project, asset: Asset, object: SceneObject): PropertyDef[] { export function objectRows(project: Project, asset: Asset, object: SceneObject): PropertyDef[] {
return asset.type === 'behavior' return asset.type === 'behavior'
? asset.variables ? publicVariables(asset)
: schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key)); : 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)); 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. // 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}` : ''; 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. */ /** 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 })); if (!inTab) header.add(openVariablesButton(host, { kind: 'object', holderId, objectId }));
host.body.add(header); host.body.add(header);
} else if (own.length > 0) addTitle(host, 'PROPERTIES'); } 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); for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, behavior);
if (inTab) markObjectRows(host, object, asset, targetOf(project, holderId, objectId)); if (inTab) markObjectRows(host, object, asset, targetOf(project, holderId, objectId));
} }
+9 -3
View File
@@ -25,6 +25,7 @@ import {
targetLabel, targetLabel,
targetProperties, targetProperties,
} from '../../core/graph'; } from '../../core/graph';
import { isPrivateVariable } from '../../core/model/Asset';
import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project'; import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project';
import { textOf } from '../../core/model/Properties'; import { textOf } from '../../core/model/Properties';
import { DECLARED_PORT_KINDS, VARIABLE_KINDS } from '../../core/properties/kinds'; import { DECLARED_PORT_KINDS, VARIABLE_KINDS } from '../../core/properties/kinds';
@@ -115,6 +116,8 @@ export function buildPropertyPortSection(
choices: behavior ? schemaForAsset(host.state.project, behavior) : [], choices: behavior ? schemaForAsset(host.state.project, behavior) : [],
owner: behavior ? `"${behavior.name}"` : 'a behavior', owner: behavior ? `"${behavior.name}"` : 'a behavior',
empty: id ? 'That behavior is gone.' : 'Pick a behavior first.', 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; custom?: boolean;
/** Where a choice comes from, shown after its key in the menu; nothing for one that speaks for itself. */ /** Where a choice comes from, shown after its key in the menu; nothing for one that speaks for itself. */
note?: (def: PropertyDef) => string | undefined; 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( function buildPickedPortSection(
host: InspectorHost, host: InspectorHost,
assetId: string, assetId: string,
node: GraphNode, node: GraphNode,
{ noun, choices, owner, empty, custom = false, note }: PickedPorts, { noun, choices, owner, empty, custom = false, note, hidden }: PickedPorts,
): void { ): void {
const offered = hidden ? choices.filter((def) => !hidden(def)) : choices;
const plural = noun === 'Property' ? 'properties' : 'variables'; const plural = noun === 'Property' ? 'properties' : 'variables';
const ports = node.ports ?? []; const ports = node.ports ?? [];
const taken = new Set(ports.map((p) => p.key)); const taken = new Set(ports.map((p) => p.key));
@@ -145,7 +151,7 @@ function buildPickedPortSection(
return where ? `${def.key} (${where})` : def.key; return where ? `${def.key} (${where})` : def.key;
}; };
const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] => const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] =>
choices.map((def) => ({ offered.map((def) => ({
label: choiceLabel(def), label: choiceLabel(def),
disabled: taken.has(def.key) && def.key !== own, disabled: taken.has(def.key) && def.key !== own,
onSelect: () => onPick(portOf(def)), onSelect: () => onPick(portOf(def)),
@@ -165,7 +171,7 @@ function buildPickedPortSection(
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port))); const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
new Menu(custom ? typed() : items, { searchable }).openBelow(add); 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); header.add(add);
host.body.add(header); host.body.add(header);
+33 -12
View File
@@ -20,6 +20,7 @@ import {
attachRowActions, attachRowActions,
createField, createField,
FIELD_INDENT, FIELD_INDENT,
type Field,
type FieldHandlers, type FieldHandlers,
fieldDef, fieldDef,
type FieldRow, type FieldRow,
@@ -31,7 +32,7 @@ import {
type RowActions, type RowActions,
structFieldValue, structFieldValue,
} from './fieldFactory'; } 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'; 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. */ /** 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; 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; on: boolean;
set: (on: boolean) => void; set: (on: boolean) => void;
} }
@@ -72,7 +76,8 @@ export interface StructTyping {
export interface VariableOptions { export interface VariableOptions {
edit?: VariableEditing; edit?: VariableEditing;
typing?: StructTyping; typing?: StructTyping;
keep?: VariableKeep; keep?: VariableSwitch;
hidden?: VariableSwitch;
/** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */ /** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */
actions?: RowActions; actions?: RowActions;
} }
@@ -88,13 +93,15 @@ export function buildVariable(
handlers: FieldHandlers, handlers: FieldHandlers,
options: VariableOptions = {}, options: VariableOptions = {},
): FieldRow { ): 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 name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
const head = gridRow(0, markLabel(def.kind), name); const head = gridRow(0, markLabel(def.kind), name);
let setValue: (next: PropertyValue) => void = () => {}; let setValue: (next: PropertyValue) => void = () => {};
let below: Widget | undefined; let below: Widget | undefined;
// A struct with a type has the type's fields: they are changed on the Struct asset, not here. // 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; 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') { if (def.kind === 'struct') {
head.add(new Spacer(), 1); head.add(new Spacer(), 1);
if (edit && typed === null) head.add(addFieldMenu(edit, [])); if (edit && typed === null) head.add(addFieldMenu(edit, []));
@@ -102,8 +109,10 @@ export function buildVariable(
const field = createField(host, def, value, handlers); const field = createField(host, def, value, handlers);
setValue = addValue(head, field); setValue = addValue(head, field);
below = field.below; 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)); if (edit) head.add(removeButton(edit.remove));
const refreshActions = actions ? attachRowActions(head, actions) : () => {}; const refreshActions = actions ? attachRowActions(head, actions) : () => {};
host.body.add(head); host.body.add(head);
@@ -123,6 +132,7 @@ export function buildVariable(
handlers, handlers,
own ? edit : undefined, own ? edit : undefined,
own ? typing : undefined, own ? typing : undefined,
locked,
); );
} }
@@ -136,18 +146,26 @@ export function buildVariable(
}; };
} }
/** Lit while the variable is kept; a click flips it. */ /** Lit while the switch is on – the lock while private, the pin while kept; a click flips it. */
function keepButton(keep: VariableKeep): Button { function switchButton(icon: keyof typeof VARIABLE_SWITCH_ICON_SVG, of: VariableSwitch): Button {
const button = new Button('Keep', { const button = new Button('', {
variant: 'ghost', variant: 'ghost',
style: 'small',
toggle: true, 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; 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 * 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 * fields indented below it (#239). `path` names the struct these fields belong to. Returns the
@@ -161,6 +179,7 @@ function structFieldRows(
handlers: FieldHandlers, handlers: FieldHandlers,
edit: VariableEditing | undefined, edit: VariableEditing | undefined,
typing: StructTyping | undefined, typing: StructTyping | undefined,
locked: boolean,
): (next: PropertyValue) => void { ): (next: PropertyValue) => void {
const setters = new Map<string, (item: PropertyValue) => void>(); const setters = new Map<string, (item: PropertyValue) => void>();
for (const field of fields) { for (const field of fields) {
@@ -185,6 +204,7 @@ function structFieldRows(
handlers, handlers,
own ? edit : undefined, own ? edit : undefined,
own ? typing : undefined, own ? typing : undefined,
locked,
), ),
); );
continue; continue;
@@ -192,6 +212,7 @@ function structFieldRows(
const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY; const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY;
const sub = createField(host, fieldDef(field), item, own); const sub = createField(host, fieldDef(field), item, own);
setters.set(field.key, addValue(row, sub)); setters.set(field.key, addValue(row, sub));
lockValue(sub, locked);
if (edit) row.add(removeButton(() => edit.removeField(path, field))); if (edit) row.add(removeButton(() => edit.removeField(path, field)));
host.body.add(row); host.body.add(row);
if (field.kind === 'list' && typing) { if (field.kind === 'list' && typing) {
+4
View File
@@ -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

+4
View File
@@ -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

+8
View File
@@ -19,6 +19,8 @@ import sound from './icons/kind-sound.svg?raw';
import statemachine from './icons/kind-statemachine.svg?raw'; import statemachine from './icons/kind-statemachine.svg?raw';
import struct from './icons/kind-struct.svg?raw'; import struct from './icons/kind-struct.svg?raw';
import texture from './icons/kind-texture.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'; import reset from './icons/reset.svg?raw';
/** Kinds shown as a picture; the rest keep their mark in letters (`#`, `Aa`). */ /** 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 }; 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. */ /** A transition row's direction: one leaving the state, one coming in. */
export const ARROW_ICON_SVG = { out: arrowOut, in: arrowIn }; export const ARROW_ICON_SVG = { out: arrowOut, in: arrowIn };
+10 -3
View File
@@ -1,6 +1,6 @@
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph'; import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph';
import { newId } from '../core/ids'; import { newId } from '../core/ids';
import type { Asset, TilemapAsset } from '../core/model/Asset'; import { type Asset, isPrivateVariable, type TilemapAsset } from '../core/model/Asset';
import { withoutObjects } from '../core/graph/liveObjects'; import { withoutObjects } from '../core/graph/liveObjects';
import { bagWithoutItemTypes, type PropertyBag, sameValue, type Vec2 } from '../core/model/Properties'; import { bagWithoutItemTypes, type PropertyBag, sameValue, type Vec2 } from '../core/model/Properties';
import { isPlaceableType } from '../core/model/AssetTypes'; import { isPlaceableType } from '../core/model/AssetTypes';
@@ -252,8 +252,11 @@ export class World {
const resolved = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object)); const resolved = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object));
for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key]; for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key];
// The scene only ever got the objects' empty places, so a value that differs by them alone stays. // The scene only ever got the objects' empty places, so a value that differs by them alone stays.
for (const [key, value] of Object.entries(resolved)) // A private value never reaches the scene, so the scene's is only where it starts.
for (const [key, value] of Object.entries(resolved)) {
if (isPrivateVariable(asset, key) && key in entity.props) continue;
if (!sameValue(value, withoutObjects(entity.props[key]))) entity.props[key] = value; if (!sameValue(value, withoutObjects(entity.props[key]))) entity.props[key] = value;
}
return entity; return entity;
} }
@@ -322,9 +325,13 @@ export class World {
const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z; const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z;
const base = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object)); const base = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object));
// What a run holds of an object stays in the run; the scene gets the empty one. // What a run holds of an object stays in the run; the scene gets the empty one.
// A private variable (#257) has no override to hold its value: that stays in the run.
const drifted = Object.keys(entity.props).filter( const drifted = Object.keys(entity.props).filter(
(k) => (k) =>
k in base && base[k] !== entity.props[k] && !sameValue(base[k], withoutObjects(entity.props[k])), k in base &&
!isPrivateVariable(asset, k) &&
base[k] !== entity.props[k] &&
!sameValue(base[k], withoutObjects(entity.props[k])),
); );
if (!moved && drifted.length === 0) continue; if (!moved && drifted.length === 0) continue;
// Only now, because a ref for resolved content costs a resolve of its own – and because a // Only now, because a ref for resolved content costs a resolve of its own – and because a
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import {
AddVariableCommand,
RemoveVariableCommand,
RenameVariableCommand,
SetVariablePrivateCommand,
} from '../../src/core/commands/variableCommands';
import { type BehaviorAsset, isPrivateVariable, publicVariables } from '../../src/core/model/Asset';
import { findAssetOf } from '../../src/core/model/Project';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
/** A behavior `Brain` with `hp` and `timer`, placed once with an override of each. */
function setup() {
const f = makeSpriteScene();
const create = new CreateAssetCommand('behavior', 'Brain');
f.stack.execute(create);
const id = create.asset.id;
f.stack.execute(new AddVariableCommand(id, 'hp', 'number'));
f.stack.execute(new AddVariableCommand(id, 'timer', 'number'));
const brain = place(f, id, { x: 0, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, brain, 'hp', 5));
f.stack.execute(new SetOverrideCommand(f.sceneId, brain, 'timer', 7));
const behavior = () => findAssetOf(f.project, id, 'behavior') as BehaviorAsset;
return { ...f, id, brain, behavior };
}
describe('private behavior variables (#257)', () => {
it('going private drops the overrides of every placed object, undo brings them back', () => {
const f = setup();
f.stack.execute(new SetVariablePrivateCommand(f.id, 'timer', true));
expect(f.behavior().privateVariables).toEqual(['timer']);
expect(f.object(f.brain).overrides).toEqual({ hp: 5 });
expect(publicVariables(f.behavior()).map((d) => d.key)).toEqual(['hp']);
f.stack.undo();
expect(f.behavior().privateVariables).toBeUndefined();
expect(f.object(f.brain).overrides).toEqual({ hp: 5, timer: 7 });
});
it('public again shows the variable with no override – the dropped one stays dropped', () => {
const f = setup();
f.stack.execute(new SetVariablePrivateCommand(f.id, 'timer', true));
f.stack.execute(new SetVariablePrivateCommand(f.id, 'timer', false));
expect(isPrivateVariable(f.behavior(), 'timer')).toBe(false);
expect(f.object(f.brain).overrides).toEqual({ hp: 5 });
});
it('rename carries the switch, remove and undo keep it', () => {
const f = setup();
f.stack.execute(new SetVariablePrivateCommand(f.id, 'timer', true));
f.stack.execute(new RenameVariableCommand(f.id, 'timer', 'clock'));
expect(f.behavior().privateVariables).toEqual(['clock']);
f.stack.execute(new RemoveVariableCommand(f.id, 'clock'));
expect(f.behavior().privateVariables).toBeUndefined();
f.stack.undo();
expect(f.behavior().privateVariables).toEqual(['clock']);
});
it('survives saving and loading, names nothing undeclared', () => {
const f = setup();
f.stack.execute(new SetVariablePrivateCommand(f.id, 'timer', true));
f.behavior().privateVariables!.push('ghost');
const loaded = findAssetOf(roundTrip(f.project), f.id, 'behavior') as BehaviorAsset;
expect(loaded.privateVariables).toEqual(['timer']);
});
it('a run writes no override for a private value and keeps it through a change to the project', () => {
const f = setup();
f.stack.execute(new SetVariablePrivateCommand(f.id, 'timer', true));
const { world } = startWorld(f);
const entity = world.findByObjectId(f.brain)!;
expect(entity.props.timer).toBe(0);
entity.props.timer = 3;
entity.props.hp = 9;
world.syncToModel();
expect(f.object(f.brain).overrides).toEqual({ hp: 9 });
// Any edit during the run reads the scene back into the entities.
f.stack.execute(new SetOverrideCommand(f.sceneId, f.brain, 'hp', 1));
world.syncFromModel();
expect(entity.props.hp).toBe(1);
expect(entity.props.timer).toBe(3);
});
});
+2 -1
View File
@@ -136,7 +136,8 @@ export async function nestedBehavior(t) {
await clickText('+ Variable'); await clickText('+ Variable');
await clickText('Number'); await clickText('Number');
const keptAdded = await kept(); const keptAdded = await kept();
const keepButton = await locate('Keep'); // A pin since #257: a list's head has no room for the word beside the lock.
const keepButton = ((await t.json("__gm.locateIcons('keep')")) ?? [])[0] ?? null;
if (keepButton) await click(keepButton.x, keepButton.y); if (keepButton) await click(keepButton.x, keepButton.y);
const keptClicked = await kept(); const keptClicked = await kept();
// A second click has to flip it back: a button drawn for the old switch would clear it again. // A second click has to flip it back: a button drawn for the old switch would clear it again.
+167
View File
@@ -0,0 +1,167 @@
/**
* Private behavior variables (#257), with the real mouse: the lock next to a variable's Keep hides it
* from the placed object – no row, no override left – and from the `+ Property` menu of a
* `Properties` node. A port that names it by hand is still a property of the behavior, and in a run
* it reads the value.
*
* Everything it adds carries a `pv56` id and goes again at the end.
*/
import { MAKE_OBJECT } from './lib/scaffold.mjs';
export async function privateVariables(t) {
const { check, json, evaluate, mutate, click, press, typeText, sleep, shot, select, undo, stop } = t;
const press_ = async (at, what) => {
if (!at) throw new Error(`nothing to click: ${what}`);
await click(at.x, at.y);
await sleep(150);
};
const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}) ?? null`);
const brain = () => asset('pv56-brain');
const overrides = () =>
json("__gm.state.activeScene.objects.find((o) => o.id === 'pv56-obj')?.overrides ?? null");
/** The locks in the inspector, top to bottom – one per variable. */
const locks = async () => ((await json("__gm.locateIcons('private')")) ?? []).sort((a, b) => a.y - b.y);
const inspectorHas = async (key) => (await json(`__gm.inspectorField(${JSON.stringify(key)})`)) !== null;
const node = (id, type, x, y, params = {}, ports) => ({
id,
type,
x,
y,
params,
...(ports ? { ports } : {}),
});
const edge = (id, from, fromPort, to, toPort) => ({
id,
from: { node: from, port: fromPort },
to: { node: to, port: toPort },
});
// On start, a Properties node of type Brain 56 reads `timer` off the behavior itself – a port
// named by hand, since the menu does not offer it once the variable is private.
const graph = {
nodes: [
node('pv56-start', 'OnStart', 0, 0),
node('pv56-self', 'Self', 0, 200),
node('pv56-props', 'Properties', 240, 200, { objectType: 'behavior', objectBehavior: 'pv56-brain' }, [
{ key: 'timer', kind: 'number' },
]),
node('pv56-log', 'Log', 520, 0),
],
edges: [
edge('pv56-e1', 'pv56-start', 'exec', 'pv56-log', 'exec'),
edge('pv56-e2', 'pv56-self', 'object', 'pv56-props', 'object'),
edge('pv56-e3', 'pv56-props', 'timer', 'pv56-log', 'message'),
],
};
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
p.assets.push({
id: 'pv56-brain',
type: 'behavior',
name: 'Brain 56',
props: { enabled: true, runInEditor: false, hp: 3, timer: 42 },
variables: [
{ key: 'hp', label: 'hp', kind: 'number', default: 3 },
{ key: 'timer', label: 'timer', kind: 'number', default: 42 },
],
graph: ${JSON.stringify(graph)},
});
${MAKE_OBJECT}
const placed = make('pv56-obj', 'pv56-brain', '', 400, 'Brain 56');
placed.overrides = { hp: 5, timer: 7 };
scene.objects.push(placed);
`);
const sceneId = await json('__gm.state.activeScene.id');
// 56a. Each variable has a lock, off. A click on the one of `timer` makes it private and takes the
// placed object's override of it; undo brings both back.
await select({ kind: 'asset', assetId: 'pv56-brain' });
await sleep(200);
const shown = await locks();
check('each variable of the behavior has a lock', shown.length === 2, JSON.stringify(shown));
await press_(shown[1], 'the lock of timer');
const made = { hidden: (await brain()).privateVariables ?? null, overrides: await overrides() };
check(
'a click on the lock makes timer private and drops its override, hp keeps its own',
JSON.stringify(made) === JSON.stringify({ hidden: ['timer'], overrides: { hp: 5 } }),
JSON.stringify(made),
);
await shot('01-lock');
await undo();
const undone = { hidden: (await brain()).privateVariables ?? null, overrides: await overrides() };
check(
'undo makes it public again, with the override back',
JSON.stringify(undone) === JSON.stringify({ hidden: null, overrides: { hp: 5, timer: 7 } }),
JSON.stringify(undone),
);
await press_((await locks())[1], 'the lock of timer, again');
// 56a-ii. While locked the value is read-only: typing into it changes nothing, hp still takes a value.
await sleep(150);
await press_(await json("__gm.inspectorField('timer')"), 'timer value');
await typeText('9');
await press('Enter');
await press_(await json("__gm.inspectorField('hp')"), 'hp value');
await press('a', { ctrl: true });
await typeText('8');
await press('Enter');
await sleep(150);
const defaults = (await brain()).props;
check(
'the value of the private timer is read-only, hp still takes a new one',
defaults.timer === 42 && defaults.hp === 8,
JSON.stringify(defaults),
);
// 56b. The placed object shows hp only; its Variables tab builds the same rows.
await select({ kind: 'object', holderId: sceneId, objectId: 'pv56-obj' });
await sleep(200);
const onObject = { hp: await inspectorHas('hp'), timer: await inspectorHas('timer') };
check(
'the placed object shows hp, and no row for the private timer',
onObject.hp && !onObject.timer,
JSON.stringify(onObject),
);
await shot('02-object');
// 56c. + Property of a Properties node on Brain 56 offers hp, not timer; the port that names timer
// by hand is no stranger to the behavior, so it carries no warning.
await select({ kind: 'node', assetId: 'pv56-brain', nodeId: 'pv56-props', nodeIds: ['pv56-props'] });
await sleep(200);
await press_(await json("__gm.locate('+ Property', true)"), '+ Property');
const offered = (await json('__gm.overlayTexts()')) ?? [];
await press('Escape');
const warned = (await json(`__gm.locate('"timer" is not a property of "Brain 56".')`)) !== null;
check(
'the Properties menu offers hp but not timer, and the hand-named timer port is not flagged',
offered.includes('hp') && !offered.includes('timer') && !warned,
JSON.stringify({ offered, warned }),
);
await shot('03-properties');
// 56d. In a run that port reads the private value.
await evaluate('__gm.state.select(null)');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Brain 56]'))
.map((line) => line.split(']').pop().trim());
check(
'in a run the hand-named port reads the private timer',
logged.includes('42'),
JSON.stringify(logged),
);
// 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 !== 'pv56-obj');
p.assets = p.assets.filter((a) => a.id !== 'pv56-brain');
`);
}
+2
View File
@@ -58,6 +58,7 @@ import { hierarchyGroups } from './52-hierarchy-groups.mjs';
import { curves } from './53-curves.mjs'; import { curves } from './53-curves.mjs';
import { structTypes } from './54-struct-types.mjs'; import { structTypes } from './54-struct-types.mjs';
import { typedLists } from './55-typed-lists.mjs'; import { typedLists } from './55-typed-lists.mjs';
import { privateVariables } from './56-private-variables.mjs';
export const STEPS = [ export const STEPS = [
hierarchy, hierarchy,
@@ -115,4 +116,5 @@ export const STEPS = [
curves, curves,
structTypes, structTypes,
typedLists, typedLists,
privateVariables,
]; ];