diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 6cd66e3e..070abdfa 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -6,12 +6,13 @@ import { isFontFile } from '../model/font'; import { removeSubtrees } from '../model/hierarchy'; import type { PropertyBag, PropertyValue } from '../model/Properties'; import { objectHolders, type Project } from '../model/Project'; +import { type Scope, scopeOf, setScope } from '../model/Scope'; import type { SceneObject } from '../model/SceneObject'; import { isTextureFile } from '../model/texture'; import { coerceValue, defaultsFor } from '../properties/PropertySchema'; import { type Removed, removeById, removeFirst, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; -import { requireAsset, requirePropDef } from './require'; +import { requireAsset, requirePropDef, requireStateMachine } from './require'; import { SetBagValueCommand } from './SetBagValueCommand'; export function buildAsset(type: AssetType, name: string, id = newId()): Asset { @@ -215,6 +216,29 @@ export class RenameAssetCommand implements Command { } } +/** A `global` machine keeps one current state for the whole run instead of one per behavior instance. */ +export class SetMachineScopeCommand implements Command { + readonly label: string; + private previous: Scope = 'local'; + + constructor( + private readonly assetId: string, + private readonly scope: Scope, + ) { + this.label = `Make state machine ${scope}`; + } + + execute(project: Project): void { + const asset = requireStateMachine(project, this.assetId); + this.previous = scopeOf(asset); + setScope(asset, this.scope); + } + + undo(project: Project): void { + setScope(requireStateMachine(project, this.assetId), this.previous); + } +} + abstract class AssetPropertyCommand extends SetBagValueCommand { protected constructor( private readonly assetId: string, diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index d249dcc0..1ef0d2ee 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 } from '../model/Asset'; +import type { Asset, BehaviorAsset, StateMachineAsset } from '../model/Asset'; import { findObject, type ObjectTree } from '../model/hierarchy'; import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance'; import { @@ -71,6 +71,12 @@ export function requireBehavior(project: Project, assetId: string): BehaviorAsse 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`); + return asset; +} + /** The graph of a behavior or of a state machine – what every graph command edits. */ export function requireGraph(project: Project, assetId: string): Graph { const asset = findGraphAsset(project, assetId); diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index 40773569..1f631071 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -7,6 +7,7 @@ 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, @@ -51,11 +52,45 @@ export class AddVariableCommand implements Command { } } +/** One placed object's own value for a variable, remembered so that undo can put it back. */ +interface TakenOverride { + holderId: string; + objectId: string; + path: string; + value: PropertyValue; +} + +function takeOverrides(project: Project, assetId: string, key: string): TakenOverride[] { + const taken: TakenOverride[] = []; + for (const site of overrideSites(project, (id) => id === assetId)) { + const value = site.bag[key]; + if (value === undefined) continue; + taken.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value }); + delete site.bag[key]; + } + return taken; +} + +function restoreOverrides( + project: Project, + assetId: string, + key: string, + taken: readonly TakenOverride[], +): void { + const sites = overrideSites(project, (id) => id === assetId); + for (const o of taken) { + const site = sites.find( + (s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path, + ); + if (site) site.bag[key] = o.value; + } +} + /** Removes a variable together with its default and every instance override. */ export class RemoveVariableCommand implements Command { readonly label: string; private removed?: { def: Removed; value: PropertyValue | undefined }; - private overrides: { holderId: string; objectId: string; path: string; value: PropertyValue }[] = []; + private overrides: TakenOverride[] = []; constructor( private readonly assetId: string, @@ -70,13 +105,7 @@ export class RemoveVariableCommand implements Command { if (!def) throw new CommandError(`Variable "${this.key}" not found`); this.removed = { def, value: asset.props[this.key] }; delete asset.props[this.key]; - this.overrides = []; - for (const site of overrideSites(project, (id) => id === this.assetId)) { - const value = site.bag[this.key]; - if (value === undefined) continue; - this.overrides.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value }); - delete site.bag[this.key]; - } + this.overrides = takeOverrides(project, this.assetId, this.key); } undo(project: Project): void { @@ -84,13 +113,40 @@ export class RemoveVariableCommand implements Command { const asset = requireBehavior(project, this.assetId); restoreAt(asset.variables, [this.removed.def]); if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value; - const sites = overrideSites(project, (id) => id === this.assetId); - for (const o of this.overrides) { - const site = sites.find( - (s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path, - ); - if (site) site.bag[this.key] = o.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); } } diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 89d5e3b1..ee03c8cb 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -69,6 +69,16 @@ export interface PointerState { released: boolean; } +/** + * 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`). + */ +export interface SharedState { + variables: PropertyBag; + states: Record; +} + /** * Everything the graph does to the world goes through here, so the core stays free of runtime * imports. Implemented by the runtime (BehaviorSystem) and by test doubles. @@ -83,6 +93,7 @@ export interface NodeContext { * as the state node's id. A machine not in here is in no state: `State branch` follows nothing. */ states: Record; + shared: SharedState; /** Seconds since the world started and since the last tick. */ time: number; dt: number; diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts index b0e5263b..a55f5c9b 100644 --- a/src/core/graph/index.ts +++ b/src/core/graph/index.ts @@ -8,6 +8,7 @@ export * from './nested'; export * from './NodeContext'; export * from './NodeRegistry'; export * from './propertyPorts'; +export * from './sharedState'; export * from './stateMachine'; export * from './values'; export * from './variablePorts'; diff --git a/src/core/graph/nodes/flow/SetState.ts b/src/core/graph/nodes/flow/SetState.ts index e4d77866..62ba7de1 100644 --- a/src/core/graph/nodes/flow/SetState.ts +++ b/src/core/graph/nodes/flow/SetState.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { stateBag } from '../../sharedState'; import { machineOf, machinePort, statePorts } from '../../stateMachine'; import { asString } from '../../values'; import { exec } from '../shared'; @@ -10,14 +11,15 @@ export const SetState: NodeDef = { category: 'action', description: 'Puts the machine into the state whose input the flow arrived through, then continues. Each ' + - 'behavior instance keeps its own states.', + 'behavior instance keeps its own states, unless the machine is global.', inputs: [machinePort()], outputs: [exec()], params: [], derivePorts: (source) => ({ inputs: statePorts(machineOf(source)) }), run: (ctx, inputs, _params, def, entry) => { const machine = asString(inputs.machine); - if (machine && def.inputs.some((p) => p.type === 'exec' && p.key === entry)) ctx.states[machine] = entry; + if (machine && def.inputs.some((p) => p.type === 'exec' && p.key === entry)) + stateBag(ctx, machine)[machine] = entry; return { next: 'exec' }; }, }; diff --git a/src/core/graph/nodes/flow/StateBranch.ts b/src/core/graph/nodes/flow/StateBranch.ts index 46eba4a2..449576b0 100644 --- a/src/core/graph/nodes/flow/StateBranch.ts +++ b/src/core/graph/nodes/flow/StateBranch.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { stateBag } from '../../sharedState'; import { machineOf, machinePort, statePorts } from '../../stateMachine'; import { asString } from '../../values'; import { exec } from '../shared'; @@ -16,7 +17,8 @@ export const StateBranch: NodeDef = { params: [], derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }), run: (ctx, inputs, _params, def) => { - const current = ctx.states[asString(inputs.machine)]; + const machine = asString(inputs.machine); + const current = stateBag(ctx, machine)[machine]; return def.outputs.some((p) => p.key === current) ? { next: current } : {}; }, }; diff --git a/src/core/graph/nodes/flow/StateTransition.ts b/src/core/graph/nodes/flow/StateTransition.ts index 1095b5ae..7e819962 100644 --- a/src/core/graph/nodes/flow/StateTransition.ts +++ b/src/core/graph/nodes/flow/StateTransition.ts @@ -1,6 +1,7 @@ import { textOf } from '../../../model/Properties'; import { flatDef } from '../../../properties/PropertySchema'; import type { NodeDef } from '../../NodeRegistry'; +import { stateBag } from '../../sharedState'; import { machinePort, STATE_TRANSITION, TRANSITION_PARAM, transitionTo } from '../../stateMachine'; import { asString } from '../../values'; import { exec } from '../shared'; @@ -18,11 +19,12 @@ export const StateTransition: NodeDef = { params: [flatDef(TRANSITION_PARAM, 'text', { label: 'Transition', value: '' })], run: (ctx, inputs, params, _def, _entry, lookup) => { const id = asString(inputs.machine); - const current = ctx.states[id] ?? ''; + const states = stateBag(ctx, id); + const current = states[id] ?? ''; const machine = current ? lookup.machine(id) : null; const name = textOf(params[TRANSITION_PARAM] ?? ''); const target = machine ? transitionTo(machine, current, name) : ''; - if (target) ctx.states[id] = target; + if (target) states[id] = target; return { next: 'exec' }; }, }; diff --git a/src/core/graph/nodes/variable/Variables.ts b/src/core/graph/nodes/variable/Variables.ts index c8dc5164..b523d48b 100644 --- a/src/core/graph/nodes/variable/Variables.ts +++ b/src/core/graph/nodes/variable/Variables.ts @@ -1,5 +1,6 @@ import { collapsedTitle, portDefOf, VARIABLES } from '../../nested'; import { type NodeDef, withInlineDefault } from '../../NodeRegistry'; +import { readableVariables, variableBag } from '../../sharedState'; import { writeTyped } from '../../values'; import { exec } from '../shared'; @@ -24,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) => ctx.variables, + read: (ctx) => readableVariables(ctx), run: (ctx, inputs, _params, def) => { for (const port of def.inputs) - if (port.type !== 'exec') writeTyped(ctx.variables, port.key, inputs[port.key]); + if (port.type !== 'exec') writeTyped(variableBag(ctx, port.key), port.key, inputs[port.key]); return { next: 'exec' }; }, }; diff --git a/src/core/graph/sharedState.ts b/src/core/graph/sharedState.ts new file mode 100644 index 00000000..133ff07b --- /dev/null +++ b/src/core/graph/sharedState.ts @@ -0,0 +1,115 @@ +import type { Asset } from '../model/Asset'; +import type { PropertyBag, PropertyValue } 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: {} }; +} + +/** + * 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`). + */ +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] = ''; + } + } + 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. + */ +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.`, + }); + } + } + } + return conflicts; +} + +/** 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 }; +} + +/** 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 4c1713c8..6b654a4e 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -2,6 +2,7 @@ import type { Graph } from '../graph/Graph'; import type { PropertyDef } from '../properties/PropertySchema'; import type { AssetType } from './AssetTypes'; import type { PropertyBag } from './Properties'; +import type { Scope } from './Scope'; import type { SceneObject } from './SceneObject'; export interface AssetBase { @@ -85,10 +86,12 @@ export interface AnimationAsset extends AssetBase { /** * The graph holds one `State` node per state and an edge per transition; a behavior reads the * machine's current state through `State branch` and moves it through `Set state` or `State transition`. + * A `global` machine has one current state for the whole run instead of one per behavior instance. */ export interface StateMachineAsset extends AssetBase { type: 'statemachine'; graph: Graph; + scope?: Scope; } /** diff --git a/src/core/model/Scope.ts b/src/core/model/Scope.ts new file mode 100644 index 00000000..9012e9ff --- /dev/null +++ b/src/core/model/Scope.ts @@ -0,0 +1,29 @@ +/** + * 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. + */ +export type Scope = 'local' | 'global'; + +export interface Scoped { + scope?: Scope; +} + +export function scopeOf(owner: Scoped): Scope { + return owner.scope === 'global' ? 'global' : 'local'; +} + +export function isGlobal(owner: Scoped): boolean { + return owner.scope === 'global'; +} + +/** Reads a scope out of loaded data, where anything but the one known word means `local`. */ +export function readScope(raw: unknown): Scope { + return raw === 'global' ? 'global' : 'local'; +} + +/** Keeps `local` out of the document: the field is present only when it says `global`. */ +export function setScope(owner: Scoped, scope: Scope): void { + if (scope === 'global') owner.scope = 'global'; + else delete owner.scope; +} diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index 6ba0592c..ec57b000 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -1,4 +1,5 @@ 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'; @@ -12,7 +13,8 @@ export function graphLookup(project: Project): GraphLookup { behavior: (assetId) => { const asset = findAssetOf(project, assetId, 'behavior'); if (!asset) return null; - return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(project, asset) }; + const defaults = withoutGlobals(resolveAssetProps(project, asset), asset); + return { name: asset.name, graph: asset.graph, defaults }; }, machine: (assetId) => { const asset = findAssetOf(project, assetId, 'statemachine'); diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index a9dc3b15..e67b46e2 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -20,6 +20,7 @@ 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'; @@ -57,10 +58,14 @@ 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

} & Extras

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

; scope?: Scope } & Extras

; }[K]; -/** The inspector is generated from these entries, so a new property is one line in a schema below. */ +/** + * 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`. + */ export type PropertyDef = | FlatDef /** @@ -69,12 +74,19 @@ 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 } + | { key: string; label: string; kind: 'list'; default: ListValue; scope?: Scope } /** * `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`). */ - | { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] }; + | { + key: string; + label: string; + kind: 'struct'; + default: StructValue; + fields: readonly StructFieldDef[]; + scope?: Scope; + }; export interface PrimitiveFieldDef { key: string; @@ -299,10 +311,15 @@ 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 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). + */ export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema { const shader = object.overrides[SHADER_KEY]; - return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader }); + const rows = schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader }); + return rows.some(isGlobal) ? rows.filter((def) => !isGlobal(def)) : rows; } function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema { diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index a572a9f1..6e43f672 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -14,7 +14,7 @@ import { } from '../graph'; import { isParticleNodeDef } from '../particles/nodes'; import { isShaderNodeDef } from '../shader/nodes'; -import type { Asset, TextureFrame } from '../model/Asset'; +import type { Asset, StateMachineAsset, TextureFrame } from '../model/Asset'; import { isAssetType } from '../model/AssetTypes'; import { isFontFile } from '../model/font'; import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance'; @@ -26,6 +26,7 @@ import { tagOf, } from '../model/Properties'; import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; +import { readScope } from '../model/Scope'; import type { SceneObject } from '../model/SceneObject'; import { isTextureFile, normalizeFrameRect } from '../model/texture'; import { hasParentCycle } from '../model/walk'; @@ -130,7 +131,15 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { graph: normalizeGraph(a.graph), }); } else if (type === 'statemachine') { - assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, (def) => def.type === STATE) }); + const machine: StateMachineAsset = { + id, + type, + name, + props, + graph: normalizeGraph(a.graph, (def) => def.type === STATE), + }; + if (readScope(a.scope) === 'global') machine.scope = 'global'; + assets.push(machine); } else if (type === 'shader') { assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() }); } else if (type === 'particles') { @@ -251,6 +260,8 @@ 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/PlaySession.ts b/src/editor/PlaySession.ts index 3bd77eee..3b4002d8 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -3,7 +3,7 @@ * keyboard and the per-frame step. Play never touches the saved model – the world is built * from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away. */ -import { worldPositionOf } from '../core/graph'; +import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph'; import { addGameSystems, type BehaviorSystem, @@ -33,6 +33,8 @@ export interface WorldSnapshot { props: Record; animation: AnimationClock | null; }[]; + /** What `global` declarations share this run – no entity carries it, so it is reported apart. */ + shared: SharedState; log: string[]; } @@ -169,6 +171,7 @@ export class PlaySession { animation: e.animation ? { ...e.animation } : null, }; }), + shared: this.behaviors?.shared ?? emptySharedState(), log: [...world.log], }; } diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 12965722..f23ff1d9 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -2,6 +2,7 @@ import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand, + SetMachineScopeCommand, } from '../../core/commands/assetCommands'; import { nextFieldName, nextVariableName } from '../../core/commands/naming'; import { @@ -11,10 +12,13 @@ import { RemoveVariableCommand, RenameStructFieldCommand, RenameVariableCommand, + SetVariableScopeCommand, } from '../../core/commands/variableCommands'; import type { Command } from '../../core/commands/Command'; -import type { Asset, BehaviorAsset } from '../../core/model/Asset'; +import type { Asset, BehaviorAsset, StateMachineAsset } from '../../core/model/Asset'; +import { globalConflicts } from '../../core/graph/sharedState'; import { findAsset, type Project } from '../../core/model/Project'; +import { isGlobal, type Scope, scopeOf } from '../../core/model/Scope'; import { type StructFieldKind, VARIABLE_KINDS } from '../../core/properties/kinds'; import { type PropertyDef, schemaForAsset, type StructFieldDef } from '../../core/properties/PropertySchema'; import { markedAssetSchema } from '../../core/properties/deviation'; @@ -22,7 +26,7 @@ import { isModified, resolveAssetProps } from '../../core/properties/resolve'; import { Dropdown, HStack, Label, Menu, Theme } from '../../ui'; import { FontPreview } from '../font/FontPreview'; import { aspectActions, sizeCommand } from './aspectAction'; -import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory'; +import { actionIf, createFieldRow, type FieldHandlers, NAME_WIDTH } from './fieldFactory'; import { addNameField, addNote, addTitle, type InspectorHost } from './host'; import { buildVariable, kindMenu, type VariableEditing } from './variableRows'; import { listOpener, previewingHandlers, structFields } from './previewHandlers'; @@ -36,13 +40,16 @@ const newField = (key: string, kind: StructFieldKind): StructFieldDef => ({ key, */ export function assetSignature(project: Project, assetId: string): string { const asset = findAsset(project, assetId); - return asset ? schemaForAsset(project, asset).map(defSignature).join(',') : 'missing'; + if (!asset) return 'missing'; + // A machine's own rows are none, so its scope has to be named here or switching it rebuilds nothing. + const machine = asset.type === 'statemachine' ? `|${scopeOf(asset)}` : ''; + return schemaForAsset(project, asset).map(defSignature).join(',') + machine; } export function defSignature(def: PropertyDef): string { // Only a struct has rows of its own; a list is one row whatever its items are. const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : ''; - return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`; + return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}@${scopeOf(def)}`; } /** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */ @@ -59,6 +66,7 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void { buildProps(host, asset, markedAssetSchema(host.state.project, asset)); if (asset.type === 'behavior') buildVariables(host, asset); + if (asset.type === 'statemachine') buildMachineScope(host, asset); // A font has no properties at all, so without this its inspector would be the name and nothing. if (asset.type === 'font') host.body.add( @@ -152,6 +160,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { remove: () => run(new RemoveVariableCommand(asset.id, def.key)), renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)), removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)), + setScope: (scope) => run(new SetVariableScopeCommand(asset.id, def.key, scope)), addField: (kind) => run( new AddStructFieldCommand( @@ -164,4 +173,35 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { const value = values[def.key] ?? def.default; host.fields.set(def.key, buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit })); } + + // The mark alone says nothing to someone meeting it for the first time. + if (asset.variables.some(isGlobal)) + addNote(host, '⊕ is one value for the whole run, shared by name.', 'small'); + + // A global name is shared with every other behavior declaring it, so the disagreement shows here. + const own = new Set(asset.variables.map((v) => v.key)); + for (const conflict of globalConflicts(host.state.project)) + if (own.has(conflict.key)) addNote(host, conflict.message, 'warning'); +} + +/** A state machine has no properties of its own, so the inspector shows just its name and this. */ +function buildMachineScope(host: InspectorHost, asset: StateMachineAsset): void { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.add(new Label('Scope', { style: 'muted', width: NAME_WIDTH, padding: Theme.fieldPadding })); + const pick = new Dropdown(isGlobal(asset) ? 'Global' : 'Local', { variant: 'ghost' }); + const set = (scope: Scope) => () => void host.state.execute(new SetMachineScopeCommand(asset.id, scope)); + pick.onClick = () => + new Menu([ + { label: 'Local \u2013 one current state per object', onSelect: set('local') }, + { label: 'Global \u2013 one current state for the whole run', onSelect: set('global') }, + ]).openBelow(pick); + row.add(pick, 1); + host.body.add(row); + addNote( + host, + isGlobal(asset) + ? 'Every behavior using this machine shares its current state.' + : 'Each behavior instance keeps its own current state.', + 'small', + ); } diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 4e26e3cb..b9a7b77d 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -24,6 +24,7 @@ import { } from '../../core/model/instance'; import type { PropertyValue } from '../../core/model/Properties'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; +import { isGlobal } from '../../core/model/Scope'; import type { SceneObject } from '../../core/model/SceneObject'; import { COMMON_PROPS, @@ -52,6 +53,17 @@ import { buildVariable } from './variableRows'; import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries'; import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows'; +/** + * The rows a placed object shows for what it was placed from: a behavior's declared variables, any + * other asset's schema minus the common switches. A global variable is none of them – it belongs to + * the run, so this object has no value of its own to show or override. + */ +export function objectRows(project: Project, asset: Asset, object: SceneObject): PropertyDef[] { + return asset.type === 'behavior' + ? asset.variables.filter((def) => !isGlobal(def)) + : schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key)); +} + /** Structure-defining facts of an object selection: parent, template link and schema. */ export function objectSignature( project: Project, @@ -186,14 +198,17 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object // Asset properties (or behavior variables) with overrides; the switches are shown above. const values = resolveSceneObjectProps(host.state.project, asset, object); - const own = - asset.type === 'behavior' - ? asset.variables - : schemaForObject(host.state.project, asset, object).filter((def) => !isCommonProp(def.key)); + const own = objectRows(host.state.project, asset, object); if (own.length > 0 || asset.type === 'behavior') addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES'); - if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.'); + // A global variable is missing from the rows above but not from the behavior, so saying it + // declares none would be untrue – name it instead, and where it went. + const shared = asset.type === 'behavior' ? asset.variables.filter(isGlobal) : []; + if (own.length === 0 && shared.length === 0 && asset.type === 'behavior') + addNote(host, 'The behavior declares no variables.'); for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, asset.type === 'behavior'); + if (shared.length > 0) + addNote(host, `Shared by the whole run: ${shared.map((def) => def.key).join(', ')}.`, 'small'); markObjectRows(host, object, asset, target); if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length); diff --git a/src/editor/inspector/variableRows.ts b/src/editor/inspector/variableRows.ts index 2dd0fe25..0542b93f 100644 --- a/src/editor/inspector/variableRows.ts +++ b/src/editor/inspector/variableRows.ts @@ -1,4 +1,5 @@ import type { PropertyValue } from '../../core/model/Properties'; +import { type Scope, scopeOf } from '../../core/model/Scope'; import { kindLabel, kindMark, STRUCT_FIELD_KINDS, type StructFieldKind } from '../../core/properties/kinds'; import type { PropertyDef, StructDef, StructFieldDef } from '../../core/properties/PropertySchema'; import { @@ -39,11 +40,21 @@ export function kindMenu(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. */ @@ -71,6 +82,7 @@ export function buildVariable( } else { setValue = addValue(head, createField(host, def, value, handlers)); } + if (edit) head.add(scopePicker(def, edit)); if (edit) head.add(removeButton(edit.remove)); const refreshActions = actions ? attachRowActions(head, actions) : () => {}; host.body.add(head); diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 167b8b43..637621db 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -1,11 +1,15 @@ import { asNumber, + buildSharedState, + emptySharedState, + globalConflicts, type EntityHandle, type Graph, type GraphNode, GraphInterpreter, isHandleEnabled, type NodeContext, + type SharedState, type TraceListener, type Value, } from '../../core/graph'; @@ -83,6 +87,7 @@ export function behaviorError(world: World): string | undefined { export class BehaviorSystem implements System { private instances: Instance[] = []; private world: World | null = null; + private sharedState: SharedState = emptySharedState(); private readonly collisions: CollisionSystem | null; private readonly animations: AnimationSystem | null; @@ -100,9 +105,24 @@ export class BehaviorSystem implements System { this.animations = sources.animations ?? null; } + /** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */ + get shared(): Readonly { + return this.sharedState; + } + 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.syncInstances(world); } @@ -341,6 +361,7 @@ export class BehaviorSystem implements System { owner: entity.parent, variables: entity.props, states, + shared: this.sharedState, time: world.time, dt, findByName: (name) => world.findByName(name) ?? null, diff --git a/tests/core/scope.test.ts b/tests/core/scope.test.ts new file mode 100644 index 00000000..0d3544b5 --- /dev/null +++ b/tests/core/scope.test.ts @@ -0,0 +1,202 @@ +/** + * 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. + */ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + SetAssetPropertyCommand, + 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 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'; + +/** 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)); + const editor = graphEditor(fixture, behavior); + const start = editor.addNode('OnStart'); + const read = editor.variables([key]); + const add = editor.addNode('Add', { b: 1 }); + const write = editor.variables([key], { write: true }); + editor.wire([ + [start, 'exec', write, 'exec'], + [read, key, add, 'a'], + [add, 'result', write, key], + ]); + return behavior; +} + +/** Places `count` objects of the behavior and runs one 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 }); + started.frame(); + return started; +} + +describe('variable scope', () => { + it('counts once per instance when local', () => { + const f = makeSpriteScene(); + const counter = makeCounter(f, 'Counter', 'score', 'local'); + 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', () => { + const f = makeSpriteScene(); + const counter = makeCounter(f, 'Counter', 'score', 'global'); + 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. + for (const entity of started.world.entities) expect(entity.props.score).toBeUndefined(); + }); + + it('shares one variable between two behaviors declaring the same global name', () => { + const f = makeSpriteScene(); + const one = makeCounter(f, 'One', 'score', 'global'); + const other = makeCounter(f, 'Other', 'score', 'global'); + const started = runWith(f, [one, other]); + expect(started.behaviors!.shared.variables.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', () => { + 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); + }); + + it('starts a global variable at its declared value again on the next run', () => { + const f = makeSpriteScene(); + const counter = makeCounter(f, 'Counter', 'score', 'global'); + place(f, counter.id, { x: 0, y: 0 }); + const first = startWorld(f, { behaviors: true }); + first.frame(); + expect(first.behaviors!.shared.variables.score).toBe(1); + + const second = startWorld(f, { behaviors: true }); + second.frame(); + expect(second.behaviors!.shared.variables.score).toBe(1); + }); +}); + +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; + } + + 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('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('says nothing when they agree', () => { + const f = makeSpriteScene(); + declare(f, 'First', 'number', 7); + declare(f, 'Second', 'number', 7); + expect(globalConflicts(f.project)).toEqual([]); + }); +}); + +describe('state machine scope', () => { + /** A machine with one state, plus a behavior that enters it on start. */ + function makeMachineAndSetter(fixture: SceneFixture, scope: 'local' | 'global') { + const machine = new CreateAssetCommand('statemachine', 'Doors'); + fixture.stack.execute(machine); + const state = new AddNodeCommand(machine.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: 'Open' }); + fixture.stack.execute(state); + if (scope === 'global') fixture.stack.execute(new SetMachineScopeCommand(machine.asset.id, scope)); + + const behavior = makeBehavior(fixture, 'Opener'); + const editor = graphEditor(fixture, behavior); + const start = editor.addNode('OnStart'); + const set = editor.addNode('SetState', { machine: machine.asset.id }); + fixture.stack.execute( + new ConnectCommand( + behavior.id, + { node: start.id, port: 'exec' }, + { node: set.id, port: state.node.id }, + ), + ); + return { machineId: machine.asset.id, stateId: state.node.id, behavior }; + } + + /** + * A second behavior that never sets the state, only branches on it: it writes `sawOpen = 1` when + * the machine is already in Open. That is the whole difference a scope makes. + */ + function makeReader(fixture: SceneFixture, machineId: string, stateId: string) { + const behavior = makeBehavior(fixture, 'Reader', ['sawOpen']); + const editor = graphEditor(fixture, behavior); + const start = editor.addNode('OnStart'); + const branch = editor.addNode('StateBranch', { machine: machineId }); + const one = editor.addNode('Add', { a: 1, b: 0 }); + const write = editor.variables(['sawOpen'], { write: true }); + editor.wire([ + [start, 'exec', branch, 'exec'], + [branch, stateId, write, 'exec'], + [one, 'result', write, 'sawOpen'], + ]); + return behavior; + } + + it('hides the state from another instance when local', () => { + const f = makeSpriteScene(); + const { machineId, stateId, behavior } = makeMachineAndSetter(f, 'local'); + const reader = makeReader(f, machineId, stateId); + const started = runWith(f, [behavior, reader]); + expect(started.behaviors!.shared.states[machineId]).toBeUndefined(); + const seen = started.world.entities.find((e) => e.assetId === reader.id)!; + expect(seen.props.sawOpen).toBe(0); + }); + + it('shows the state to another instance when global', () => { + const f = makeSpriteScene(); + const { machineId, stateId, behavior } = makeMachineAndSetter(f, 'global'); + const reader = makeReader(f, machineId, stateId); + const started = runWith(f, [behavior, reader]); + expect(started.behaviors!.shared.states[machineId]).toBe(stateId); + const seen = started.world.entities.find((e) => e.assetId === reader.id)!; + expect(seen.props.sawOpen).toBe(1); + }); +}); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index 2e2de893..e6dc08f2 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { + CreateAssetCommand, + SetAssetPropertyCommand, + SetMachineScopeCommand, +} from '../../src/core/commands/assetCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; -import { deserializeProject } from '../../src/core/store/serialize'; +import { SetVariableScopeCommand } from '../../src/core/commands/variableCommands'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; describe('serialize', () => { @@ -21,6 +26,40 @@ describe('serialize', () => { expect(loaded.props.runInEditor).toBe(true); }); + it('round-trips the scope of a variable and of a state machine', () => { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'B', ['score']); + f.stack.execute(new SetVariableScopeCommand(behavior.id, 'score', 'global')); + const machine = new CreateAssetCommand('statemachine', 'M'); + f.stack.execute(machine); + f.stack.execute(new SetMachineScopeCommand(machine.asset.id, 'global')); + expect(roundTrip(f.project)).toEqual(f.project); + }); + + /** `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', () => { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'B', ['score']); + 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' }, + ], + }); + 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(); + expect(machine.scope).toBeUndefined(); + }); + it('repairs broken documents', () => { const project = deserializeProject( { diff --git a/tests/editor/object-variables.test.ts b/tests/editor/object-variables.test.ts new file mode 100644 index 00000000..c4bef39d --- /dev/null +++ b/tests/editor/object-variables.test.ts @@ -0,0 +1,27 @@ +/** + * 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. + */ +import { describe, expect, it } from 'vitest'; +import { SetVariableScopeCommand } from '../../src/core/commands/variableCommands'; +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', () => { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'Counter', ['score', 'lives']); + const objectId = place(f, behavior.id, { x: 0, y: 0 }); + const rows = () => { + const asset = findAsset(f.project, behavior.id)!; + return objectRows(f.project, asset, f.object(objectId)).map((def) => def.key); + }; + + expect(rows()).toEqual(['score', 'lives']); + f.stack.execute(new SetVariableScopeCommand(behavior.id, 'score', 'global')); + expect(rows()).toEqual(['lives']); + f.stack.undo(); + expect(rows()).toEqual(['score', 'lives']); + }); +}); diff --git a/tests/editor/variable-rows.test.ts b/tests/editor/variable-rows.test.ts index 77d81fdc..102fcf7d 100644 --- a/tests/editor/variable-rows.test.ts +++ b/tests/editor/variable-rows.test.ts @@ -44,6 +44,7 @@ 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 da966538..e9a2f863 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -7,6 +7,7 @@ import { requireNodeDef } from '../src/core/commands/require'; import { AddVariableCommand } from '../src/core/commands/variableCommands'; import { type DeclaredPort, + emptySharedState, ENTITY_HANDLE, type EntityHandle, type GraphLookup, @@ -185,6 +186,7 @@ export function fakeContext(overrides: Partial = {}): FakeContext { owner: fakeEntity('owner'), variables: { speed: 100 }, states: {}, + shared: emptySharedState(), time: 0, dt: 0.5, logs, diff --git a/tools/e2e/32-scope.mjs b/tools/e2e/32-scope.mjs new file mode 100644 index 00000000..bcbbc76b --- /dev/null +++ b/tools/e2e/32-scope.mjs @@ -0,0 +1,166 @@ +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. + * + * The step brings its own boxes, behavior and machine; everything it adds is removed at the end. + */ + +/** `On start → Variables{set score} ← Add(score, 1)`: every instance adds one to the same number. */ +const counterGraph = { + nodes: [ + node('sc-start', 'OnStart', 0, 0), + node('sc-read', 'Variables', 0, 200, {}, [port('score', 'number')]), + node('sc-add', 'Add', 260, 200, { b: 1 }), + node('sc-write', 'Variables', 560, 0, {}, [written('score', 'number')]), + ], + edges: [ + edge('sc-e1', 'sc-start', 'exec', 'sc-write', 'exec'), + edge('sc-e2', 'sc-read', 'score', 'sc-add', 'a'), + edge('sc-e3', 'sc-add', 'result', 'sc-write', 'score'), + ], +}; + +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; + + const selectAsset = async (id) => { + await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(id)} })`); + await sleep(300); + }; + const selectObject = async (id) => { + await evaluate( + `__gm.state.select({ kind: 'object', holderId: __gm.state.activeScene.id, objectId: ${JSON.stringify(id)} })`, + ); + await sleep(300); + }; + /** The inspector is drawn over the graph, so the last match is the row rather than a port. */ + const clickLast = async (text) => { + const p = await locate(text, true); + 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. + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const mover = p.assets.find((a) => a.id === 'mover'); + const a = structuredClone(mover); + Object.assign(a, { + id: 'sc-logic', + name: 'Counter', + props: { enabled: true, runInEditor: false, score: 0 }, + variables: ${JSON.stringify([globalScore])}, + graph: ${JSON.stringify(counterGraph)}, + }); + p.assets.unshift(a); + ${MAKE_OBJECT} + scene.objects.push( + make('sc-box-a', 'crate', '', 1500, 'Counter box A'), + make('sc-brain-a', 'sc-logic', 'sc-box-a', 0, 'Counter A'), + make('sc-box-b', 'crate', '', 1700, 'Counter box B'), + make('sc-brain-b', 'sc-logic', 'sc-box-b', 0, 'Counter B'), + ); + `); + await sleep(400); + + // 32b. The run: two instances, one number. A per-instance variable would stand at 1. + await t.play(); + 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); + 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`, + ); + check( + 'and the value belongs to the run, not to either placed object', + carriers.length === 0, + `objects carrying "score": ${JSON.stringify(carriers)}`, + ); + 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.'); + 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}`, + ); + await shot('01-object-without-global'); + + // 32d. The switch sits on the declaration, in the behavior's own inspector. + await selectAsset('sc-logic'); + const picker = await locate('⊕'); + check( + 'a global variable is marked as shared where it is declared', + Boolean(picker), + JSON.stringify(picker), + ); + await shot('02-declaration'); + await click(picker.x, picker.y); + await sleep(250); + await clickLast('Local – one value per object'); + await sleep(350); + check( + 'and the menu switches it back to one value per object', + (await scopeOfVariable()) === 'local', + `scope is now ${await scopeOfVariable()}`, + ); + + 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. + await mutate(` + const p = __gm.state.project; + p.assets.unshift({ + id: 'sc-machine', + type: 'statemachine', + name: 'Shared doors', + props: {}, + graph: { nodes: [], edges: [] }, + }); + `); + await selectAsset('sc-machine'); + const machineScope = await has('Local'); + 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 sleep(350); + const machine = await asset('sc-machine'); + check( + 'a state machine is switched between one current state per object and one for the run', + machineScope && machine.scope === 'global', + `dropdown found: ${machineScope}, scope now ${JSON.stringify(machine.scope)}`, + ); + await shot('03-machine-scope'); + + // 32f. Everything this step added goes again. + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => !o.id.startsWith('sc-')); + p.assets = p.assets.filter((a) => !a.id.startsWith('sc-')); + __gm.state.select(null); + `); + await sleep(300); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index c9f6dde1..37e5d87f 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -34,6 +34,7 @@ import { shader } from './28-shader.mjs'; import { transform } from './29-transform.mjs'; import { font } from './30-font.mjs'; import { particles } from './31-particles.mjs'; +import { scope } from './32-scope.mjs'; export const STEPS = [ hierarchy, @@ -67,4 +68,5 @@ export const STEPS = [ transform, font, particles, + scope, ];