diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 070abdfa..d06aca46 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -30,6 +30,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { return { ...base, type: 'text' }; case 'behavior': return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() }; + case 'variables': + return { ...base, type: 'variables', variables: [] }; case 'collider': return { ...base, type: 'collider' }; case 'container': diff --git a/src/core/commands/naming.ts b/src/core/commands/naming.ts index 2c7cf1fa..8fa862fd 100644 --- a/src/core/commands/naming.ts +++ b/src/core/commands/naming.ts @@ -1,4 +1,4 @@ -import type { BehaviorAsset } from '../model/Asset'; +import type { BehaviorAsset, VariablesAsset } from '../model/Asset'; import type { Project } from '../model/Project'; function firstFree(taken: ReadonlySet, candidate: (i: number) => string, from: number): string { @@ -15,7 +15,7 @@ export function nextAssetName(project: Project, base: string): string { } /** "var1", "var2", … – the first variable name not yet used by the behavior. */ -export function nextVariableName(asset: BehaviorAsset, base = 'var'): string { +export function nextVariableName(asset: BehaviorAsset | VariablesAsset, base = 'var'): string { return firstFree(new Set(asset.variables.map((v) => v.key)), (i) => `${base}${i}`, 1); } diff --git a/src/core/commands/portCommands.ts b/src/core/commands/portCommands.ts index 0e4cca9a..86cd43a5 100644 --- a/src/core/commands/portCommands.ts +++ b/src/core/commands/portCommands.ts @@ -14,6 +14,7 @@ import { OBJECT_BEHAVIOR_PARAM, propertyTargetOf, RUN_BEHAVIOR, + variablesSourceOf, } from '../graph'; import type { PropertyValue } from '../model/Properties'; import type { Project } from '../model/Project'; @@ -355,7 +356,11 @@ export function renameVariablePorts(project: Project, behaviorId: string, from: } function namesVariablesOf(node: GraphNode, graphOwner: string, behaviorId: string): boolean { - if (declaresVariablePorts(node.type)) return graphOwner === behaviorId; + if (declaresVariablePorts(node.type)) { + // A node naming a Variables asset follows that asset wherever it sits, not the graph around it. + const source = variablesSourceOf(node.params); + return source ? source === behaviorId : graphOwner === behaviorId; + } return ( declaresPropertyPorts(node.type) && propertyTargetOf(node.params) === 'behavior' && diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index 1ef0d2ee..b8780696 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -1,7 +1,7 @@ /** Lookups that throw a `CommandError` when the target does not exist. */ import { findNode, type Graph, type GraphNode } from '../graph/Graph'; import { type NodeDef, nodeDef } from '../graph/NodeRegistry'; -import type { Asset, BehaviorAsset, StateMachineAsset } from '../model/Asset'; +import type { Asset, BehaviorAsset, StateMachineAsset, VariablesAsset } from '../model/Asset'; import { findObject, type ObjectTree } from '../model/hierarchy'; import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance'; import { @@ -71,6 +71,14 @@ export function requireBehavior(project: Project, assetId: string): BehaviorAsse return asset; } +/** An asset that declares variables: a behavior, or a Variables asset shared by the whole run. */ +export function requireVariableOwner(project: Project, assetId: string): BehaviorAsset | VariablesAsset { + const asset = findAsset(project, assetId); + if (asset?.type !== 'behavior' && asset?.type !== 'variables') + throw new CommandError(`Variable owner ${assetId} not found`); + return asset; +} + export function requireStateMachine(project: Project, assetId: string): StateMachineAsset { const asset = findAssetOf(project, assetId, 'statemachine'); if (!asset) throw new CommandError(`State machine ${assetId} not found`); diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index 1f631071..1a804547 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -7,7 +7,6 @@ import { } from '../model/Properties'; import { overrideSites } from '../model/instance'; import type { Project } from '../model/Project'; -import { type Scope, scopeOf, setScope } from '../model/Scope'; import type { VariableKind } from '../properties/kinds'; import { emptyFieldValue, @@ -20,7 +19,7 @@ import { import { type Removed, removeFirst, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; import { renameVariablePorts } from './portCommands'; -import { requireBehavior } from './require'; +import { requireVariableOwner } from './require'; /** The variable's default value is written to `props` as well. */ export class AddVariableCommand implements Command { @@ -37,7 +36,7 @@ export class AddVariableCommand implements Command { } execute(project: Project): void { - const asset = requireBehavior(project, this.assetId); + const asset = requireVariableOwner(project, this.assetId); if (!isValidVariableName(this.def.key)) throw new CommandError(INVALID_NAME); if (asset.variables.some((v) => v.key === this.def.key)) throw new CommandError(`Variable "${this.def.key}" already exists`); @@ -46,7 +45,7 @@ export class AddVariableCommand implements Command { } undo(project: Project): void { - const asset = requireBehavior(project, this.assetId); + const asset = requireVariableOwner(project, this.assetId); asset.variables = asset.variables.filter((v) => v.key !== this.def.key); delete asset.props[this.def.key]; } @@ -100,7 +99,7 @@ export class RemoveVariableCommand implements Command { } execute(project: Project): void { - const asset = requireBehavior(project, this.assetId); + const asset = requireVariableOwner(project, this.assetId); const def = removeFirst(asset.variables, (v) => v.key === this.key); if (!def) throw new CommandError(`Variable "${this.key}" not found`); this.removed = { def, value: asset.props[this.key] }; @@ -110,46 +109,13 @@ export class RemoveVariableCommand implements Command { undo(project: Project): void { if (!this.removed) return; - const asset = requireBehavior(project, this.assetId); + const asset = requireVariableOwner(project, this.assetId); restoreAt(asset.variables, [this.removed.def]); if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value; restoreOverrides(project, this.assetId, this.key, this.overrides); } } -/** - * A shared variable has no per-instance value, so going `global` takes the deviations placed objects - * kept for it. Undo gives them back. - */ -export class SetVariableScopeCommand implements Command { - readonly label: string; - private previous: Scope = 'local'; - private overrides: TakenOverride[] = []; - - constructor( - private readonly assetId: string, - private readonly key: string, - private readonly scope: Scope, - ) { - this.label = `Make variable ${key} ${scope}`; - } - - execute(project: Project): void { - const def = variableOf(project, this.assetId, this.key); - if (!def) throw new CommandError(`Variable "${this.key}" not found`); - this.previous = scopeOf(def); - setScope(def, this.scope); - this.overrides = this.scope === 'global' ? takeOverrides(project, this.assetId, this.key) : []; - } - - undo(project: Project): void { - const def = variableOf(project, this.assetId, this.key); - if (!def) return; - setScope(def, this.previous); - restoreOverrides(project, this.assetId, this.key, this.overrides); - } -} - /** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */ export class RenameVariableCommand implements Command { readonly label: string; @@ -171,7 +137,7 @@ export class RenameVariableCommand implements Command { } private rename(project: Project, from: string, to: string): void { - const asset = requireBehavior(project, this.assetId); + const asset = requireVariableOwner(project, this.assetId); if (from === to) return; if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME); const def = asset.variables.find((v) => v.key === from); @@ -202,7 +168,7 @@ interface FieldOwner { } function variableOf(project: Project, assetId: string, key: string): PropertyDef | undefined { - return requireBehavior(project, assetId).variables.find((v) => v.key === key); + return requireVariableOwner(project, assetId).variables.find((v) => v.key === key); } function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner { @@ -244,7 +210,7 @@ interface ValueHolder { } function valueHolders(project: Project, assetId: string, key: string): ValueHolder[] { - const asset = requireBehavior(project, assetId); + const asset = requireVariableOwner(project, assetId); const def = asset.variables.find((v) => v.key === key); const holders: ValueHolder[] = []; if (def) diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index ee03c8cb..ebd6d454 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -70,12 +70,12 @@ export interface PointerState { } /** - * What `global` declarations share for the length of one run: one value per variable name, one - * current state per machine. A key that is not in here is not shared – which is what lets a behavior - * declaring the same name for itself keep its own value (see `graph/sharedState.ts`). + * What is shared for the length of one run: the values of each Variables asset, keyed by its id, + * and the current state of each `global` machine. Two Variables assets may declare the same name + * without meeting, since a node names the asset it reads (see `graph/sharedState.ts`). */ export interface SharedState { - variables: PropertyBag; + variables: Record; states: Record; } diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index a617686c..5a64ddef 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -1,6 +1,6 @@ -import { textOf } from '../model/Properties'; +import { type PropertyBag, textOf } from '../model/Properties'; import { isTaggedKind } from '../properties/kinds'; -import { flatDef, isValidFieldName } from '../properties/PropertySchema'; +import { flatDef, isValidFieldName, type PropertyDef } from '../properties/PropertySchema'; import { type DeclaredPort, declaredPortType, type Graph, type GraphEdge, type GraphNode } from './Graph'; import type { GraphLookup } from './lookup'; import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; @@ -12,6 +12,20 @@ export const VARIABLES = 'Variables'; export const PROPERTIES = 'Properties'; export const BEHAVIOR_PARAM = 'behavior'; +export const VARIABLES_PARAM = 'variablesAsset'; + +/** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */ +export const VARIABLES_SETTING: PropertyDef = { + key: VARIABLES_PARAM, + label: 'Variables', + kind: 'asset', + default: '', + assetTypes: ['variables'], +}; + +export function variablesSourceOf(params: PropertyBag): string { + return textOf(params[VARIABLES_PARAM] ?? ''); +} /** * Nodes whose declared ports are not names of their own but variables of the behavior around them. diff --git a/src/core/graph/nodes/variable/Variables.ts b/src/core/graph/nodes/variable/Variables.ts index b523d48b..397b490f 100644 --- a/src/core/graph/nodes/variable/Variables.ts +++ b/src/core/graph/nodes/variable/Variables.ts @@ -1,6 +1,6 @@ -import { collapsedTitle, portDefOf, VARIABLES } from '../../nested'; +import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested'; import { type NodeDef, withInlineDefault } from '../../NodeRegistry'; -import { readableVariables, variableBag } from '../../sharedState'; +import { variableBag } from '../../sharedState'; import { writeTyped } from '../../values'; import { exec } from '../shared'; @@ -13,11 +13,11 @@ export const Variables: NodeDef = { label: 'Variables', category: 'variable', description: - "Reads this behavior's variables at any time. Those switched to Set are written together when " + - 'the flow arrives, and a read after that sees the new value.', + "Reads variables at any time: this behavior's own, or those of a Variables asset, which every " + + 'graph pointing at it shares. Those switched to Set are written when the flow arrives.', inputs: [], outputs: [], - params: [], + params: [VARIABLES_SETTING], declaresPorts: 'both', declaredPort: (port) => withInlineDefault(portDefOf(port)), derivePorts: ({ node }) => { @@ -25,10 +25,10 @@ export const Variables: NodeDef = { const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {}; return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape; }, - read: (ctx) => readableVariables(ctx), - run: (ctx, inputs, _params, def) => { - for (const port of def.inputs) - if (port.type !== 'exec') writeTyped(variableBag(ctx, port.key), port.key, inputs[port.key]); + read: (ctx, params) => variableBag(ctx, variablesSourceOf(params)), + run: (ctx, inputs, params, def) => { + const bag = variableBag(ctx, variablesSourceOf(params)); + for (const port of def.inputs) if (port.type !== 'exec') writeTyped(bag, port.key, inputs[port.key]); return { next: 'exec' }; }, }; diff --git a/src/core/graph/sharedState.ts b/src/core/graph/sharedState.ts index 133ff07b..040db17c 100644 --- a/src/core/graph/sharedState.ts +++ b/src/core/graph/sharedState.ts @@ -1,115 +1,70 @@ -import type { Asset } from '../model/Asset'; -import type { PropertyBag, PropertyValue } from '../model/Properties'; +import type { PropertyBag } from '../model/Properties'; import type { Project } from '../model/Project'; import { isGlobal } from '../model/Scope'; -import type { PropertyDef } from '../properties/PropertySchema'; import type { NodeContext, SharedState } from './NodeContext'; export function emptySharedState(): SharedState { return { variables: {}, states: {} }; } +/** What every Variables asset declares right now: the value set on the asset, else the default. */ +export function declaredVariables(project: Project): Record { + const declared: Record = {}; + for (const asset of project.assets) { + if (asset.type !== 'variables') continue; + const bag: PropertyBag = {}; + for (const def of asset.variables) bag[def.key] = asset.props[def.key] ?? def.default; + declared[asset.id] = bag; + } + return declared; +} + /** - * Built once per run, from the declarations rather than from any instance: a global variable exists - * even before the behavior declaring it is placed. Two behaviors declaring the same name mean the - * same variable – the first declaration sets the starting value (see `globalConflicts`). + * Built once per run, from the declarations rather than from any instance: a Variables asset holds + * its values whether or not a graph ever reads them, and nothing is written back when the run ends. */ export function buildSharedState(project: Project): SharedState { - const shared = emptySharedState(); - for (const asset of project.assets) { - if (asset.type === 'behavior') { - for (const def of asset.variables) - if (isGlobal(def) && !(def.key in shared.variables)) - shared.variables[def.key] = asset.props[def.key] ?? def.default; - } else if (asset.type === 'statemachine' && isGlobal(asset)) { - shared.states[asset.id] = ''; - } - } + const shared: SharedState = { variables: declaredVariables(project), states: {} }; + for (const asset of project.assets) + if (asset.type === 'statemachine' && isGlobal(asset)) shared.states[asset.id] = ''; return shared; } /** - * Where two behaviors disagree about the same global name. A different kind is an error – one of the - * two would silently read a value of a type it never declared – while a different starting value is - * only a warning, since the first declaration decides and the run is well defined either way. + * Moves edits made to the declarations during a run into the values it shares. A key is taken over + * only where the document changed it since `seen`, which is what leaves a value a graph has moved + * on alone – the panel can show the running value and set it without the two fighting. */ -export interface GlobalConflict { - key: string; - severity: 'error' | 'warning'; - message: string; -} - -export function globalConflicts(project: Project): GlobalConflict[] { - const first = new Map(); - const conflicts: GlobalConflict[] = []; - const seen = new Set(); - for (const asset of project.assets) { - if (asset.type !== 'behavior') continue; - for (const def of asset.variables) { - if (!isGlobal(def)) continue; - const value = asset.props[def.key] ?? def.default; - const held = first.get(def.key); - if (!held) { - first.set(def.key, { def, asset: asset.name, value }); - continue; - } - // One report per name: a third behavior disagreeing adds nothing the first line does not say. - if (seen.has(def.key)) continue; - if (held.def.kind !== def.kind) { - seen.add(def.key); - conflicts.push({ - key: def.key, - severity: 'error', - message: `Global variable "${def.key}" is a ${held.def.kind} in ${held.asset} and a ${def.kind} in ${asset.name}.`, - }); - } else if (!sameValue(held.value, value)) { - seen.add(def.key); - conflicts.push({ - key: def.key, - severity: 'warning', - message: `Global variable "${def.key}" starts at ${display(held.value)} in ${held.asset} and at ${display(value)} in ${asset.name}; the first is used.`, - }); - } +export function adoptDeclarations( + live: Record, + seen: Record, + now: Record, +): void { + for (const [assetId, declared] of Object.entries(now)) { + const bag = live[assetId]; + if (!bag) { + live[assetId] = { ...declared }; + continue; } + const before = seen[assetId] ?? {}; + for (const [key, value] of Object.entries(declared)) + if (!(key in bag) || before[key] !== value) bag[key] = value; + for (const key of Object.keys(bag)) if (!(key in declared)) delete bag[key]; } - return conflicts; + for (const assetId of Object.keys(live)) if (!(assetId in now)) delete live[assetId]; } -/** Structs and lists compare by their stored shape, which is what the project file holds anyway. */ -function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean { - return a === b || JSON.stringify(a ?? null) === JSON.stringify(b ?? null); -} - -function display(value: PropertyValue | undefined): string { - return typeof value === 'string' ? `"${value}"` : JSON.stringify(value ?? null); -} - -/** The bag a variable is written to: its own when the behavior declares it, else the shared one. */ -export function variableBag(ctx: NodeContext, key: string): PropertyBag { - if (key in ctx.variables) return ctx.variables; - return key in ctx.shared.variables ? ctx.shared.variables : ctx.variables; -} - -/** Everything the behavior can read under one name, its own declaration winning over a shared one. */ -export function readableVariables(ctx: NodeContext): PropertyBag { - const shared = ctx.shared.variables; - return isEmpty(shared) ? ctx.variables : { ...shared, ...ctx.variables }; +/** + * The values a `Variables` node reads and writes: those of the asset it names, else the behavior's + * own. A name that no asset answers to is a fresh bag, so a node left pointing at a deleted asset + * writes nowhere instead of into the behavior it sits in. + */ +export function variableBag(ctx: NodeContext, source: string): PropertyBag { + if (!source) return ctx.variables; + return ctx.shared.variables[source] ?? {}; } /** Where a machine's current state is kept: shared when the machine is `global`, else per instance. */ export function stateBag(ctx: NodeContext, machineId: string): Record { return machineId in ctx.shared.states ? ctx.shared.states : ctx.states; } - -/** The defaults a behavior instance starts with: a shared variable is not one of them. */ -export function withoutGlobals(defaults: PropertyBag, asset: Asset): PropertyBag { - if (asset.type !== 'behavior') return defaults; - const own = { ...defaults }; - for (const def of asset.variables) if (isGlobal(def)) delete own[def.key]; - return own; -} - -function isEmpty(bag: PropertyBag): boolean { - for (const key in bag) if (Object.hasOwn(bag, key)) return false; - return true; -} diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 6b654a4e..6bae82fb 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -36,6 +36,16 @@ export interface BehaviorAsset extends AssetBase { graph: Graph; } +/** + * Declarations shared by the whole run: a `Variables` node naming this asset reads and writes one + * value per key, however many graphs point at it. Nothing is placed, so nothing overrides it – + * `props` holds the starting values, as a behavior's does for its own variables. + */ +export interface VariablesAsset extends AssetBase { + type: 'variables'; + variables: PropertyDef[]; +} + /** Placed as a child of the object it belongs to; its position is the offset from that body. */ export interface ColliderAsset extends AssetBase { type: 'collider'; @@ -129,6 +139,7 @@ export type Asset = | TextAsset | SceneAsset | BehaviorAsset + | VariablesAsset | ColliderAsset | ContainerAsset | TextureAsset diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index a6262339..cd7fcb8a 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -4,6 +4,7 @@ export const ASSET_TYPES = [ 'sprite', 'text', 'behavior', + 'variables', 'collider', 'container', 'texture', @@ -22,6 +23,7 @@ export const ASSET_TYPE_LABELS: Record = { sprite: 'Sprite', text: 'Text', behavior: 'Behavior', + variables: 'Variables', collider: 'Collider', container: 'Container', texture: 'Texture', @@ -35,11 +37,11 @@ export const ASSET_TYPE_LABELS: Record = { /** * Textures and animations are not placeable: they are pictures a sprite wears through `texture`, * as a shader is worn through `shader` and a font through `font`. A state machine is not either: - * a behavior refers to it by id. + * a behavior refers to it by id, and a Variables asset is declarations a graph reads by id. */ export type PlaceableAssetType = Exclude< AssetType, - 'scene' | 'texture' | 'animation' | 'statemachine' | 'shader' | 'font' + 'scene' | 'texture' | 'animation' | 'statemachine' | 'shader' | 'font' | 'variables' >; const NOT_PLACEABLE: ReadonlySet = new Set([ @@ -49,6 +51,7 @@ const NOT_PLACEABLE: ReadonlySet = new Set([ 'statemachine', 'shader', 'font', + 'variables', ]); export function isPlaceableType(type: AssetType): type is PlaceableAssetType { diff --git a/src/core/model/Scope.ts b/src/core/model/Scope.ts index 9012e9ff..0c7cc35c 100644 --- a/src/core/model/Scope.ts +++ b/src/core/model/Scope.ts @@ -1,7 +1,7 @@ /** - * Whether a declaration keeps a value per behavior instance or shares one for the whole run. - * Everything was `local` before scopes existed, so a missing field means `local` and old projects - * load unchanged. + * Whether a state machine keeps its current state per behavior instance or shares one for the whole + * run. Everything was `local` before scopes existed, so a missing field means `local` and old + * projects load unchanged. Variables are shared by living on a Variables asset instead (#91). */ export type Scope = 'local' | 'global'; diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index ec57b000..6ba0592c 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -1,5 +1,4 @@ import type { GraphLookup } from '../graph/lookup'; -import { withoutGlobals } from '../graph/sharedState'; import { statesOf, transitionsOf } from '../graph/stateMachine'; import { resolveAssetProps } from '../properties/resolve'; import { findAssetOf, type Project } from './Project'; @@ -13,8 +12,7 @@ export function graphLookup(project: Project): GraphLookup { behavior: (assetId) => { const asset = findAssetOf(project, assetId, 'behavior'); if (!asset) return null; - const defaults = withoutGlobals(resolveAssetProps(project, asset), asset); - return { name: asset.name, graph: asset.graph, defaults }; + return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(project, asset) }; }, machine: (assetId) => { const asset = findAssetOf(project, assetId, 'statemachine'); diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index e67b46e2..d9d138ce 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -20,7 +20,6 @@ import { type VariableKind, } from './kinds'; import { findAssetOf, type Project } from '../model/Project'; -import { isGlobal, type Scope } from '../model/Scope'; import type { SceneObject } from '../model/SceneObject'; import { flatListOf, listItemOf } from './listItems'; import { FONT_KEY } from './fonts'; @@ -58,14 +57,10 @@ type Extras = K extends 'number' /** The definition of a flat kind: its default is a value of that kind, as the table says. */ export type FlatDef = { - [P in K]: { key: string; label: string; kind: P; default: ValueOf

; scope?: Scope } & Extras

; + [P in K]: { key: string; label: string; kind: P; default: ValueOf

} & Extras

; }[K]; -/** - * The inspector is generated from these entries, so a new property is one line in a schema below. - * `scope` is only ever set on a behavior's own variables; the fixed schemas below leave it off, which - * reads as `local`. - */ +/** The inspector is generated from these entries, so a new property is one line in a schema below. */ export type PropertyDef = | FlatDef /** @@ -74,7 +69,7 @@ export type PropertyDef = * item kind: a list is stored in the project file, and an object is a live handle that no reload * survives. */ - | { key: string; label: string; kind: 'list'; default: ListValue; scope?: Scope } + | { key: string; label: string; kind: 'list'; default: ListValue } /** * `fields` is the declaration; the value carries exactly those keys, which is what lets a struct * keep its shape while single fields are written (`mergeStruct`). @@ -85,7 +80,6 @@ export type PropertyDef = kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[]; - scope?: Scope; }; export interface PrimitiveFieldDef { @@ -220,6 +214,8 @@ const ASSET_SCHEMAS: Record = { ], // Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`. behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }], + // Every row is declared on the asset itself – see `schemaForBag`. + variables: [], // The states are the asset's graph (see StateMachineAsset), not a property. statemachine: [], // The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it. @@ -311,19 +307,16 @@ export function schemaForAsset(project: Project, asset: Asset): PropertySchema { return schemaForBag(project, asset, asset.props); } -/** - * A placed object's rows: an object overriding its shader has that shader's parameters. A global - * variable is not among them – it belongs to the run, not to this object, so it shows no row here - * and never reaches `entity.props` (which is what lets the shared bag answer for it instead). - */ +/** A placed object's rows: an object overriding its shader has that shader's parameters. */ export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema { const shader = object.overrides[SHADER_KEY]; - const rows = schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader }); - return rows.some(isGlobal) ? rows.filter((def) => !isGlobal(def)) : rows; + return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader }); } function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema { if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables]; + // Nothing but declarations: the starting values live in `props` under the very same keys. + if (asset.type === 'variables') return asset.variables; if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))]; // Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up. if (asset.type === 'particles') { diff --git a/src/core/properties/deviation.ts b/src/core/properties/deviation.ts index f6619933..5edc8b1b 100644 --- a/src/core/properties/deviation.ts +++ b/src/core/properties/deviation.ts @@ -11,7 +11,10 @@ import { deviatesFromAsset, isModified } from './resolve'; * default is declared on the variable itself, so the asset value cannot differ from it. */ export function markedAssetSchema(project: Project, asset: Asset): PropertySchema { - return asset.type === 'behavior' ? schemaFor('behavior') : schemaForAsset(project, asset); + // Declared variables are drawn by the inspector's own section, not as asset rows. + return asset.type === 'behavior' || asset.type === 'variables' + ? schemaFor(asset.type) + : schemaForAsset(project, asset); } /** Whether any asset property the inspector would mark gold differs from its default. */ diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 6e43f672..f6a0922f 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -73,12 +73,12 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue; rawProps.set(a.id, asRecord(a.props)); if (a.type === 'sprite' || a.type === 'particles') wearers.set(a.id, a.type); - if (a.type === 'behavior') { + if (a.type === 'behavior' || a.type === 'variables') { const vars = normalizeVariables(a.variables); variables.set(a.id, vars); // The full schema, not just the common rows: this is what pass 2 coerces `props` against, and // a behavior's own `runInEditor` would be dropped as undeclared. - schemas.set(a.id, [...schemaFor('behavior'), ...vars]); + schemas.set(a.id, [...schemaFor(a.type), ...vars]); } else if (a.type === 'particles') { // A source's rows end with its own graph's parameters, so the graph is needed before the // overrides of the objects placed from it can be coerced. @@ -130,6 +130,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { variables: variables.get(id) ?? [], graph: normalizeGraph(a.graph), }); + } else if (type === 'variables') { + assets.push({ id, type, name, props, variables: variables.get(id) ?? [] }); } else if (type === 'statemachine') { const machine: StateMachineAsset = { id, @@ -260,8 +262,6 @@ function normalizeVariables(raw: unknown): PropertyDef[] { const fields = normalizeStructFields(v.fields); const def = variableDef(key, kind, undefined, fields); def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue); - // Written only when shared, so every project made before scopes existed saves back unchanged. - if (readScope(v.scope) === 'global') def.scope = 'global'; defs.push(def); } return defs; diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 22b6889d..0d1d7ce8 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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), diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index affb8ab4..9ea6e1f4 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -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(); diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 3b4002d8..520edc24 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -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 { diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 069cc85d..06be63ab 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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, diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index f23ff1d9..6a3d8d39 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -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) diff --git a/src/editor/inspector/host.ts b/src/editor/inspector/host.ts index 4bbf4b0f..be088c9a 100644 --- a/src/editor/inspector/host.ts +++ b/src/editor/inspector/host.ts @@ -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; diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index 55143c97..19906242 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -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)); diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index b9a7b77d..c7afa722 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -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); diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index 244a1ae7..36412b00 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -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.', }); } diff --git a/src/editor/inspector/variableRows.ts b/src/editor/inspector/variableRows.ts index 0542b93f..2dd0fe25 100644 --- a/src/editor/inspector/variableRows.ts +++ b/src/editor/inspector/variableRows.ts @@ -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(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 = { 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); diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 637621db..7bc0764a 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -1,8 +1,9 @@ import { + adoptDeclarations, asNumber, buildSharedState, + declaredVariables, emptySharedState, - globalConflicts, type EntityHandle, type Graph, type GraphNode, @@ -88,6 +89,8 @@ export class BehaviorSystem implements System { private instances: Instance[] = []; private world: World | null = null; private sharedState: SharedState = emptySharedState(); + /** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */ + private declared: Record = {}; private readonly collisions: CollisionSystem | null; private readonly animations: AnimationSystem | null; @@ -110,19 +113,24 @@ export class BehaviorSystem implements System { return this.sharedState; } + /** The values a Variables asset shares this run; null when this run holds none for it. */ + liveVariables(assetId: string): PropertyBag | null { + return this.sharedState.variables[assetId] ?? null; + } + + /** An edit to a declaration while the run is on reaches the running values through here. */ + syncDeclarations(): void { + const now = declaredVariables(this.project); + adoptDeclarations(this.sharedState.variables, this.declared, now); + this.declared = now; + } + start(world: World): void { this.world = world; this.instances = []; // Built fresh for every run: a shared variable starts at its declared value each time Play does. this.sharedState = buildSharedState(this.project); - // Two behaviors declaring one global name with different kinds: whichever ran second would read - // a value of a type it never declared, so nothing runs until that is settled. - const blocking = globalConflicts(this.project).filter((c) => c.severity === 'error'); - if (blocking.length > 0) { - for (const conflict of blocking) world.log.push(`${ERROR_PREFIX}: ${conflict.message}`); - world.stopRequested = true; - return; - } + this.declared = declaredVariables(this.project); this.syncInstances(world); } diff --git a/tests/core/scope.test.ts b/tests/core/scope.test.ts index 0d3544b5..5e3df3fc 100644 --- a/tests/core/scope.test.ts +++ b/tests/core/scope.test.ts @@ -1,7 +1,7 @@ /** - * A variable or a state machine declared `global` keeps one value for the whole run instead of one - * per behavior instance. The name is the key: two behaviors declaring the same global name mean the - * same variable, while a behavior declaring it locally keeps its own. + * A Variables asset holds one value per key for the whole run, whatever points at it; a state + * machine declared `global` does the same for its current state. A behavior's own variables stay + * what they were: properties of the object it is placed on. */ import { describe, expect, it } from 'vitest'; import { @@ -10,22 +10,32 @@ import { SetMachineScopeCommand, } from '../../src/core/commands/assetCommands'; import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; -import { AddVariableCommand, SetVariableScopeCommand } from '../../src/core/commands/variableCommands'; -import { globalConflicts, STATE, STATE_NAME_PARAM } from '../../src/core/graph'; +import { AddVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands'; +import { STATE, STATE_NAME_PARAM, VARIABLES_PARAM } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; -import type { PropertyValue } from '../../src/core/model/Properties'; -import type { VariableKind } from '../../src/core/properties/kinds'; -import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers'; +import type { PropertyBag } from '../../src/core/model/Properties'; +import { + graphEditor, + makeBehavior, + makeSpriteScene, + makeVariables, + place, + type SceneFixture, + startWorld, +} from '../helpers'; -/** A behavior that adds one to `key` whenever it starts – the coin counter of the example game. */ -function makeCounter(fixture: SceneFixture, name: string, key: string, scope: 'local' | 'global') { - const behavior = makeBehavior(fixture, name, [key]); - if (scope === 'global') fixture.stack.execute(new SetVariableScopeCommand(behavior.id, key, scope)); +/** + * A behavior that adds one to `key` whenever it starts – the coin counter of the example game. + * With `source` it counts on that Variables asset, without it on its own declaration. + */ +function makeCounter(fixture: SceneFixture, name: string, key: string, source = '') { + const behavior = makeBehavior(fixture, name, source ? [] : [key]); const editor = graphEditor(fixture, behavior); + const params: PropertyBag = source ? { [VARIABLES_PARAM]: source } : {}; const start = editor.addNode('OnStart'); - const read = editor.variables([key]); + const read = editor.variables([key], { params }); const add = editor.addNode('Add', { b: 1 }); - const write = editor.variables([key], { write: true }); + const write = editor.variables([key], { write: true, params }); editor.wire([ [start, 'exec', write, 'exec'], [read, key, add, 'a'], @@ -34,7 +44,7 @@ function makeCounter(fixture: SceneFixture, name: string, key: string, scope: 'l return behavior; } -/** Places `count` objects of the behavior and runs one frame, so every `On start` has fired. */ +/** Places one object per behavior and runs a frame, so every `On start` has fired. */ function runWith(fixture: SceneFixture, behaviors: readonly BehaviorAsset[]) { for (const behavior of behaviors) place(fixture, behavior.id, { x: 0, y: 0 }); const started = startWorld(fixture, { behaviors: true }); @@ -42,99 +52,142 @@ function runWith(fixture: SceneFixture, behaviors: readonly BehaviorAsset[]) { return started; } -describe('variable scope', () => { - it('counts once per instance when local', () => { +describe('variables of a behavior', () => { + it('counts once per instance', () => { const f = makeSpriteScene(); - const counter = makeCounter(f, 'Counter', 'score', 'local'); + const counter = makeCounter(f, 'Counter', 'score'); const started = runWith(f, [counter, counter]); const values = started.world.entities.filter((e) => e.type === 'behavior').map((e) => e.props.score); expect(values).toEqual([1, 1]); }); +}); - it('counts once for the whole run when global', () => { +describe('variables of a Variables asset', () => { + it('counts once for the whole run', () => { const f = makeSpriteScene(); - const counter = makeCounter(f, 'Counter', 'score', 'global'); + const shared = makeVariables(f, 'Score', ['score']); + const counter = makeCounter(f, 'Counter', 'score', shared.id); const started = runWith(f, [counter, counter]); - expect(started.behaviors!.shared.variables.score).toBe(2); - // The value belongs to the run, so it is no longer a property of any placed object. + expect(started.behaviors!.shared.variables[shared.id]!.score).toBe(2); + // The value belongs to the run, so no placed object carries one of its own. for (const entity of started.world.entities) expect(entity.props.score).toBeUndefined(); }); - it('shares one variable between two behaviors declaring the same global name', () => { + it('shares one value between two different behaviors', () => { const f = makeSpriteScene(); - const one = makeCounter(f, 'One', 'score', 'global'); - const other = makeCounter(f, 'Other', 'score', 'global'); + const shared = makeVariables(f, 'Score', ['score']); + const one = makeCounter(f, 'One', 'score', shared.id); + const other = makeCounter(f, 'Other', 'score', shared.id); const started = runWith(f, [one, other]); - expect(started.behaviors!.shared.variables.score).toBe(2); + expect(started.behaviors!.shared.variables[shared.id]!.score).toBe(2); }); - /** The scope sits on the declaration, so declaring the name yourself is how you opt out. */ - it('lets a behavior declaring the name locally keep its own value', () => { + /** The asset is the key, not the name – which is what two declaration places are worth. */ + it('keeps two assets apart although they declare the same name', () => { const f = makeSpriteScene(); - const shared = makeCounter(f, 'Shared', 'score', 'global'); - const own = makeCounter(f, 'Own', 'score', 'local'); - const started = runWith(f, [shared, shared, own]); - expect(started.behaviors!.shared.variables.score).toBe(2); - const ownEntity = started.world.entities.find((e) => e.assetId === own.id)!; - expect(ownEntity.props.score).toBe(1); + const players = makeVariables(f, 'Players', ['score']); + const enemies = makeVariables(f, 'Enemies', ['score']); + const player = makeCounter(f, 'Player', 'score', players.id); + const enemy = makeCounter(f, 'Enemy', 'score', enemies.id); + const started = runWith(f, [player, player, enemy]); + expect(started.behaviors!.shared.variables[players.id]!.score).toBe(2); + expect(started.behaviors!.shared.variables[enemies.id]!.score).toBe(1); }); - it('starts a global variable at its declared value again on the next run', () => { + it('starts at the declared value again on the next run', () => { const f = makeSpriteScene(); - const counter = makeCounter(f, 'Counter', 'score', 'global'); + const shared = makeVariables(f, 'Score', ['score']); + f.stack.execute(new SetAssetPropertyCommand(shared.id, 'score', 7)); + const counter = makeCounter(f, 'Counter', 'score', shared.id); place(f, counter.id, { x: 0, y: 0 }); + const first = startWorld(f, { behaviors: true }); first.frame(); - expect(first.behaviors!.shared.variables.score).toBe(1); + expect(first.behaviors!.shared.variables[shared.id]!.score).toBe(8); const second = startWorld(f, { behaviors: true }); second.frame(); - expect(second.behaviors!.shared.variables.score).toBe(1); + expect(second.behaviors!.shared.variables[shared.id]!.score).toBe(8); + }); + + /** The declaration is in another asset, so the rename has to reach graphs it knows nothing about. */ + it('carries a rename into every graph pointing at it', () => { + const f = makeSpriteScene(); + const shared = makeVariables(f, 'Score', ['score']); + const counter = makeCounter(f, 'Counter', 'score', shared.id); + const keys = () => counter.graph.nodes.filter((n) => n.ports).flatMap((n) => n.ports!.map((p) => p.key)); + + expect(keys()).toEqual(['score', 'score']); + f.stack.execute(new RenameVariableCommand(shared.id, 'score', 'total')); + expect(keys()).toEqual(['total', 'total']); + + f.stack.undo(); + expect(keys()).toEqual(['score', 'score']); + }); + + /** A node left pointing at a deleted asset must not fall back to the behavior it sits in. */ + it('writes nowhere when the asset it names is gone', () => { + const f = makeSpriteScene(); + const counter = makeCounter(f, 'Counter', 'score', 'no-such-asset'); + const started = runWith(f, [counter]); + expect(started.world.stopRequested).toBe(false); + for (const entity of started.world.entities) expect(entity.props.score).toBeUndefined(); }); }); -describe('two behaviors declaring the same global name', () => { - /** Declares `score` as `kind` with `start` as its value, global. */ - function declare(fixture: SceneFixture, name: string, kind: VariableKind, start: PropertyValue) { - const behavior = makeBehavior(fixture, name); - fixture.stack.execute(new AddVariableCommand(behavior.id, 'score', kind)); - fixture.stack.execute(new SetAssetPropertyCommand(behavior.id, 'score', start)); - fixture.stack.execute(new SetVariableScopeCommand(behavior.id, 'score', 'global')); - return behavior; +/** + * Editing a declaration while the run is on. The run holds its values apart from the document, so + * the two have to meet on purpose: an edit reaches the run, and the run reaches nothing else. + */ +describe('a declaration edited during the run', () => { + /** Counts `score` up by one, with `lives` declared alongside it and left to the document. */ + function counting() { + const f = makeSpriteScene(); + const shared = makeVariables(f, 'Score', ['score', 'lives']); + const started = runWith(f, [makeCounter(f, 'Counter', 'score', shared.id)]); + const bag = () => started.behaviors!.shared.variables[shared.id]!; + expect(bag().score).toBe(1); + return { f, shared, started, bag }; } - it('refuses to run when the kinds disagree', () => { - const f = makeSpriteScene(); - const counting = declare(f, 'Counting', 'number', 0); - declare(f, 'Naming', 'text', ''); - place(f, counting.id, { x: 0, y: 0 }); - const started = startWorld(f, { behaviors: true }); - - expect(started.world.stopRequested).toBe(true); - expect(started.world.log.join('\n')).toContain('is a number in Counting and a text in Naming'); - expect(started.behaviors!.shared.variables.score).toBe(0); + it('reaches the running value', () => { + const { f, shared, started, bag } = counting(); + f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3)); + started.behaviors!.syncDeclarations(); + expect(bag().lives).toBe(3); }); - it('runs on the first starting value when only the defaults disagree', () => { - const f = makeSpriteScene(); - const first = declare(f, 'First', 'number', 7); - declare(f, 'Second', 'number', 99); - place(f, first.id, { x: 0, y: 0 }); - const started = startWorld(f, { behaviors: true }); - started.frame(); - - expect(started.world.stopRequested).toBe(false); - expect(started.behaviors!.shared.variables.score).toBe(7); - const conflicts = globalConflicts(f.project); - expect(conflicts.map((c) => c.severity)).toEqual(['warning']); - expect(conflicts[0]!.message).toContain('starts at 7 in First and at 99 in Second'); + it('leaves a value the run has moved on', () => { + const { f, shared, started, bag } = counting(); + f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3)); + started.behaviors!.syncDeclarations(); + // The document still declares score as 0, but the run counted it to 1 – and keeps counting. + expect(bag().score).toBe(1); }); - it('says nothing when they agree', () => { - const f = makeSpriteScene(); - declare(f, 'First', 'number', 7); - declare(f, 'Second', 'number', 7); - expect(globalConflicts(f.project)).toEqual([]); + it('wins over the run for the very key it changes', () => { + const { f, shared, started, bag } = counting(); + f.stack.execute(new SetAssetPropertyCommand(shared.id, 'score', 10)); + started.behaviors!.syncDeclarations(); + expect(bag().score).toBe(10); + }); + + it('brings a variable added during the run along', () => { + const { f, shared, started, bag } = counting(); + f.stack.execute(new AddVariableCommand(shared.id, 'coins', 'number')); + started.behaviors!.syncDeclarations(); + expect(bag().coins).toBe(0); + expect(bag().score).toBe(1); + }); + + it('writes nothing back into the document', () => { + const { f, shared, started, bag } = counting(); + f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3)); + started.behaviors!.syncDeclarations(); + // What the run counted stays in the run: the document still declares the value it started from. + const asset = f.project.assets.find((a) => a.id === shared.id)!; + expect(asset.props.score).toBe(0); + expect(bag().score).toBe(1); }); }); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index e6dc08f2..f6de9b72 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -5,7 +5,7 @@ import { SetMachineScopeCommand, } from '../../src/core/commands/assetCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; -import { SetVariableScopeCommand } from '../../src/core/commands/variableCommands'; +import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; @@ -26,10 +26,13 @@ describe('serialize', () => { expect(loaded.props.runInEditor).toBe(true); }); - it('round-trips the scope of a variable and of a state machine', () => { + /** The declarations of a Variables asset and their starting values are the whole asset. */ + it('round-trips a Variables asset and the scope of a state machine', () => { const f = makeSpriteScene(); - const behavior = makeBehavior(f, 'B', ['score']); - f.stack.execute(new SetVariableScopeCommand(behavior.id, 'score', 'global')); + const shared = new CreateAssetCommand('variables', 'Score'); + f.stack.execute(shared); + f.stack.execute(new AddVariableCommand(shared.asset.id, 'score', 'number')); + f.stack.execute(new SetAssetPropertyCommand(shared.asset.id, 'score', 7)); const machine = new CreateAssetCommand('statemachine', 'M'); f.stack.execute(machine); f.stack.execute(new SetMachineScopeCommand(machine.asset.id, 'global')); @@ -37,26 +40,17 @@ describe('serialize', () => { }); /** `local` is the absence of the field, so a project made before scopes existed saves back byte-alike. */ - it('writes no scope for a local variable and reads an unknown one as local', () => { + it('writes no scope for a local machine and reads an unknown one as local', () => { const f = makeSpriteScene(); - const behavior = makeBehavior(f, 'B', ['score']); + makeBehavior(f, 'B', ['score']); + f.stack.execute(new CreateAssetCommand('statemachine', 'M')); expect(JSON.stringify(serializeProject(f.project))).not.toContain('scope'); const loaded = deserializeProject({ - assets: [ - { - id: behavior.id, - type: 'behavior', - name: 'B', - variables: [{ key: 'score', kind: 'number', scope: 'planet' }], - }, - { id: 'm1', type: 'statemachine', name: 'M', scope: 'planet' }, - ], + assets: [{ id: 'm1', type: 'statemachine', name: 'M', scope: 'planet' }], }); - const reloaded = loaded.assets[0]!; - const machine = loaded.assets[1]!; - if (reloaded.type !== 'behavior' || machine.type !== 'statemachine') throw new Error('wrong types'); - expect(reloaded.variables[0]!.scope).toBeUndefined(); + const machine = loaded.assets[0]!; + if (machine.type !== 'statemachine') throw new Error('wrong types'); expect(machine.scope).toBeUndefined(); }); diff --git a/tests/core/variable-ports.test.ts b/tests/core/variable-ports.test.ts index 95339236..4d820695 100644 --- a/tests/core/variable-ports.test.ts +++ b/tests/core/variable-ports.test.ts @@ -11,7 +11,14 @@ import { RemoveVariableCommand, RenameVariableCommand, } from '../../src/core/commands/variableCommands'; -import { type GraphNode, GraphInterpreter, NO_LOOKUP, resolveDef, VARIABLES } from '../../src/core/graph'; +import { + type GraphNode, + GraphInterpreter, + NO_LOOKUP, + resolveDef, + VARIABLES, + VARIABLES_PARAM, +} from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; @@ -69,7 +76,8 @@ describe('a node that reads or writes several variables at once', () => { fixture.stack.execute(new RenameVariableCommand(behavior.id, 'px', 'column')); fixture.stack.execute(new RenameVariableCommand(behavior.id, 'sum', 'total')); expect((get.ports ?? []).map((p) => p.key)).toEqual(['column', 'py']); - expect(set.params).toEqual({ column: 7, py: 9 }); + // The node's own setting sits in the same bag as its typed-in values, and is untouched by a rename. + expect(set.params).toEqual({ [VARIABLES_PARAM]: '', column: 7, py: 9 }); expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'column')).toBe(true); expect(behavior.graph.edges.some((e) => e.to.node === store.id && e.to.port === 'total')).toBe(true); expect(run(behavior, fixture, { column: 0, py: 0, total: 0 })).toEqual({ column: 7, py: 9, total: 16 }); diff --git a/tests/editor/editor-run.test.ts b/tests/editor/editor-run.test.ts index 1b231bab..092eebdd 100644 --- a/tests/editor/editor-run.test.ts +++ b/tests/editor/editor-run.test.ts @@ -3,11 +3,11 @@ * mode this one edits the saved model, so these tests watch what lands in the scene itself. */ import { describe, expect, it } from 'vitest'; -import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; import { instanceChildId } from '../../src/core/model/instance'; import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; -import type { BehaviorAsset, ContainerAsset } from '../../src/core/model/Asset'; +import type { BehaviorAsset, ContainerAsset, VariablesAsset } from '../../src/core/model/Asset'; import { hasEditorRun, runsInEditor } from '../../src/core/properties/editorRun'; import { schemaFor } from '../../src/core/properties/PropertySchema'; import { resolveSceneObjectProps } from '../../src/core/properties/resolve'; @@ -15,7 +15,15 @@ import { findAsset } from '../../src/core/model/Project'; import { EditorRun } from '../../src/editor/EditorRun'; import type { World } from '../../src/runtime/World'; import { EditorState } from '../../src/editor/state'; -import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture } from '../helpers'; +import { VARIABLES_PARAM } from '../../src/core/graph'; +import { + graphEditor, + makeBehavior, + makeSpriteScene, + makeVariables, + place, + type SceneFixture, +} from '../helpers'; /** Wires `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts 100 units per second. */ function drift(f: SceneFixture, behavior: BehaviorAsset): void { @@ -250,3 +258,60 @@ describe('the keyboard of an editor run', () => { expect(f.keyboard.world).toBeNull(); }); }); + +/** + * A Variables asset while the editor runs. This world is built on the saved model, so it is the + * place where a value that belongs to the run could leak into the document – and must not. + */ +describe('shared variables of an editor run', () => { + /** A Variables asset holding `score`, and a behavior writing 1 into it when it starts. */ + function sharedScene() { + const f = scene(); + const shared = makeVariables(f, 'Score', ['score']); + const writer = makeBehavior(f, 'Writer'); + const edit = graphEditor(f, writer); + const start = edit.addNode('OnStart'); + const one = edit.addNode('Add', { a: 1, b: 0 }); + const write = edit.variables(['score'], { write: true, params: { [VARIABLES_PARAM]: shared.id } }); + edit.wire([ + [start, 'exec', write, 'exec'], + [one, 'result', write, 'score'], + ]); + const object = place(f, writer.id, { x: 0, y: 0 }); + f.state.execute(new SetOverrideCommand(f.sceneId, object, 'runInEditor', true)); + return { + ...f, + sharedId: shared.id, + writerObject: object, + live: () => f.run.liveVariables(shared.id)?.score, + declared: () => (findAsset(f.project, shared.id) as VariablesAsset).props.score, + }; + } + + it('holds a value of its own, and leaves the document the one it started from', () => { + const f = sharedScene(); + f.frame(0.1); + expect(f.live()).toBe(1); + for (let i = 0; i < 5; i++) f.frame(0.1); + expect(f.declared()).toBe(0); + }); + + it('takes over a value edited while it runs', () => { + const f = sharedScene(); + f.frame(0.1); + f.state.execute(new SetAssetPropertyCommand(f.sharedId, 'score', 5)); + f.frame(0.1); + expect(f.live()).toBe(5); + }); + + /** Without a run there is no live value, so the panels go back to showing the declaration. */ + it('answers with nothing once the run has ended', () => { + const f = sharedScene(); + f.frame(0.1); + expect(f.live()).toBe(1); + + f.state.execute(new ClearOverrideCommand(f.sceneId, f.writerObject, 'runInEditor')); + f.frame(0.1); + expect(f.run.liveVariables(f.sharedId)).toBeNull(); + }); +}); diff --git a/tests/editor/live-variables.test.ts b/tests/editor/live-variables.test.ts new file mode 100644 index 00000000..e2f03cb4 --- /dev/null +++ b/tests/editor/live-variables.test.ts @@ -0,0 +1,53 @@ +/** + * What the inspector shows for a Variables asset: the values of the run while one is on, the + * declaration otherwise. A run holds them apart from the document, so the two really do differ – + * and a behavior's own variables are properties of an object, never a run's. + */ +import { describe, expect, it } from 'vitest'; +import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import type { Asset } from '../../src/core/model/Asset'; +import type { PropertyBag } from '../../src/core/model/Properties'; +import { shownValues } from '../../src/editor/inspector/assetSection'; +import type { InspectorHost } from '../../src/editor/inspector/host'; +import { EditorState } from '../../src/editor/state'; +import { VStack } from '../../src/ui'; +import { makeBehavior, makeSpriteScene, makeVariables } from '../helpers'; + +/** A declaration of `score` = 3, and whatever the run is pretending to hold for it. */ +function setup(live: PropertyBag | null) { + const f = makeSpriteScene(); + const shared = makeVariables(f, 'Score', ['score']); + const behavior = makeBehavior(f, 'Counter', ['score']); + f.stack.execute(new SetAssetPropertyCommand(shared.id, 'score', 3)); + f.stack.execute(new SetAssetPropertyCommand(behavior.id, 'score', 3)); + + const state = new EditorState(); + state.loadProject(f.project, 1); + const host: InspectorHost = { + state, + body: new VStack(), + fields: new Map(), + nameField: null, + project: () => state.project, + liveVariables: () => live, + }; + return { host, shared: shared as Asset, behavior: behavior as Asset }; +} + +describe('the values an asset section shows', () => { + it('shows the declaration while nothing runs', () => { + const { host, shared } = setup(null); + expect(shownValues(host, shared).score).toBe(3); + }); + + it('shows the running value while a run holds one', () => { + const { host, shared } = setup({ score: 9 }); + expect(shownValues(host, shared).score).toBe(9); + }); + + /** A behavior variable lives on the placed object, so no run holds a value of it to show. */ + it('never shows a run value for a behavior', () => { + const { host, behavior } = setup({ score: 9 }); + expect(shownValues(host, behavior).score).toBe(3); + }); +}); diff --git a/tests/editor/object-variables.test.ts b/tests/editor/object-variables.test.ts index c4bef39d..8fdecafe 100644 --- a/tests/editor/object-variables.test.ts +++ b/tests/editor/object-variables.test.ts @@ -1,15 +1,17 @@ /** - * What the inspector of a *placed* behavior lists. A global variable has no value of this object's - * own, so it gets no row here – the switch on the declaration is the only place it is edited. + * What the inspector of a *placed* behavior lists. Every variable a behavior declares is a property + * of the object it sits on; what a run shares lives on a Variables asset instead, which is never + * placed and so has no object to show rows on. */ import { describe, expect, it } from 'vitest'; -import { SetVariableScopeCommand } from '../../src/core/commands/variableCommands'; +import { AddVariableCommand, RemoveVariableCommand } from '../../src/core/commands/variableCommands'; +import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { findAsset } from '../../src/core/model/Project'; import { objectRows } from '../../src/editor/inspector/objectSection'; import { makeBehavior, makeSpriteScene, place } from '../helpers'; describe('the rows of a placed behavior', () => { - it('lists a local variable and leaves out a global one', () => { + it('lists every variable it declares', () => { const f = makeSpriteScene(); const behavior = makeBehavior(f, 'Counter', ['score', 'lives']); const objectId = place(f, behavior.id, { x: 0, y: 0 }); @@ -19,9 +21,13 @@ describe('the rows of a placed behavior', () => { }; expect(rows()).toEqual(['score', 'lives']); - f.stack.execute(new SetVariableScopeCommand(behavior.id, 'score', 'global')); + f.stack.execute(new RemoveVariableCommand(behavior.id, 'score')); expect(rows()).toEqual(['lives']); - f.stack.undo(); - expect(rows()).toEqual(['score', 'lives']); + f.stack.execute(new AddVariableCommand(behavior.id, 'coins', 'number')); + expect(rows()).toEqual(['lives', 'coins']); + }); + + it('cannot place a Variables asset, so its values need no rows', () => { + expect(isPlaceableType('variables')).toBe(false); }); }); diff --git a/tests/editor/variable-choices.test.ts b/tests/editor/variable-choices.test.ts new file mode 100644 index 00000000..f6ca6b0d --- /dev/null +++ b/tests/editor/variable-choices.test.ts @@ -0,0 +1,42 @@ +/** + * What `+ Variable` offers on a Variables node. The node's own setting decides: empty means the + * behavior it sits in, a Variables asset means that asset's declarations – which is the only way + * one graph reaches a value another graph writes. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; +import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { VARIABLES, VARIABLES_PARAM } from '../../src/core/graph'; +import { variableChoices } from '../../src/editor/inspector/portSection'; +import { makeBehavior, makeProject } from '../helpers'; + +describe('the variables a node offers', () => { + const scene = () => { + const f = makeProject(); + const behavior = makeBehavior(f, 'Counter', ['own']); + const shared = new CreateAssetCommand('variables', 'Score'); + f.stack.execute(shared); + f.stack.execute(new AddVariableCommand(shared.asset.id, 'score', 'number')); + const add = (source: string) => { + const command = new AddNodeCommand(behavior.id, VARIABLES, 0, 0, { [VARIABLES_PARAM]: source }); + f.stack.execute(command); + return variableChoices(f.project, behavior.id, command.node).map((def) => def.key); + }; + return { add, sharedId: shared.asset.id }; + }; + + it('offers the behavior’s own when no asset is named', () => { + expect(scene().add('')).toEqual(['own']); + }); + + it('offers the asset’s declarations when one is named, and not the behavior’s', () => { + const { add, sharedId } = scene(); + expect(add(sharedId)).toEqual(['score']); + }); + + /** Falling back to the behavior would offer names the node cannot reach at runtime. */ + it('offers nothing when the named asset is gone', () => { + expect(scene().add('no-such-asset')).toEqual([]); + }); +}); diff --git a/tests/editor/variable-rows.test.ts b/tests/editor/variable-rows.test.ts index 102fcf7d..1f52a8b9 100644 --- a/tests/editor/variable-rows.test.ts +++ b/tests/editor/variable-rows.test.ts @@ -35,7 +35,14 @@ const PLAIN: PropertyDef[] = [ function makeHost(): InspectorHost { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); - return { state, body: new VStack(), fields: new Map(), nameField: null, project: () => state.project }; + return { + state, + body: new VStack(), + fields: new Map(), + nameField: null, + project: () => state.project, + liveVariables: () => null, + }; } const noHandlers = (): FieldHandlers => ({ onInput: () => {}, onChange: () => {} }); @@ -44,7 +51,6 @@ const withFields: FieldHandlers = { ...noHandlers(), structFieldHandlers: noHand const EDIT: VariableEditing = { rename: () => {}, remove: () => {}, - setScope: () => {}, renameField: () => {}, removeField: () => {}, addField: () => {}, diff --git a/tests/helpers.ts b/tests/helpers.ts index e9a2f863..4a7ba9b0 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -21,7 +21,7 @@ import { PROPERTIES, VARIABLES, } from '../src/core/graph'; -import type { BehaviorAsset, SceneAsset } from '../src/core/model/Asset'; +import type { BehaviorAsset, SceneAsset, VariablesAsset } from '../src/core/model/Asset'; import { findObject } from '../src/core/model/hierarchy'; import { worldPosition } from '../src/core/properties/transform'; import { resolvedHolder } from '../src/core/model/instance'; @@ -90,6 +90,18 @@ export function place( return command.objectId; } +/** Adds a Variables asset declaring the given number variables – what a run shares. */ +export function makeVariables( + fixture: ProjectFixture, + name: string, + keys: readonly string[], +): VariablesAsset { + const command = new CreateAssetCommand('variables', name); + fixture.stack.execute(command); + for (const key of keys) fixture.stack.execute(new AddVariableCommand(command.asset.id, key, 'number')); + return command.asset as VariablesAsset; +} + /** Adds a behavior asset declaring the given number variables. */ export function makeBehavior( fixture: ProjectFixture, diff --git a/tools/e2e/32-scope.mjs b/tools/e2e/32-scope.mjs index bcbbc76b..d4203234 100644 --- a/tools/e2e/32-scope.mjs +++ b/tools/e2e/32-scope.mjs @@ -2,20 +2,22 @@ import { edge, node, port, variable, written } from '../lib/builders.mjs'; import { MAKE_OBJECT } from './lib/scaffold.mjs'; /** - * Scopes: a variable or a state machine declared `global` keeps one value for the whole run instead - * of one per behavior instance. The step checks the run (two objects counting into one number), the - * switch in the inspector, and that a global variable stops being a property of the placed object. + * Shared state: a Variables asset holds one value per key for the whole run, and a state machine + * declared `global` one current state. The step checks the run (two objects counting into one + * number), that the node offers the asset's declarations, and the switch on the machine. * - * The step brings its own boxes, behavior and machine; everything it adds is removed at the end. + * The step brings its own boxes, behavior, Variables asset and machine; everything goes at the end. */ -/** `On start → Variables{set score} ← Add(score, 1)`: every instance adds one to the same number. */ +const SOURCE = { variablesAsset: 'sc-vars' }; + +/** `On start → Variables{set score} ← Add(score, 1)`, counting on the shared asset rather than its own. */ const counterGraph = { nodes: [ node('sc-start', 'OnStart', 0, 0), - node('sc-read', 'Variables', 0, 200, {}, [port('score', 'number')]), + node('sc-read', 'Variables', 0, 200, SOURCE, [port('score', 'number')]), node('sc-add', 'Add', 260, 200, { b: 1 }), - node('sc-write', 'Variables', 560, 0, {}, [written('score', 'number')]), + node('sc-write', 'Variables', 560, 0, SOURCE, [written('score', 'number')]), ], edges: [ edge('sc-e1', 'sc-start', 'exec', 'sc-write', 'exec'), @@ -24,8 +26,6 @@ const counterGraph = { ], }; -const globalScore = { ...variable('score', 'number', 0), scope: 'global' }; - export async function scope(t) { const { check, sleep, evaluate, json, shot, click, locate, asset, mutate, stop, has } = t; @@ -33,9 +33,9 @@ export async function scope(t) { await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(id)} })`); await sleep(300); }; - const selectObject = async (id) => { + const selectNode = async (nodeId) => { await evaluate( - `__gm.state.select({ kind: 'object', holderId: __gm.state.activeScene.id, objectId: ${JSON.stringify(id)} })`, + `__gm.state.select({ kind: 'node', assetId: 'sc-logic', nodeId: ${JSON.stringify(nodeId)} })`, ); await sleep(300); }; @@ -45,9 +45,8 @@ export async function scope(t) { await click(p.x, p.y); await sleep(250); }; - const scopeOfVariable = async () => (await asset('sc-logic')).variables[0].scope ?? 'local'; - // 32a. Two boxes wearing one behavior whose `score` is global. + // 32a. A Variables asset, a behavior counting on it, and two boxes wearing that behavior. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; @@ -56,11 +55,17 @@ export async function scope(t) { Object.assign(a, { id: 'sc-logic', name: 'Counter', - props: { enabled: true, runInEditor: false, score: 0 }, - variables: ${JSON.stringify([globalScore])}, + props: { enabled: true, runInEditor: false }, + variables: [], graph: ${JSON.stringify(counterGraph)}, }); - p.assets.unshift(a); + p.assets.unshift(a, { + id: 'sc-vars', + type: 'variables', + name: 'Score', + props: { score: 0 }, + variables: ${JSON.stringify([variable('score', 'number', 0)])}, + }); ${MAKE_OBJECT} scene.objects.push( make('sc-box-a', 'crate', '', 1500, 'Counter box A'), @@ -76,10 +81,11 @@ export async function scope(t) { await sleep(600); const world = await json('__gm.world()'); const carriers = world.entities.filter((e) => e.props && 'score' in e.props).map((e) => e.name); + const shared = world.shared.variables['sc-vars']; check( - 'two behavior instances count into one global variable', - world.shared.variables.score === 2, - `score = ${JSON.stringify(world.shared.variables.score)} after both started – 1 would mean each kept its own`, + 'two behavior instances count into one shared variable', + shared && shared.score === 2, + `score = ${JSON.stringify(shared && shared.score)} after both started – 1 would mean each kept its own`, ); check( 'and the value belongs to the run, not to either placed object', @@ -89,46 +95,43 @@ export async function scope(t) { await stop(); await sleep(400); - // 32c. A global variable shows no row on the object placed from the behavior – overriding one - // value of something every object shares would mean nothing. - await selectObject('sc-brain-a'); - const rowWhileGlobal = await has('score'); - const saysWhereItWent = await has('Shared by the whole run: score.'); + // 32c. The asset is where the declaration lives, and it says what it is for. + await selectAsset('sc-vars'); + const declared = await has('score'); + const explains = await has('Every graph pointing here shares them.'); check( - 'the object names the variable it no longer holds, instead of claiming there is none', - saysWhereItWent && !(await has('The behavior declares no variables.')), - `note found: ${saysWhereItWent}`, + 'the Variables asset lists its declarations and says what they are', + declared && explains, + `row: ${declared}, note: ${explains}`, ); - await shot('01-object-without-global'); + await shot('01-variables-asset'); - // 32d. The switch sits on the declaration, in the behavior's own inspector. - await selectAsset('sc-logic'); - const picker = await locate('⊕'); + // 32d. The node picks its ports from the asset it names – the whole point of naming one. + await selectNode('sc-read'); + const offersShared = await has('score'); + const complains = await has('"score" is not a variable of this behavior.'); check( - 'a global variable is marked as shared where it is declared', - Boolean(picker), - JSON.stringify(picker), + 'a node naming the asset offers its variables instead of the behavior’s', + offersShared && !complains, + `row: ${offersShared}, warning shown: ${complains}`, ); - await shot('02-declaration'); - await click(picker.x, picker.y); - await sleep(250); - await clickLast('Local – one value per object'); - await sleep(350); + await shot('02-node-source'); + + // Emptying the setting points the node back at the behavior, which declares nothing – so the + // very same port is now a stranger. That is the switch doing something visible. + await mutate(` + const g = __gm.state.project.assets.find((a) => a.id === 'sc-logic').graph; + g.nodes.find((n) => n.id === 'sc-read').params.variablesAsset = ''; + `); + await sleep(400); + const complainsNow = await has('"score" is not a variable of this behavior.'); check( - 'and the menu switches it back to one value per object', - (await scopeOfVariable()) === 'local', - `scope is now ${await scopeOfVariable()}`, + 'and clearing it points the node back at the behavior, which has no such variable', + complainsNow, + `warning after clearing: ${complainsNow}`, ); - await selectObject('sc-brain-a'); - const rowWhileLocal = await has('score'); - check( - 'the row appears on the placed object exactly while the variable is local', - !rowWhileGlobal && rowWhileLocal, - `global: ${rowWhileGlobal}, local: ${rowWhileLocal}`, - ); - - // 32e. A machine carries the same switch, and its own inspector is where it lives. + // 32e. A machine carries its scope on the asset too, with its own switch. await mutate(` const p = __gm.state.project; p.assets.unshift({ @@ -144,7 +147,7 @@ export async function scope(t) { const pick = await locate('Local', true); await click(pick.x, pick.y); await sleep(250); - await clickLast('Global – one current state for the whole run'); + await clickLast('Global'); await sleep(350); const machine = await asset('sc-machine'); check(