A shared value belongs to the run, so a declaration carries its scope (#91)
A variable or a state machine declared `global` keeps one value for the whole run instead of one per behavior instance. The scope sits on the declaration -- `PropertyDef.scope` and `StateMachineAsset.scope`, both optional, absence meaning `local` -- and `local` is never written, so every project made before this saves back unchanged. **The name is the key.** Two behaviors declaring a global `score` mean one variable; a behavior declaring `score` locally keeps its own. Kinds that disagree about one global name stop the run before it starts, with the two behaviors named; starting values that disagree are a warning on the behavior that declares it, and the first declaration wins (`globalConflicts`). The runtime needs no redirection layer. A global variable falls out of `schemaForObject`, so it never reaches `entity.props` -- and then the whole rule is "whoever declares the name takes their own value" (`graph/sharedState.ts`). The `Variables` node is untouched and stays transparent: switching a variable to global breaks no existing graph, because `GraphInterpreter.readAll` builds a fresh object per read anyway and only asks `port.key in all`. That same cut answers the inspector. A shared value has no owner, so the placed object shows no row for it and can hold no override -- `SetVariableScopeCommand` takes the deviations that were there and undo gives them back. Both places say it in words rather than leaving a glyph to be guessed: the declaration explains the mark below the list, and the placed object names the variable it no longer holds instead of claiming the behavior declares none -- which is what it said before, untruthfully. Screenshots of the e2e run turned that up; no check had been looking at it. `SharedState` hangs off `NodeContext`, is built in `BehaviorSystem.start()` from the *declarations* rather than from any instance (a global variable exists before its behavior is placed), and is thrown away with the run: nothing is written back, not even in the editor run. `GraphInterpreter.call` passes it on through `...ctx` while nested calls keep getting fresh local variables, which is why `program.defaults` is stripped of global keys. Two traps this hit. `defSignature`/`assetSignature` have to carry the scope or the inspector does not rebuild after the switch -- and a state machine has no rows at all, so its scope is appended to the signature by hand. And the object inspector does not go through `schemaForObject` for behaviors, it reads `asset.variables` directly; filtering in one place was not enough, and no unit test covered that path. The e2e caught it (`global: true` where false belonged), and the rule now lives in one named function, `objectRows`. Scopes `object` and `scene` are deliberately not built. The object case already has a route (`Object by name` -> `Properties`), and a run knows exactly one scene, so `scene` would be indistinguishable from `global` until #39 exists. Closes #59: a machine's scope is a word on the machine asset, not a setting per `Set state` node, so the same machine cannot live in two places at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9027a86edd
commit
d1fcfe8b81
27 files changed
+857
-45
No files matched your search
@@ -3,7 +3,7 @@
|
||||
* keyboard and the per-frame step. Play never touches the saved model – the world is built
|
||||
* from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away.
|
||||
*/
|
||||
import { worldPositionOf } from '../core/graph';
|
||||
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
|
||||
import {
|
||||
addGameSystems,
|
||||
type BehaviorSystem,
|
||||
@@ -33,6 +33,8 @@ export interface WorldSnapshot {
|
||||
props: Record<string, unknown>;
|
||||
animation: AnimationClock | null;
|
||||
}[];
|
||||
/** What `global` declarations share this run – no entity carries it, so it is reported apart. */
|
||||
shared: SharedState;
|
||||
log: string[];
|
||||
}
|
||||
|
||||
@@ -169,6 +171,7 @@ export class PlaySession {
|
||||
animation: e.animation ? { ...e.animation } : null,
|
||||
};
|
||||
}),
|
||||
shared: this.behaviors?.shared ?? emptySharedState(),
|
||||
log: [...world.log],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
RenameAssetCommand,
|
||||
ResetAssetPropertyCommand,
|
||||
SetAssetPropertyCommand,
|
||||
SetMachineScopeCommand,
|
||||
} from '../../core/commands/assetCommands';
|
||||
import { nextFieldName, nextVariableName } from '../../core/commands/naming';
|
||||
import {
|
||||
@@ -11,10 +12,13 @@ import {
|
||||
RemoveVariableCommand,
|
||||
RenameStructFieldCommand,
|
||||
RenameVariableCommand,
|
||||
SetVariableScopeCommand,
|
||||
} from '../../core/commands/variableCommands';
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||
import type { Asset, BehaviorAsset, StateMachineAsset } from '../../core/model/Asset';
|
||||
import { globalConflicts } from '../../core/graph/sharedState';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import { isGlobal, type Scope, 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';
|
||||
@@ -22,7 +26,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 } from './fieldFactory';
|
||||
import { actionIf, createFieldRow, type FieldHandlers, NAME_WIDTH } from './fieldFactory';
|
||||
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { buildVariable, kindMenu, type VariableEditing } from './variableRows';
|
||||
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
|
||||
@@ -36,13 +40,16 @@ const newField = (key: string, kind: StructFieldKind): StructFieldDef => ({ key,
|
||||
*/
|
||||
export function assetSignature(project: Project, assetId: string): string {
|
||||
const asset = findAsset(project, assetId);
|
||||
return asset ? schemaForAsset(project, asset).map(defSignature).join(',') : 'missing';
|
||||
if (!asset) return 'missing';
|
||||
// A machine's own rows are none, so its scope has to be named here or switching it rebuilds nothing.
|
||||
const machine = asset.type === 'statemachine' ? `|${scopeOf(asset)}` : '';
|
||||
return schemaForAsset(project, asset).map(defSignature).join(',') + machine;
|
||||
}
|
||||
|
||||
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})` : ''}`;
|
||||
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}@${scopeOf(def)}`;
|
||||
}
|
||||
|
||||
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
|
||||
@@ -59,6 +66,7 @@ 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 === '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')
|
||||
host.body.add(
|
||||
@@ -152,6 +160,7 @@ 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(
|
||||
@@ -164,4 +173,35 @@ 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');
|
||||
}
|
||||
|
||||
/** 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);
|
||||
addNote(
|
||||
host,
|
||||
isGlobal(asset)
|
||||
? 'Every behavior using this machine shares its current state.'
|
||||
: 'Each behavior instance keeps its own current state.',
|
||||
'small',
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ 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,
|
||||
@@ -52,6 +53,17 @@ import { buildVariable } from './variableRows';
|
||||
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
|
||||
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. 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.
|
||||
*/
|
||||
export function objectRows(project: Project, asset: Asset, object: SceneObject): PropertyDef[] {
|
||||
return asset.type === 'behavior'
|
||||
? asset.variables.filter((def) => !isGlobal(def))
|
||||
: schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key));
|
||||
}
|
||||
|
||||
/** Structure-defining facts of an object selection: parent, template link and schema. */
|
||||
export function objectSignature(
|
||||
project: Project,
|
||||
@@ -186,14 +198,17 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
|
||||
|
||||
// Asset properties (or behavior variables) with overrides; the switches are shown above.
|
||||
const values = resolveSceneObjectProps(host.state.project, asset, object);
|
||||
const own =
|
||||
asset.type === 'behavior'
|
||||
? asset.variables
|
||||
: schemaForObject(host.state.project, asset, object).filter((def) => !isCommonProp(def.key));
|
||||
const own = objectRows(host.state.project, asset, object);
|
||||
if (own.length > 0 || asset.type === 'behavior')
|
||||
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
|
||||
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
|
||||
// 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.');
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 {
|
||||
@@ -39,11 +40,21 @@ 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. */
|
||||
@@ -71,6 +82,7 @@ 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);
|
||||
|
||||
Reference in new issue
Block a user