A shared value belongs to no behavior, so it becomes an asset of its own (#91)
game-maker / suite (push) Failing after 8m59s

This reverses the design of the previous commit. `PropertyDef.scope` is gone
without replacement: a behavior variable is an object property again, without
exception, and sharing is something you point at rather than something you
declare twice.

A **Variables asset** is declarations and nothing else -- no graph, no position,
never placed, no tab of its own (`TAB_OF_ASSET` is already `Partial<>`, so that
costs no line). A `Variables` node names one through its new `variablesAsset`
setting, empty meaning the behavior's own variables as before, and then reads
and writes one value per key for the whole run. Its property list follows the
chosen source, which was the actual request: a value another graph declared is
now offerable, where before every participant had to declare the name itself.

That deletes more than it adds (-72 lines net): `globalConflicts` and its
run-stopping kind clash, `SetVariableScopeCommand`, the scope picker, both
object-inspector filters and the "shared by the whole run" note on the placed
object. A typo is no longer a silent second variable -- naming an asset is
picking from a list. And two Variables assets may carry the same key without
meeting, because the asset is the key, not the name. The one shape it copies is
the one that already existed: `Set state` picks its machine the same way.

Lifetime is unchanged. The bag is built at `BehaviorSystem.start()` from the
declarations and never written back, so playing in the editor cannot change the
project -- which is the whole reason a counter must not be an object property
(`World.syncToModel` would `commit()` it, and the coin counter would stand at 7
for good).

Full symmetry across that gap, without mirroring anything. Two named wires:
`Inspector.liveVariables` shows the values as they stand while a run is on and
falls back to the declarations when it ends, and `adoptDeclarations()` takes a
key into the run *only where the document changed it since the last sync*.
Without that condition any unrelated project edit would snap a running counter
back to its starting value; a counter-test bends exactly that and falls over.
Nothing was ever cached, incidentally: `GraphInterpreter` only pulse-caches
nodes without a `read`, and the variable node has one. The wire was missing.

The state machine `Scope` row is now an ordinary `enum` row through
`createFieldRow`. It was hand-built from an `HStack` with a ghost dropdown and
began ~34px left of every value above it; the explanations beside `Local` and
`Global` are gone from the menu, the sentence below the row keeps saying it.
Machine scope is otherwise untouched -- `StateMachineAsset.scope`,
`SetMachineScopeCommand`, `stateBag()` and its persistence all stand, so #59
stays closed as built and `Scope` as a type now carries machines alone.

Six counter-proofs, each biting at its own guard. One of them did not bite at
first: the suggestion list was computed twice, in `nodeSection` and
`portSection`, so only the browser test covered the request itself. It is one
named function now, `variableChoices`, with a unit test of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-14 18:05:36 +02:00
1 parent d1fcfe8b81
commit 8143130ed2
37 files changed
+679 -419

No files matched your search

+3
View File
@@ -164,6 +164,9 @@ export class Editor implements PlayHost {
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
this.graphEditor.canvas.timeProvider = () => this.play.time;
// Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`).
this.inspector.liveVariables = (assetId) =>
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
this.toolbar = new Toolbar(state, {
openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor),
+19 -1
View File
@@ -1,4 +1,6 @@
/** Runs "run in editor" behaviors outside play mode, on a world built from the saved model, not a copy. */
import { findAsset } from '../core/model/Project';
import type { PropertyBag } from '../core/model/Properties';
import { hasEditorRun, runsInEditor } from '../core/properties/editorRun';
import { addGameSystems, type BehaviorSystem, behaviorError } from '../runtime/systems/BehaviorSystem';
import { Input } from '../runtime/systems/Input';
@@ -60,7 +62,22 @@ export class EditorRun {
world.tick(step);
this.input.endFrame();
if (this.control(world)) return;
this.commit(world.syncToModel());
const write = world.syncToModel();
this.commit(write);
// A shared value belongs to no object, so a frame that only moved one writes nothing here and
// the panels would never hear of it. Asking who is looking keeps that to the frames it helps.
if (!write.values && !write.structure && this.showsVariables()) this.state.runtimeTick();
}
/** The values a Variables asset shares in this run; null when no editor run is on. */
liveVariables(assetId: string): PropertyBag | null {
return this.behaviors?.liveVariables(assetId) ?? null;
}
private showsVariables(): boolean {
const selection = this.state.selection;
if (selection?.kind !== 'asset') return false;
return findAsset(this.state.project, selection.assetId)?.type === 'variables';
}
/** Facts for the check scripts: object ids of the behaviors currently running without Play. */
@@ -83,6 +100,7 @@ export class EditorRun {
const world = this.world;
if (world && world.project === this.state.project && world.sceneId === scene.id) {
world.syncFromModel();
this.behaviors?.syncDeclarations();
return;
}
this.stop();
+7
View File
@@ -11,6 +11,7 @@ import {
type Trace,
} from '../runtime/systems/BehaviorSystem';
import type { AnimationClock } from '../runtime/Entity';
import type { PropertyBag } from '../core/model/Properties';
import { gameClock } from '../core/time';
import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
@@ -82,6 +83,12 @@ export class PlaySession {
syncFromModel(): void {
this.world?.syncFromModel();
this.behaviors?.syncDeclarations();
}
/** The values a Variables asset shares in this run; null when nothing is playing. */
liveVariables(assetId: string): PropertyBag | null {
return this.behaviors?.liveVariables(assetId) ?? null;
}
play(): void {
+4
View File
@@ -1,3 +1,4 @@
import type { PropertyBag } from '../../core/model/Properties';
import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui';
import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
@@ -27,6 +28,8 @@ export class Inspector extends Panel {
/** A rebuild that was due while a field in here had focus (see `refreshValues`). */
private rebuildWhenLeft = false;
private rebuilds = 0;
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
liveVariables: (assetId: string) => PropertyBag | null = () => null;
constructor(private readonly state: EditorState) {
super({ title: 'Inspector', padding: 0, gap: 0 });
@@ -53,6 +56,7 @@ export class Inspector extends Panel {
state: this.state,
// Read through the state on every call: load and play mode replace the project object.
project: () => this.state.project,
liveVariables: (assetId) => this.liveVariables(assetId),
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
fields: new Map(),
nameField: null,
+42 -33
View File
@@ -12,13 +12,12 @@ import {
RemoveVariableCommand,
RenameStructFieldCommand,
RenameVariableCommand,
SetVariableScopeCommand,
} from '../../core/commands/variableCommands';
import type { Command } from '../../core/commands/Command';
import type { Asset, BehaviorAsset, StateMachineAsset } from '../../core/model/Asset';
import { globalConflicts } from '../../core/graph/sharedState';
import type { Asset, BehaviorAsset, StateMachineAsset, VariablesAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import { isGlobal, type Scope, scopeOf } from '../../core/model/Scope';
import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
import { isGlobal, readScope, scopeOf } from '../../core/model/Scope';
import { type StructFieldKind, VARIABLE_KINDS } from '../../core/properties/kinds';
import { type PropertyDef, schemaForAsset, type StructFieldDef } from '../../core/properties/PropertySchema';
import { markedAssetSchema } from '../../core/properties/deviation';
@@ -26,7 +25,7 @@ import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Dropdown, HStack, Label, Menu, Theme } from '../../ui';
import { FontPreview } from '../font/FontPreview';
import { aspectActions, sizeCommand } from './aspectAction';
import { actionIf, createFieldRow, type FieldHandlers, NAME_WIDTH } from './fieldFactory';
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
import { buildVariable, kindMenu, type VariableEditing } from './variableRows';
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
@@ -49,7 +48,7 @@ export function assetSignature(project: Project, assetId: string): string {
export function defSignature(def: PropertyDef): string {
// Only a struct has rows of its own; a list is one row whatever its items are.
const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : '';
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}@${scopeOf(def)}`;
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
}
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
@@ -65,7 +64,12 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void {
});
buildProps(host, asset, markedAssetSchema(host.state.project, asset));
if (asset.type === 'behavior') buildVariables(host, asset);
if (asset.type === 'behavior' || asset.type === 'variables') buildVariables(host, asset);
// Two lines rather than one: the panel is narrow enough to cut a longer note off mid-sentence.
if (asset.type === 'variables') {
addNote(host, 'One value each for the whole run.', 'small');
addNote(host, 'Every graph pointing here shares them.', 'small');
}
if (asset.type === 'statemachine') buildMachineScope(host, asset);
// A font has no properties at all, so without this its inspector would be the name and nothing.
if (asset.type === 'font')
@@ -80,7 +84,7 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole
if (!asset) return false;
host.nameField?.setValue(asset.name);
const project = host.state.project;
const values = resolveAssetProps(project, asset);
const values = shownValues(host, asset);
for (const [key, row] of host.fields) {
row.setValue(values[key] ?? 0);
row.setModified(isModified(project, asset, key));
@@ -135,15 +139,25 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef):
};
}
/** Variables of a behavior, each with its declaration editable – plus an "add" menu. */
function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
/**
* What the rows show: the values of the run while one is on, else what the document declares – so a
* value a graph counts up is visible without ever being saved.
*/
export function shownValues(host: InspectorHost, asset: Asset): PropertyBag {
const values = resolveAssetProps(host.state.project, asset);
const live = asset.type === 'variables' ? host.liveVariables(asset.id) : null;
return live ? { ...values, ...live } : values;
}
/** Declared variables, each with its declaration editable – plus an "add" menu. */
function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAsset): void {
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1);
const add = new Dropdown('+ Variable', { variant: 'ghost' });
add.onClick = () => {
const items = kindMenu(VARIABLE_KINDS, (kind) => {
const current = findAsset(host.state.project, asset.id);
if (current?.type === 'behavior')
if (current?.type === 'behavior' || current?.type === 'variables')
host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
});
new Menu(items).openBelow(add);
@@ -152,7 +166,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
host.body.add(header);
if (asset.variables.length === 0) addNote(host, 'No variables yet.');
const values = resolveAssetProps(host.state.project, asset);
const values = shownValues(host, asset);
const run = (command: Command): void => void host.state.execute(command);
for (const def of asset.variables) {
const edit: VariableEditing = {
@@ -160,7 +174,6 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
remove: () => run(new RemoveVariableCommand(asset.id, def.key)),
renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)),
removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
setScope: (scope) => run(new SetVariableScopeCommand(asset.id, def.key, scope)),
addField: (kind) =>
run(
new AddStructFieldCommand(
@@ -173,30 +186,26 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
const value = values[def.key] ?? def.default;
host.fields.set(def.key, buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit }));
}
// The mark alone says nothing to someone meeting it for the first time.
if (asset.variables.some(isGlobal))
addNote(host, '⊕ is one value for the whole run, shared by name.', 'small');
// A global name is shared with every other behavior declaring it, so the disagreement shows here.
const own = new Set(asset.variables.map((v) => v.key));
for (const conflict of globalConflicts(host.state.project))
if (own.has(conflict.key)) addNote(host, conflict.message, 'warning');
}
/** Not in the schema table: the scope lives on the asset itself, not in its property bag. */
const MACHINE_SCOPE: PropertyDef = {
key: 'scope',
label: 'Scope',
kind: 'enum',
default: 'local',
options: [
{ value: 'local', label: 'Local' },
{ value: 'global', label: 'Global' },
],
};
/** A state machine has no properties of its own, so the inspector shows just its name and this. */
function buildMachineScope(host: InspectorHost, asset: StateMachineAsset): void {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.add(new Label('Scope', { style: 'muted', width: NAME_WIDTH, padding: Theme.fieldPadding }));
const pick = new Dropdown(isGlobal(asset) ? 'Global' : 'Local', { variant: 'ghost' });
const set = (scope: Scope) => () => void host.state.execute(new SetMachineScopeCommand(asset.id, scope));
pick.onClick = () =>
new Menu([
{ label: 'Local \u2013 one current state per object', onSelect: set('local') },
{ label: 'Global \u2013 one current state for the whole run', onSelect: set('global') },
]).openBelow(pick);
row.add(pick, 1);
host.body.add(row);
const set = (value: PropertyValue): void =>
void host.state.execute(new SetMachineScopeCommand(asset.id, readScope(value)));
const row = createFieldRow(host, MACHINE_SCOPE, scopeOf(asset), { onInput: set, onChange: set });
host.body.add(row.row);
addNote(
host,
isGlobal(asset)
+3
View File
@@ -1,3 +1,4 @@
import type { PropertyBag } from '../../core/model/Properties';
import { Label, TextField, type TextFieldOptions, type VStack } from '../../ui';
import type { EditorState } from '../state';
import { type FieldContext, type FieldRow, labelledRow } from './fieldFactory';
@@ -5,6 +6,8 @@ import { type FieldContext, type FieldRow, labelledRow } from './fieldFactory';
/** What the inspector sections build into: the panel body and the registry of rows. */
export interface InspectorHost extends FieldContext {
readonly state: EditorState;
/** The values a Variables asset shares in the run going on, null when none is (see `Inspector`). */
liveVariables(assetId: string): PropertyBag | null;
readonly body: VStack;
/** Rows by key; live refreshes and highlight updates find them here. */
readonly fields: Map<string, FieldRow>;
+17 -7
View File
@@ -19,6 +19,7 @@ import {
STATE_TRANSITION,
stateName,
TRANSITION_PARAM,
variablesSourceOf,
} from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup';
@@ -28,7 +29,12 @@ import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import { buildPortSection, buildPropertyPortSection, buildVariablePortSection } from './portSection';
import {
buildPortSection,
buildPropertyPortSection,
buildVariablePortSection,
variableChoices,
} from './portSection';
import { listOpener, previewingHandlers } from './previewHandlers';
import { buildTransitionPicker, buildTransitionSection } from './transitionSection';
import { currentNode, nodeParamValue } from './valueSpecs';
@@ -56,11 +62,14 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
.map((e) => `${e.id}:${e.label ?? ''}:${farStateName(asset.graph, e, node.id)}`)
.join(',')
: `${textOf(node.params[TRANSITION_PARAM] ?? '')}:${textOf(node.params[MACHINE_PORT] ?? '')}`;
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${variablesOf(
asset,
)
// The source names which declarations the port menu offers, so switching it has to rebuild.
const source = variablesSourceOf(node.params);
const choices = variableChoices(project, assetId, node)
.map((v) => v.key)
.join(',')}|${target}|${transitions}`;
.join(',');
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(
',',
)}|${source}:${choices}|${target}|${transitions}`;
}
/** The name of the state at the other end of an edge – what a transition row is labelled with. */
@@ -95,8 +104,9 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
host.fields.set(`param:${param.key}`, row);
host.body.add(row.row);
};
const ownSettings = declaresPropertyPorts(node.type);
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, variablesOf(asset));
// Both draw their settings inside the port section: a setting decides what the menu offers.
const ownSettings = declaresPropertyPorts(node.type) || declaresVariablePorts(node.type);
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, addSetting);
else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting);
else if (def.declaresPorts) buildPortSection(host, assetId, node, variablesOf(asset));
+3 -11
View File
@@ -24,7 +24,6 @@ import {
} from '../../core/model/instance';
import type { PropertyValue } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
import { isGlobal } from '../../core/model/Scope';
import type { SceneObject } from '../../core/model/SceneObject';
import {
COMMON_PROPS,
@@ -55,12 +54,11 @@ import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRow
/**
* 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. A global variable is none of them – it belongs to
* the run, so this object has no value of its own to show or override.
* other asset's schema minus the common switches.
*/
export function objectRows(project: Project, asset: Asset, object: SceneObject): PropertyDef[] {
return asset.type === 'behavior'
? asset.variables.filter((def) => !isGlobal(def))
? asset.variables
: schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key));
}
@@ -201,14 +199,8 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
const own = objectRows(host.state.project, asset, object);
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
// A global variable is missing from the rows above but not from the behavior, so saying it
// declares none would be untrue – name it instead, and where it went.
const shared = asset.type === 'behavior' ? asset.variables.filter(isGlobal) : [];
if (own.length === 0 && shared.length === 0 && asset.type === 'behavior')
addNote(host, 'The behavior declares no variables.');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, asset.type === 'behavior');
if (shared.length > 0)
addNote(host, `Shared by the whole run: ${shared.map((def) => def.key).join(', ')}.`, 'small');
markObjectRows(host, object, asset, target);
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
+25 -7
View File
@@ -18,12 +18,14 @@ import {
OBJECT_BEHAVIOR_SETTING,
OBJECT_TYPE_SETTING,
portKindOf,
VARIABLES_SETTING,
variablesSourceOf,
propertyTargetOf,
structShapeOf,
targetLabel,
targetProperties,
} from '../../core/graph';
import { findAssetOf } from '../../core/model/Project';
import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project';
import { textOf } from '../../core/model/Properties';
import { DECLARED_PORT_KINDS, kindMark, VARIABLE_KINDS } from '../../core/properties/kinds';
import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
@@ -43,21 +45,37 @@ import { NAME_WIDTH, removeButton } from './fieldFactory';
import { addNote, type InspectorHost } from './host';
import { kindMenu, markLabel } from './variableRows';
/** Whose declarations a Variables node picks its ports from: the asset it names, else the behavior. */
export function variableChoices(project: Project, assetId: string, node: GraphNode): PropertyDef[] {
const source = variablesSourceOf(node.params);
if (source) return findAssetOf(project, source, 'variables')?.variables ?? [];
const asset = findGraphAsset(project, assetId);
// A state machine declares no variables; its one node type has none to name.
return asset?.type === 'behavior' ? asset.variables : [];
}
/**
* The ports of a Variables node: each one *is* a variable of the behavior, so it is picked from that
* list rather than named, and its kind follows the variable's declaration. Set makes it written too.
* The ports of a Variables node: each one *is* a declared variable, so it is picked from a list
* rather than named, and its kind follows the declaration. Set makes it written too.
*/
export function buildVariablePortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
variables: readonly PropertyDef[],
addSetting: (def: PropertyDef) => void,
): void {
addSetting(VARIABLES_SETTING);
const source = variablesSourceOf(node.params);
const shared = source ? findAssetOf(host.state.project, source, 'variables') : undefined;
buildPickedPortSection(host, assetId, node, {
noun: 'Variable',
choices: variables,
owner: 'this behavior',
empty: 'This behavior has no variables yet.',
choices: variableChoices(host.state.project, assetId, node),
owner: shared ? `"${shared.name}"` : 'this behavior',
empty: source
? shared
? `"${shared.name}" declares no variables yet.`
: 'That Variables asset is gone.'
: 'This behavior has no variables yet.',
});
}
-12
View File
@@ -1,5 +1,4 @@
import type { PropertyValue } from '../../core/model/Properties';
import { type Scope, scopeOf } from '../../core/model/Scope';
import { kindLabel, kindMark, STRUCT_FIELD_KINDS, type StructFieldKind } from '../../core/properties/kinds';
import type { PropertyDef, StructDef, StructFieldDef } from '../../core/properties/PropertySchema';
import {
@@ -40,21 +39,11 @@ export function kindMenu<K extends string>(kinds: readonly K[], pick: (kind: K)
export interface VariableEditing {
rename: (next: string) => void;
remove: () => void;
setScope: (scope: Scope) => void;
renameField: (field: StructFieldDef, next: string) => void;
removeField: (field: StructFieldDef) => void;
addField: (kind: StructFieldKind) => void;
}
const SCOPE_MARK: Record<Scope, string> = { local: '◦', global: '⊕' };
function scopePicker(def: PropertyDef, edit: VariableEditing): Button {
return markPicker(SCOPE_MARK[scopeOf(def)], [
{ label: 'Local – one value per object', onSelect: () => edit.setScope('local') },
{ label: 'Global – one value for the whole run', onSelect: () => edit.setScope('global') },
]);
}
export interface VariableOptions {
edit?: VariableEditing;
/** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */
@@ -82,7 +71,6 @@ export function buildVariable(
} else {
setValue = addValue(head, createField(host, def, value, handlers));
}
if (edit) head.add(scopePicker(def, edit));
if (edit) head.add(removeButton(edit.remove));
const refreshActions = actions ? attachRowActions(head, actions) : () => {};
host.body.add(head);