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

+2 -1
View File
@@ -136,7 +136,8 @@ export async function nestedBehavior(t) {
await clickText('+ Variable');
await clickText('Number');
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);
const keptClicked = await kept();
// 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 { structTypes } from './54-struct-types.mjs';
import { typedLists } from './55-typed-lists.mjs';
import { privateVariables } from './56-private-variables.mjs';
export const STEPS = [
hierarchy,
@@ -115,4 +116,5 @@ export const STEPS = [
curves,
structTypes,
typedLists,
privateVariables,
];