diff --git a/README.md b/README.md index c739b057..f30ab8e6 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index 9a58059a..7d98b3a9 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -5,7 +5,13 @@ import { type PropertyValue, type StructValue, } 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 type { Project } from '../model/Project'; import type { VariableKind } from '../properties/kinds'; @@ -93,7 +99,12 @@ function restoreOverrides( /** Removes a variable together with its default and every instance override. */ export class RemoveVariableCommand implements Command { readonly label: string; - private removed?: { def: Removed; value: PropertyValue | undefined; kept: boolean }; + private removed?: { + def: Removed; + value: PropertyValue | undefined; + kept: boolean; + hidden: boolean; + }; private overrides: TakenOverride[] = []; constructor( @@ -108,9 +119,13 @@ export class RemoveVariableCommand implements Command { const def = removeFirst(asset.variables, (v) => v.key === this.key); if (!def) throw new CommandError(`Variable "${this.key}" not found`); 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]; - 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); } @@ -119,7 +134,10 @@ export class RemoveVariableCommand implements Command { const asset = requireVariableOwner(project, this.assetId); restoreAt(asset.variables, [this.removed.def]); 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); } } @@ -143,9 +161,12 @@ export class RenameVariableCommand extends RenameCommand { if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`); def.key = to; def.label = to; - // Renamed in place, so the switch keeps its spot in the list. - if (asset.type === 'behavior' && asset.keptVariables) - asset.keptVariables = asset.keptVariables.map((k) => (k === from ? to : k)); + // Renamed in place, so the switches keep their spot in the list. + if (asset.type === 'behavior') { + 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) { asset.props[to] = 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 * there – as they are for a struct field. diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 30e057c8..6dc06b20 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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 * 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. + * + * `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 { type: 'behavior'; variables: PropertyDef[]; keptVariables?: string[]; + privateVariables?: string[]; graph: Graph; } +type VariableSwitch = 'keptVariables' | 'privateVariables'; + /** Whether a call of this behavior hands `key` on to the next call (#229). */ export function isKeptVariable(asset: BehaviorAsset, key: string): boolean { 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. */ 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; - if (next.length > 0) asset.keptVariables = next; - else delete asset.keptVariables; + if (next.length > 0) asset[list] = next; + else delete asset[list]; } /** diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index fc91ff9d..e9682163 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -184,9 +184,14 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { graph: normalizeGraph(a.graph), }; // Only names that are declared, each once, in declaration order. - const kept = new Set(Array.isArray(a.keptVariables) ? a.keptVariables : []); - const keptVariables = declared.map((v) => v.key).filter((key) => kept.has(key)); + const declaredOf = (list: unknown): string[] => { + 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; + const privateVariables = declaredOf(a.privateVariables); + if (privateVariables.length > 0) behavior.privateVariables = privateVariables; assets.push(behavior); } else if (type === 'variables') { const shared: VariablesAsset = { id, type, name, props, variables: variables.get(id) ?? [] }; diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index e295b060..79dec838 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -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 }), ); } } diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 7f772ce7..1527884a 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -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)); } diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index c0cdc31b..90d2cb50 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -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); diff --git a/src/editor/inspector/variableRows.ts b/src/editor/inspector/variableRows.ts index 971abce2..4b77e5ef 100644 --- a/src/editor/inspector/variableRows.ts +++ b/src/editor/inspector/variableRows.ts @@ -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 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) { diff --git a/src/editor/panels/icons/keep.svg b/src/editor/panels/icons/keep.svg new file mode 100644 index 00000000..30d8f278 --- /dev/null +++ b/src/editor/panels/icons/keep.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/private.svg b/src/editor/panels/icons/private.svg new file mode 100644 index 00000000..78fd7ad1 --- /dev/null +++ b/src/editor/panels/icons/private.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/markIcons.ts b/src/editor/panels/markIcons.ts index ef8777bd..81180fe0 100644 --- a/src/editor/panels/markIcons.ts +++ b/src/editor/panels/markIcons.ts @@ -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> = { 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 }; diff --git a/src/runtime/World.ts b/src/runtime/World.ts index e2553ba0..92131739 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -1,6 +1,6 @@ import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph'; 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 { bagWithoutItemTypes, type PropertyBag, sameValue, type Vec2 } from '../core/model/Properties'; import { isPlaceableType } from '../core/model/AssetTypes'; @@ -252,8 +252,11 @@ export class World { const resolved = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object)); 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. - 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; + } return entity; } @@ -322,9 +325,13 @@ export class World { const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z; 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. + // A private variable (#257) has no override to hold its value: that stays in the run. const drifted = Object.keys(entity.props).filter( (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; // Only now, because a ref for resolved content costs a resolve of its own – and because a diff --git a/tests/core/private-variables.test.ts b/tests/core/private-variables.test.ts new file mode 100644 index 00000000..68916917 --- /dev/null +++ b/tests/core/private-variables.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/19-nested-behavior.mjs b/tools/e2e/19-nested-behavior.mjs index 4b30ba31..a989a0e3 100644 --- a/tools/e2e/19-nested-behavior.mjs +++ b/tools/e2e/19-nested-behavior.mjs @@ -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. diff --git a/tools/e2e/56-private-variables.mjs b/tools/e2e/56-private-variables.mjs new file mode 100644 index 00000000..2724a8f3 --- /dev/null +++ b/tools/e2e/56-private-variables.mjs @@ -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'); + `); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 09616030..e1e226be 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -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, ];