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

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

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

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

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

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

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

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

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

No files matched your search

+2
View File
@@ -30,6 +30,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'text' }; return { ...base, type: 'text' };
case 'behavior': case 'behavior':
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() }; return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
case 'variables':
return { ...base, type: 'variables', variables: [] };
case 'collider': case 'collider':
return { ...base, type: 'collider' }; return { ...base, type: 'collider' };
case 'container': case 'container':
+2 -2
View File
@@ -1,4 +1,4 @@
import type { BehaviorAsset } from '../model/Asset'; import type { BehaviorAsset, VariablesAsset } from '../model/Asset';
import type { Project } from '../model/Project'; import type { Project } from '../model/Project';
function firstFree(taken: ReadonlySet<string>, candidate: (i: number) => string, from: number): string { function firstFree(taken: ReadonlySet<string>, 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. */ /** "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); return firstFree(new Set(asset.variables.map((v) => v.key)), (i) => `${base}${i}`, 1);
} }
+6 -1
View File
@@ -14,6 +14,7 @@ import {
OBJECT_BEHAVIOR_PARAM, OBJECT_BEHAVIOR_PARAM,
propertyTargetOf, propertyTargetOf,
RUN_BEHAVIOR, RUN_BEHAVIOR,
variablesSourceOf,
} from '../graph'; } from '../graph';
import type { PropertyValue } from '../model/Properties'; import type { PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project'; 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 { 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 ( return (
declaresPropertyPorts(node.type) && declaresPropertyPorts(node.type) &&
propertyTargetOf(node.params) === 'behavior' && propertyTargetOf(node.params) === 'behavior' &&
+9 -1
View File
@@ -1,7 +1,7 @@
/** Lookups that throw a `CommandError` when the target does not exist. */ /** Lookups that throw a `CommandError` when the target does not exist. */
import { findNode, type Graph, type GraphNode } from '../graph/Graph'; import { findNode, type Graph, type GraphNode } from '../graph/Graph';
import { type NodeDef, nodeDef } from '../graph/NodeRegistry'; 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 { findObject, type ObjectTree } from '../model/hierarchy';
import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance'; import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
import { import {
@@ -71,6 +71,14 @@ export function requireBehavior(project: Project, assetId: string): BehaviorAsse
return asset; 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 { export function requireStateMachine(project: Project, assetId: string): StateMachineAsset {
const asset = findAssetOf(project, assetId, 'statemachine'); const asset = findAssetOf(project, assetId, 'statemachine');
if (!asset) throw new CommandError(`State machine ${assetId} not found`); if (!asset) throw new CommandError(`State machine ${assetId} not found`);
+8 -42
View File
@@ -7,7 +7,6 @@ import {
} from '../model/Properties'; } from '../model/Properties';
import { overrideSites } from '../model/instance'; import { overrideSites } from '../model/instance';
import type { Project } from '../model/Project'; import type { Project } from '../model/Project';
import { type Scope, scopeOf, setScope } from '../model/Scope';
import type { VariableKind } from '../properties/kinds'; import type { VariableKind } from '../properties/kinds';
import { import {
emptyFieldValue, emptyFieldValue,
@@ -20,7 +19,7 @@ import {
import { type Removed, removeFirst, restoreAt } from '../util/list'; import { type Removed, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command'; import { type Command, CommandError } from './Command';
import { renameVariablePorts } from './portCommands'; import { renameVariablePorts } from './portCommands';
import { requireBehavior } from './require'; import { requireVariableOwner } from './require';
/** The variable's default value is written to `props` as well. */ /** The variable's default value is written to `props` as well. */
export class AddVariableCommand implements Command { export class AddVariableCommand implements Command {
@@ -37,7 +36,7 @@ export class AddVariableCommand implements Command {
} }
execute(project: Project): void { 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 (!isValidVariableName(this.def.key)) throw new CommandError(INVALID_NAME);
if (asset.variables.some((v) => v.key === this.def.key)) if (asset.variables.some((v) => v.key === this.def.key))
throw new CommandError(`Variable "${this.def.key}" already exists`); throw new CommandError(`Variable "${this.def.key}" already exists`);
@@ -46,7 +45,7 @@ export class AddVariableCommand implements Command {
} }
undo(project: Project): void { 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); asset.variables = asset.variables.filter((v) => v.key !== this.def.key);
delete asset.props[this.def.key]; delete asset.props[this.def.key];
} }
@@ -100,7 +99,7 @@ export class RemoveVariableCommand implements Command {
} }
execute(project: Project): void { 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); const def = removeFirst(asset.variables, (v) => v.key === this.key);
if (!def) throw new CommandError(`Variable "${this.key}" not found`); if (!def) throw new CommandError(`Variable "${this.key}" not found`);
this.removed = { def, value: asset.props[this.key] }; this.removed = { def, value: asset.props[this.key] };
@@ -110,46 +109,13 @@ export class RemoveVariableCommand implements Command {
undo(project: Project): void { undo(project: Project): void {
if (!this.removed) return; if (!this.removed) return;
const asset = requireBehavior(project, this.assetId); const asset = requireVariableOwner(project, this.assetId);
restoreAt(asset.variables, [this.removed.def]); restoreAt(asset.variables, [this.removed.def]);
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value; if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
restoreOverrides(project, this.assetId, this.key, this.overrides); 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. */ /** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */
export class RenameVariableCommand implements Command { export class RenameVariableCommand implements Command {
readonly label: string; readonly label: string;
@@ -171,7 +137,7 @@ export class RenameVariableCommand implements Command {
} }
private rename(project: Project, from: string, to: string): void { 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 (from === to) return;
if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME); if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME);
const def = asset.variables.find((v) => v.key === from); 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 { 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 { function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
@@ -244,7 +210,7 @@ interface ValueHolder {
} }
function valueHolders(project: Project, assetId: string, key: string): 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 def = asset.variables.find((v) => v.key === key);
const holders: ValueHolder[] = []; const holders: ValueHolder[] = [];
if (def) if (def)
+4 -4
View File
@@ -70,12 +70,12 @@ export interface PointerState {
} }
/** /**
* What `global` declarations share for the length of one run: one value per variable name, one * What is shared for the length of one run: the values of each Variables asset, keyed by its id,
* current state per machine. A key that is not in here is not shared – which is what lets a behavior * and the current state of each `global` machine. Two Variables assets may declare the same name
* declaring the same name for itself keep its own value (see `graph/sharedState.ts`). * without meeting, since a node names the asset it reads (see `graph/sharedState.ts`).
*/ */
export interface SharedState { export interface SharedState {
variables: PropertyBag; variables: Record<string, PropertyBag>;
states: Record<string, string>; states: Record<string, string>;
} }
+16 -2
View File
@@ -1,6 +1,6 @@
import { textOf } from '../model/Properties'; import { type PropertyBag, textOf } from '../model/Properties';
import { isTaggedKind } from '../properties/kinds'; 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 DeclaredPort, declaredPortType, type Graph, type GraphEdge, type GraphNode } from './Graph';
import type { GraphLookup } from './lookup'; import type { GraphLookup } from './lookup';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
@@ -12,6 +12,20 @@ export const VARIABLES = 'Variables';
export const PROPERTIES = 'Properties'; export const PROPERTIES = 'Properties';
export const BEHAVIOR_PARAM = 'behavior'; 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. * Nodes whose declared ports are not names of their own but variables of the behavior around them.
+9 -9
View File
@@ -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 { type NodeDef, withInlineDefault } from '../../NodeRegistry';
import { readableVariables, variableBag } from '../../sharedState'; import { variableBag } from '../../sharedState';
import { writeTyped } from '../../values'; import { writeTyped } from '../../values';
import { exec } from '../shared'; import { exec } from '../shared';
@@ -13,11 +13,11 @@ export const Variables: NodeDef = {
label: 'Variables', label: 'Variables',
category: 'variable', category: 'variable',
description: description:
"Reads this behavior's variables at any time. Those switched to Set are written together when " + "Reads variables at any time: this behavior's own, or those of a Variables asset, which every " +
'the flow arrives, and a read after that sees the new value.', 'graph pointing at it shares. Those switched to Set are written when the flow arrives.',
inputs: [], inputs: [],
outputs: [], outputs: [],
params: [], params: [VARIABLES_SETTING],
declaresPorts: 'both', declaresPorts: 'both',
declaredPort: (port) => withInlineDefault(portDefOf(port)), declaredPort: (port) => withInlineDefault(portDefOf(port)),
derivePorts: ({ node }) => { derivePorts: ({ node }) => {
@@ -25,10 +25,10 @@ export const Variables: NodeDef = {
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {}; const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape; return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape;
}, },
read: (ctx) => readableVariables(ctx), read: (ctx, params) => variableBag(ctx, variablesSourceOf(params)),
run: (ctx, inputs, _params, def) => { run: (ctx, inputs, params, def) => {
for (const port of def.inputs) const bag = variableBag(ctx, variablesSourceOf(params));
if (port.type !== 'exec') writeTyped(variableBag(ctx, port.key), port.key, inputs[port.key]); for (const port of def.inputs) if (port.type !== 'exec') writeTyped(bag, port.key, inputs[port.key]);
return { next: 'exec' }; return { next: 'exec' };
}, },
}; };
+44 -89
View File
@@ -1,115 +1,70 @@
import type { Asset } from '../model/Asset'; import type { PropertyBag } from '../model/Properties';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project'; import type { Project } from '../model/Project';
import { isGlobal } from '../model/Scope'; import { isGlobal } from '../model/Scope';
import type { PropertyDef } from '../properties/PropertySchema';
import type { NodeContext, SharedState } from './NodeContext'; import type { NodeContext, SharedState } from './NodeContext';
export function emptySharedState(): SharedState { export function emptySharedState(): SharedState {
return { variables: {}, states: {} }; 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<string, PropertyBag> {
const declared: Record<string, PropertyBag> = {};
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 * Built once per run, from the declarations rather than from any instance: a Variables asset holds
* even before the behavior declaring it is placed. Two behaviors declaring the same name mean the * its values whether or not a graph ever reads them, and nothing is written back when the run ends.
* same variable – the first declaration sets the starting value (see `globalConflicts`).
*/ */
export function buildSharedState(project: Project): SharedState { export function buildSharedState(project: Project): SharedState {
const shared = emptySharedState(); const shared: SharedState = { variables: declaredVariables(project), states: {} };
for (const asset of project.assets) { for (const asset of project.assets)
if (asset.type === 'behavior') { if (asset.type === 'statemachine' && isGlobal(asset)) shared.states[asset.id] = '';
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; return shared;
} }
/** /**
* Where two behaviors disagree about the same global name. A different kind is an error – one of the * Moves edits made to the declarations during a run into the values it shares. A key is taken over
* two would silently read a value of a type it never declared – while a different starting value is * only where the document changed it since `seen`, which is what leaves a value a graph has moved
* only a warning, since the first declaration decides and the run is well defined either way. * on alone – the panel can show the running value and set it without the two fighting.
*/ */
export interface GlobalConflict { export function adoptDeclarations(
key: string; live: Record<string, PropertyBag>,
severity: 'error' | 'warning'; seen: Record<string, PropertyBag>,
message: string; now: Record<string, PropertyBag>,
} ): void {
for (const [assetId, declared] of Object.entries(now)) {
export function globalConflicts(project: Project): GlobalConflict[] { const bag = live[assetId];
const first = new Map<string, { def: PropertyDef; asset: string; value: PropertyValue }>(); if (!bag) {
const conflicts: GlobalConflict[] = []; live[assetId] = { ...declared };
const seen = new Set<string>(); continue;
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.`,
});
}
} }
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 { * The values a `Variables` node reads and writes: those of the asset it names, else the behavior's
return a === b || JSON.stringify(a ?? null) === JSON.stringify(b ?? null); * 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.
*/
function display(value: PropertyValue | undefined): string { export function variableBag(ctx: NodeContext, source: string): PropertyBag {
return typeof value === 'string' ? `"${value}"` : JSON.stringify(value ?? null); if (!source) return ctx.variables;
} return ctx.shared.variables[source] ?? {};
/** 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. */ /** 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<string, string> { export function stateBag(ctx: NodeContext, machineId: string): Record<string, string> {
return machineId in ctx.shared.states ? ctx.shared.states : ctx.states; 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;
}
+11
View File
@@ -36,6 +36,16 @@ export interface BehaviorAsset extends AssetBase {
graph: Graph; 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. */ /** Placed as a child of the object it belongs to; its position is the offset from that body. */
export interface ColliderAsset extends AssetBase { export interface ColliderAsset extends AssetBase {
type: 'collider'; type: 'collider';
@@ -129,6 +139,7 @@ export type Asset =
| TextAsset | TextAsset
| SceneAsset | SceneAsset
| BehaviorAsset | BehaviorAsset
| VariablesAsset
| ColliderAsset | ColliderAsset
| ContainerAsset | ContainerAsset
| TextureAsset | TextureAsset
+5 -2
View File
@@ -4,6 +4,7 @@ export const ASSET_TYPES = [
'sprite', 'sprite',
'text', 'text',
'behavior', 'behavior',
'variables',
'collider', 'collider',
'container', 'container',
'texture', 'texture',
@@ -22,6 +23,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
sprite: 'Sprite', sprite: 'Sprite',
text: 'Text', text: 'Text',
behavior: 'Behavior', behavior: 'Behavior',
variables: 'Variables',
collider: 'Collider', collider: 'Collider',
container: 'Container', container: 'Container',
texture: 'Texture', texture: 'Texture',
@@ -35,11 +37,11 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
/** /**
* Textures and animations are not placeable: they are pictures a sprite wears through `texture`, * 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: * 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< export type PlaceableAssetType = Exclude<
AssetType, AssetType,
'scene' | 'texture' | 'animation' | 'statemachine' | 'shader' | 'font' 'scene' | 'texture' | 'animation' | 'statemachine' | 'shader' | 'font' | 'variables'
>; >;
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
@@ -49,6 +51,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'statemachine', 'statemachine',
'shader', 'shader',
'font', 'font',
'variables',
]); ]);
export function isPlaceableType(type: AssetType): type is PlaceableAssetType { export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
+3 -3
View File
@@ -1,7 +1,7 @@
/** /**
* Whether a declaration keeps a value per behavior instance or shares one for the whole run. * Whether a state machine keeps its current state per behavior instance or shares one for the whole
* Everything was `local` before scopes existed, so a missing field means `local` and old projects * run. Everything was `local` before scopes existed, so a missing field means `local` and old
* load unchanged. * projects load unchanged. Variables are shared by living on a Variables asset instead (#91).
*/ */
export type Scope = 'local' | 'global'; export type Scope = 'local' | 'global';
+1 -3
View File
@@ -1,5 +1,4 @@
import type { GraphLookup } from '../graph/lookup'; import type { GraphLookup } from '../graph/lookup';
import { withoutGlobals } from '../graph/sharedState';
import { statesOf, transitionsOf } from '../graph/stateMachine'; import { statesOf, transitionsOf } from '../graph/stateMachine';
import { resolveAssetProps } from '../properties/resolve'; import { resolveAssetProps } from '../properties/resolve';
import { findAssetOf, type Project } from './Project'; import { findAssetOf, type Project } from './Project';
@@ -13,8 +12,7 @@ export function graphLookup(project: Project): GraphLookup {
behavior: (assetId) => { behavior: (assetId) => {
const asset = findAssetOf(project, assetId, 'behavior'); const asset = findAssetOf(project, assetId, 'behavior');
if (!asset) return null; if (!asset) return null;
const defaults = withoutGlobals(resolveAssetProps(project, asset), asset); return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(project, asset) };
return { name: asset.name, graph: asset.graph, defaults };
}, },
machine: (assetId) => { machine: (assetId) => {
const asset = findAssetOf(project, assetId, 'statemachine'); const asset = findAssetOf(project, assetId, 'statemachine');
+9 -16
View File
@@ -20,7 +20,6 @@ import {
type VariableKind, type VariableKind,
} from './kinds'; } from './kinds';
import { findAssetOf, type Project } from '../model/Project'; import { findAssetOf, type Project } from '../model/Project';
import { isGlobal, type Scope } from '../model/Scope';
import type { SceneObject } from '../model/SceneObject'; import type { SceneObject } from '../model/SceneObject';
import { flatListOf, listItemOf } from './listItems'; import { flatListOf, listItemOf } from './listItems';
import { FONT_KEY } from './fonts'; import { FONT_KEY } from './fonts';
@@ -58,14 +57,10 @@ type Extras<K extends FlatKind> = K extends 'number'
/** The definition of a flat kind: its default is a value of that kind, as the table says. */ /** The definition of a flat kind: its default is a value of that kind, as the table says. */
export type FlatDef<K extends FlatKind = FlatKind> = { export type FlatDef<K extends FlatKind = FlatKind> = {
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P>; scope?: Scope } & Extras<P>; [P in K]: { key: string; label: string; kind: P; default: ValueOf<P> } & Extras<P>;
}[K]; }[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 = export type PropertyDef =
| FlatDef | 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 * item kind: a list is stored in the project file, and an object is a live handle that no reload
* survives. * 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 * `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`). * keep its shape while single fields are written (`mergeStruct`).
@@ -85,7 +80,6 @@ export type PropertyDef =
kind: 'struct'; kind: 'struct';
default: StructValue; default: StructValue;
fields: readonly StructFieldDef[]; fields: readonly StructFieldDef[];
scope?: Scope;
}; };
export interface PrimitiveFieldDef { export interface PrimitiveFieldDef {
@@ -220,6 +214,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
], ],
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`. // Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }], 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. // The states are the asset's graph (see StateMachineAsset), not a property.
statemachine: [], statemachine: [],
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it. // 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); 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 { export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema {
const shader = object.overrides[SHADER_KEY]; const shader = object.overrides[SHADER_KEY];
const rows = schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader }); return 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 { function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {
if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables]; 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))]; 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. // 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') { if (asset.type === 'particles') {
+4 -1
View File
@@ -11,7 +11,10 @@ import { deviatesFromAsset, isModified } from './resolve';
* default is declared on the variable itself, so the asset value cannot differ from it. * default is declared on the variable itself, so the asset value cannot differ from it.
*/ */
export function markedAssetSchema(project: Project, asset: Asset): PropertySchema { 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. */ /** Whether any asset property the inspector would mark gold differs from its default. */
+4 -4
View File
@@ -73,12 +73,12 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue; if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
rawProps.set(a.id, asRecord(a.props)); rawProps.set(a.id, asRecord(a.props));
if (a.type === 'sprite' || a.type === 'particles') wearers.set(a.id, a.type); 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); const vars = normalizeVariables(a.variables);
variables.set(a.id, vars); variables.set(a.id, vars);
// The full schema, not just the common rows: this is what pass 2 coerces `props` against, and // 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. // 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') { } else if (a.type === 'particles') {
// A source's rows end with its own graph's parameters, so the graph is needed before the // 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. // 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) ?? [], variables: variables.get(id) ?? [],
graph: normalizeGraph(a.graph), graph: normalizeGraph(a.graph),
}); });
} else if (type === 'variables') {
assets.push({ id, type, name, props, variables: variables.get(id) ?? [] });
} else if (type === 'statemachine') { } else if (type === 'statemachine') {
const machine: StateMachineAsset = { const machine: StateMachineAsset = {
id, id,
@@ -260,8 +262,6 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
const fields = normalizeStructFields(v.fields); const fields = normalizeStructFields(v.fields);
const def = variableDef(key, kind, undefined, fields); const def = variableDef(key, kind, undefined, fields);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue); 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); defs.push(def);
} }
return defs; return defs;
+3
View File
@@ -164,6 +164,9 @@ export class Editor implements PlayHost {
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId); this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
this.graphEditor.canvas.timeProvider = () => this.play.time; 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, { this.toolbar = new Toolbar(state, {
openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor), openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor),
+19 -1
View File
@@ -1,4 +1,6 @@
/** Runs "run in editor" behaviors outside play mode, on a world built from the saved model, not a copy. */ /** 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 { hasEditorRun, runsInEditor } from '../core/properties/editorRun';
import { addGameSystems, type BehaviorSystem, behaviorError } from '../runtime/systems/BehaviorSystem'; import { addGameSystems, type BehaviorSystem, behaviorError } from '../runtime/systems/BehaviorSystem';
import { Input } from '../runtime/systems/Input'; import { Input } from '../runtime/systems/Input';
@@ -60,7 +62,22 @@ export class EditorRun {
world.tick(step); world.tick(step);
this.input.endFrame(); this.input.endFrame();
if (this.control(world)) return; 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. */ /** 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; const world = this.world;
if (world && world.project === this.state.project && world.sceneId === scene.id) { if (world && world.project === this.state.project && world.sceneId === scene.id) {
world.syncFromModel(); world.syncFromModel();
this.behaviors?.syncDeclarations();
return; return;
} }
this.stop(); this.stop();
+7
View File
@@ -11,6 +11,7 @@ import {
type Trace, type Trace,
} from '../runtime/systems/BehaviorSystem'; } from '../runtime/systems/BehaviorSystem';
import type { AnimationClock } from '../runtime/Entity'; import type { AnimationClock } from '../runtime/Entity';
import type { PropertyBag } from '../core/model/Properties';
import { gameClock } from '../core/time'; import { gameClock } from '../core/time';
import { Input } from '../runtime/systems/Input'; import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World'; import { World } from '../runtime/World';
@@ -82,6 +83,12 @@ export class PlaySession {
syncFromModel(): void { syncFromModel(): void {
this.world?.syncFromModel(); 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 { play(): void {
+4
View File
@@ -1,3 +1,4 @@
import type { PropertyBag } from '../../core/model/Properties';
import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui'; import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui';
import { type EditorState, sameSelection, type Selection } from '../state'; import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection'; 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`). */ /** A rebuild that was due while a field in here had focus (see `refreshValues`). */
private rebuildWhenLeft = false; private rebuildWhenLeft = false;
private rebuilds = 0; 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) { constructor(private readonly state: EditorState) {
super({ title: 'Inspector', padding: 0, gap: 0 }); super({ title: 'Inspector', padding: 0, gap: 0 });
@@ -53,6 +56,7 @@ export class Inspector extends Panel {
state: this.state, state: this.state,
// Read through the state on every call: load and play mode replace the project object. // Read through the state on every call: load and play mode replace the project object.
project: () => this.state.project, project: () => this.state.project,
liveVariables: (assetId) => this.liveVariables(assetId),
body: new VStack({ padding: Theme.padding, gap: Theme.gap }), body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
fields: new Map(), fields: new Map(),
nameField: null, nameField: null,
+42 -33
View File
@@ -12,13 +12,12 @@ import {
RemoveVariableCommand, RemoveVariableCommand,
RenameStructFieldCommand, RenameStructFieldCommand,
RenameVariableCommand, RenameVariableCommand,
SetVariableScopeCommand,
} from '../../core/commands/variableCommands'; } from '../../core/commands/variableCommands';
import type { Command } from '../../core/commands/Command'; import type { Command } from '../../core/commands/Command';
import type { Asset, BehaviorAsset, StateMachineAsset } from '../../core/model/Asset'; import type { Asset, BehaviorAsset, StateMachineAsset, VariablesAsset } from '../../core/model/Asset';
import { globalConflicts } from '../../core/graph/sharedState';
import { findAsset, type Project } from '../../core/model/Project'; 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 StructFieldKind, VARIABLE_KINDS } from '../../core/properties/kinds';
import { type PropertyDef, schemaForAsset, type StructFieldDef } from '../../core/properties/PropertySchema'; import { type PropertyDef, schemaForAsset, type StructFieldDef } from '../../core/properties/PropertySchema';
import { markedAssetSchema } from '../../core/properties/deviation'; 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 { Dropdown, HStack, Label, Menu, Theme } from '../../ui';
import { FontPreview } from '../font/FontPreview'; import { FontPreview } from '../font/FontPreview';
import { aspectActions, sizeCommand } from './aspectAction'; 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 { addNameField, addNote, addTitle, type InspectorHost } from './host';
import { buildVariable, kindMenu, type VariableEditing } from './variableRows'; import { buildVariable, kindMenu, type VariableEditing } from './variableRows';
import { listOpener, previewingHandlers, structFields } from './previewHandlers'; import { listOpener, previewingHandlers, structFields } from './previewHandlers';
@@ -49,7 +48,7 @@ export function assetSignature(project: Project, assetId: string): string {
export function defSignature(def: PropertyDef): string { export function defSignature(def: PropertyDef): string {
// Only a struct has rows of its own; a list is one row whatever its items are. // 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('+') : ''; 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. */ /** 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)); 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); 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. // A font has no properties at all, so without this its inspector would be the name and nothing.
if (asset.type === 'font') if (asset.type === 'font')
@@ -80,7 +84,7 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole
if (!asset) return false; if (!asset) return false;
host.nameField?.setValue(asset.name); host.nameField?.setValue(asset.name);
const project = host.state.project; const project = host.state.project;
const values = resolveAssetProps(project, asset); const values = shownValues(host, asset);
for (const [key, row] of host.fields) { for (const [key, row] of host.fields) {
row.setValue(values[key] ?? 0); row.setValue(values[key] ?? 0);
row.setModified(isModified(project, asset, key)); 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' }); const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1); header.add(new Label('VARIABLES', { style: 'title' }), 1);
const add = new Dropdown('+ Variable', { variant: 'ghost' }); const add = new Dropdown('+ Variable', { variant: 'ghost' });
add.onClick = () => { add.onClick = () => {
const items = kindMenu(VARIABLE_KINDS, (kind) => { const items = kindMenu(VARIABLE_KINDS, (kind) => {
const current = findAsset(host.state.project, asset.id); 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)); host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
}); });
new Menu(items).openBelow(add); new Menu(items).openBelow(add);
@@ -152,7 +166,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
host.body.add(header); host.body.add(header);
if (asset.variables.length === 0) addNote(host, 'No variables yet.'); 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); const run = (command: Command): void => void host.state.execute(command);
for (const def of asset.variables) { for (const def of asset.variables) {
const edit: VariableEditing = { const edit: VariableEditing = {
@@ -160,7 +174,6 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
remove: () => run(new RemoveVariableCommand(asset.id, def.key)), remove: () => run(new RemoveVariableCommand(asset.id, def.key)),
renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)), renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)),
removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)), removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
setScope: (scope) => run(new SetVariableScopeCommand(asset.id, def.key, scope)),
addField: (kind) => addField: (kind) =>
run( run(
new AddStructFieldCommand( new AddStructFieldCommand(
@@ -173,30 +186,26 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
const value = values[def.key] ?? def.default; const value = values[def.key] ?? def.default;
host.fields.set(def.key, buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit })); 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. */ /** 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 { function buildMachineScope(host: InspectorHost, asset: StateMachineAsset): void {
const row = new HStack({ gap: Theme.gap, align: 'center' }); const set = (value: PropertyValue): void =>
row.add(new Label('Scope', { style: 'muted', width: NAME_WIDTH, padding: Theme.fieldPadding })); void host.state.execute(new SetMachineScopeCommand(asset.id, readScope(value)));
const pick = new Dropdown(isGlobal(asset) ? 'Global' : 'Local', { variant: 'ghost' }); const row = createFieldRow(host, MACHINE_SCOPE, scopeOf(asset), { onInput: set, onChange: set });
const set = (scope: Scope) => () => void host.state.execute(new SetMachineScopeCommand(asset.id, scope)); host.body.add(row.row);
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( addNote(
host, host,
isGlobal(asset) isGlobal(asset)
+3
View File
@@ -1,3 +1,4 @@
import type { PropertyBag } from '../../core/model/Properties';
import { Label, TextField, type TextFieldOptions, type VStack } from '../../ui'; import { Label, TextField, type TextFieldOptions, type VStack } from '../../ui';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { type FieldContext, type FieldRow, labelledRow } from './fieldFactory'; 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. */ /** What the inspector sections build into: the panel body and the registry of rows. */
export interface InspectorHost extends FieldContext { export interface InspectorHost extends FieldContext {
readonly state: EditorState; 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; readonly body: VStack;
/** Rows by key; live refreshes and highlight updates find them here. */ /** Rows by key; live refreshes and highlight updates find them here. */
readonly fields: Map<string, FieldRow>; readonly fields: Map<string, FieldRow>;
+17 -7
View File
@@ -19,6 +19,7 @@ import {
STATE_TRANSITION, STATE_TRANSITION,
stateName, stateName,
TRANSITION_PARAM, TRANSITION_PARAM,
variablesSourceOf,
} from '../../core/graph'; } from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset'; import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
@@ -28,7 +29,12 @@ import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Label } from '../../ui'; import { Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory'; import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host'; 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 { listOpener, previewingHandlers } from './previewHandlers';
import { buildTransitionPicker, buildTransitionSection } from './transitionSection'; import { buildTransitionPicker, buildTransitionSection } from './transitionSection';
import { currentNode, nodeParamValue } from './valueSpecs'; 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)}`) .map((e) => `${e.id}:${e.label ?? ''}:${farStateName(asset.graph, e, node.id)}`)
.join(',') .join(',')
: `${textOf(node.params[TRANSITION_PARAM] ?? '')}:${textOf(node.params[MACHINE_PORT] ?? '')}`; : `${textOf(node.params[TRANSITION_PARAM] ?? '')}:${textOf(node.params[MACHINE_PORT] ?? '')}`;
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${variablesOf( // The source names which declarations the port menu offers, so switching it has to rebuild.
asset, const source = variablesSourceOf(node.params);
) const choices = variableChoices(project, assetId, node)
.map((v) => v.key) .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. */ /** 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.fields.set(`param:${param.key}`, row);
host.body.add(row.row); host.body.add(row.row);
}; };
const ownSettings = declaresPropertyPorts(node.type); // Both draw their settings inside the port section: a setting decides what the menu offers.
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, variablesOf(asset)); 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 (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting);
else if (def.declaresPorts) buildPortSection(host, assetId, node, variablesOf(asset)); else if (def.declaresPorts) buildPortSection(host, assetId, node, variablesOf(asset));
+3 -11
View File
@@ -24,7 +24,6 @@ import {
} from '../../core/model/instance'; } from '../../core/model/instance';
import type { PropertyValue } from '../../core/model/Properties'; import type { PropertyValue } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
import { isGlobal } from '../../core/model/Scope';
import type { SceneObject } from '../../core/model/SceneObject'; import type { SceneObject } from '../../core/model/SceneObject';
import { import {
COMMON_PROPS, 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 * 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 * other asset's schema minus the common switches.
* 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[] { export function objectRows(project: Project, asset: Asset, object: SceneObject): PropertyDef[] {
return asset.type === 'behavior' return asset.type === 'behavior'
? asset.variables.filter((def) => !isGlobal(def)) ? asset.variables
: schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key)); : 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); const own = objectRows(host.state.project, asset, object);
if (own.length > 0 || asset.type === 'behavior') if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES'); addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
// A global variable is missing from the rows above but not from the behavior, so saying it if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
// 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'); 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); markObjectRows(host, object, asset, target);
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length); if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
+25 -7
View File
@@ -18,12 +18,14 @@ import {
OBJECT_BEHAVIOR_SETTING, OBJECT_BEHAVIOR_SETTING,
OBJECT_TYPE_SETTING, OBJECT_TYPE_SETTING,
portKindOf, portKindOf,
VARIABLES_SETTING,
variablesSourceOf,
propertyTargetOf, propertyTargetOf,
structShapeOf, structShapeOf,
targetLabel, targetLabel,
targetProperties, targetProperties,
} from '../../core/graph'; } 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 { textOf } from '../../core/model/Properties';
import { DECLARED_PORT_KINDS, kindMark, VARIABLE_KINDS } from '../../core/properties/kinds'; import { DECLARED_PORT_KINDS, kindMark, VARIABLE_KINDS } from '../../core/properties/kinds';
import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema'; 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 { addNote, type InspectorHost } from './host';
import { kindMenu, markLabel } from './variableRows'; 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 * The ports of a Variables node: each one *is* a declared variable, so it is picked from a list
* list rather than named, and its kind follows the variable's declaration. Set makes it written too. * rather than named, and its kind follows the declaration. Set makes it written too.
*/ */
export function buildVariablePortSection( export function buildVariablePortSection(
host: InspectorHost, host: InspectorHost,
assetId: string, assetId: string,
node: GraphNode, node: GraphNode,
variables: readonly PropertyDef[], addSetting: (def: PropertyDef) => void,
): void { ): void {
addSetting(VARIABLES_SETTING);
const source = variablesSourceOf(node.params);
const shared = source ? findAssetOf(host.state.project, source, 'variables') : undefined;
buildPickedPortSection(host, assetId, node, { buildPickedPortSection(host, assetId, node, {
noun: 'Variable', noun: 'Variable',
choices: variables, choices: variableChoices(host.state.project, assetId, node),
owner: 'this behavior', owner: shared ? `"${shared.name}"` : 'this behavior',
empty: 'This behavior has no variables yet.', empty: source
? shared
? `"${shared.name}" declares no variables yet.`
: 'That Variables asset is gone.'
: 'This behavior has no variables yet.',
}); });
} }
-12
View File
@@ -1,5 +1,4 @@
import type { PropertyValue } from '../../core/model/Properties'; import type { 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 { kindLabel, kindMark, STRUCT_FIELD_KINDS, type StructFieldKind } from '../../core/properties/kinds';
import type { PropertyDef, StructDef, StructFieldDef } from '../../core/properties/PropertySchema'; import type { PropertyDef, StructDef, StructFieldDef } from '../../core/properties/PropertySchema';
import { import {
@@ -40,21 +39,11 @@ export function kindMenu<K extends string>(kinds: readonly K[], pick: (kind: K)
export interface VariableEditing { export interface VariableEditing {
rename: (next: string) => void; rename: (next: string) => void;
remove: () => void; remove: () => void;
setScope: (scope: Scope) => void;
renameField: (field: StructFieldDef, next: string) => void; renameField: (field: StructFieldDef, next: string) => void;
removeField: (field: StructFieldDef) => void; removeField: (field: StructFieldDef) => void;
addField: (kind: StructFieldKind) => void; addField: (kind: StructFieldKind) => void;
} }
const SCOPE_MARK: Record<Scope, string> = { local: '◦', global: '⊕' };
function scopePicker(def: PropertyDef, edit: VariableEditing): Button {
return markPicker(SCOPE_MARK[scopeOf(def)], [
{ label: 'Local – one value per object', onSelect: () => edit.setScope('local') },
{ label: 'Global – one value for the whole run', onSelect: () => edit.setScope('global') },
]);
}
export interface VariableOptions { export interface VariableOptions {
edit?: VariableEditing; edit?: VariableEditing;
/** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */ /** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */
@@ -82,7 +71,6 @@ export function buildVariable(
} else { } else {
setValue = addValue(head, createField(host, def, value, handlers)); setValue = addValue(head, createField(host, def, value, handlers));
} }
if (edit) head.add(scopePicker(def, edit));
if (edit) head.add(removeButton(edit.remove)); if (edit) head.add(removeButton(edit.remove));
const refreshActions = actions ? attachRowActions(head, actions) : () => {}; const refreshActions = actions ? attachRowActions(head, actions) : () => {};
host.body.add(head); host.body.add(head);
+17 -9
View File
@@ -1,8 +1,9 @@
import { import {
adoptDeclarations,
asNumber, asNumber,
buildSharedState, buildSharedState,
declaredVariables,
emptySharedState, emptySharedState,
globalConflicts,
type EntityHandle, type EntityHandle,
type Graph, type Graph,
type GraphNode, type GraphNode,
@@ -88,6 +89,8 @@ export class BehaviorSystem implements System {
private instances: Instance[] = []; private instances: Instance[] = [];
private world: World | null = null; private world: World | null = null;
private sharedState: SharedState = emptySharedState(); private sharedState: SharedState = emptySharedState();
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
private declared: Record<string, PropertyBag> = {};
private readonly collisions: CollisionSystem | null; private readonly collisions: CollisionSystem | null;
private readonly animations: AnimationSystem | null; private readonly animations: AnimationSystem | null;
@@ -110,19 +113,24 @@ export class BehaviorSystem implements System {
return this.sharedState; 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 { start(world: World): void {
this.world = world; this.world = world;
this.instances = []; this.instances = [];
// Built fresh for every run: a shared variable starts at its declared value each time Play does. // Built fresh for every run: a shared variable starts at its declared value each time Play does.
this.sharedState = buildSharedState(this.project); this.sharedState = buildSharedState(this.project);
// Two behaviors declaring one global name with different kinds: whichever ran second would read this.declared = declaredVariables(this.project);
// 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); this.syncInstances(world);
} }
+127 -74
View File
@@ -1,7 +1,7 @@
/** /**
* A variable or a state machine declared `global` keeps one value for the whole run instead of one * A Variables asset holds one value per key for the whole run, whatever points at it; a state
* per behavior instance. The name is the key: two behaviors declaring the same global name mean the * machine declared `global` does the same for its current state. A behavior's own variables stay
* same variable, while a behavior declaring it locally keeps its own. * what they were: properties of the object it is placed on.
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
@@ -10,22 +10,32 @@ import {
SetMachineScopeCommand, SetMachineScopeCommand,
} from '../../src/core/commands/assetCommands'; } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
import { AddVariableCommand, SetVariableScopeCommand } from '../../src/core/commands/variableCommands'; import { AddVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { globalConflicts, STATE, STATE_NAME_PARAM } from '../../src/core/graph'; import { STATE, STATE_NAME_PARAM, VARIABLES_PARAM } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { BehaviorAsset } from '../../src/core/model/Asset';
import type { PropertyValue } from '../../src/core/model/Properties'; import type { PropertyBag } from '../../src/core/model/Properties';
import type { VariableKind } from '../../src/core/properties/kinds'; import {
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers'; 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') { * A behavior that adds one to `key` whenever it starts – the coin counter of the example game.
const behavior = makeBehavior(fixture, name, [key]); * With `source` it counts on that Variables asset, without it on its own declaration.
if (scope === 'global') fixture.stack.execute(new SetVariableScopeCommand(behavior.id, key, scope)); */
function makeCounter(fixture: SceneFixture, name: string, key: string, source = '') {
const behavior = makeBehavior(fixture, name, source ? [] : [key]);
const editor = graphEditor(fixture, behavior); const editor = graphEditor(fixture, behavior);
const params: PropertyBag = source ? { [VARIABLES_PARAM]: source } : {};
const start = editor.addNode('OnStart'); const start = editor.addNode('OnStart');
const read = editor.variables([key]); const read = editor.variables([key], { params });
const add = editor.addNode('Add', { b: 1 }); const add = editor.addNode('Add', { b: 1 });
const write = editor.variables([key], { write: true }); const write = editor.variables([key], { write: true, params });
editor.wire([ editor.wire([
[start, 'exec', write, 'exec'], [start, 'exec', write, 'exec'],
[read, key, add, 'a'], [read, key, add, 'a'],
@@ -34,7 +44,7 @@ function makeCounter(fixture: SceneFixture, name: string, key: string, scope: 'l
return behavior; 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[]) { function runWith(fixture: SceneFixture, behaviors: readonly BehaviorAsset[]) {
for (const behavior of behaviors) place(fixture, behavior.id, { x: 0, y: 0 }); for (const behavior of behaviors) place(fixture, behavior.id, { x: 0, y: 0 });
const started = startWorld(fixture, { behaviors: true }); const started = startWorld(fixture, { behaviors: true });
@@ -42,99 +52,142 @@ function runWith(fixture: SceneFixture, behaviors: readonly BehaviorAsset[]) {
return started; return started;
} }
describe('variable scope', () => { describe('variables of a behavior', () => {
it('counts once per instance when local', () => { it('counts once per instance', () => {
const f = makeSpriteScene(); const f = makeSpriteScene();
const counter = makeCounter(f, 'Counter', 'score', 'local'); const counter = makeCounter(f, 'Counter', 'score');
const started = runWith(f, [counter, counter]); const started = runWith(f, [counter, counter]);
const values = started.world.entities.filter((e) => e.type === 'behavior').map((e) => e.props.score); const values = started.world.entities.filter((e) => e.type === 'behavior').map((e) => e.props.score);
expect(values).toEqual([1, 1]); 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 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]); const started = runWith(f, [counter, counter]);
expect(started.behaviors!.shared.variables.score).toBe(2); expect(started.behaviors!.shared.variables[shared.id]!.score).toBe(2);
// The value belongs to the run, so it is no longer a property of any placed object. // 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(); 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 f = makeSpriteScene();
const one = makeCounter(f, 'One', 'score', 'global'); const shared = makeVariables(f, 'Score', ['score']);
const other = makeCounter(f, 'Other', 'score', 'global'); const one = makeCounter(f, 'One', 'score', shared.id);
const other = makeCounter(f, 'Other', 'score', shared.id);
const started = runWith(f, [one, other]); 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. */ /** The asset is the key, not the name – which is what two declaration places are worth. */
it('lets a behavior declaring the name locally keep its own value', () => { it('keeps two assets apart although they declare the same name', () => {
const f = makeSpriteScene(); const f = makeSpriteScene();
const shared = makeCounter(f, 'Shared', 'score', 'global'); const players = makeVariables(f, 'Players', ['score']);
const own = makeCounter(f, 'Own', 'score', 'local'); const enemies = makeVariables(f, 'Enemies', ['score']);
const started = runWith(f, [shared, shared, own]); const player = makeCounter(f, 'Player', 'score', players.id);
expect(started.behaviors!.shared.variables.score).toBe(2); const enemy = makeCounter(f, 'Enemy', 'score', enemies.id);
const ownEntity = started.world.entities.find((e) => e.assetId === own.id)!; const started = runWith(f, [player, player, enemy]);
expect(ownEntity.props.score).toBe(1); 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 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 }); place(f, counter.id, { x: 0, y: 0 });
const first = startWorld(f, { behaviors: true }); const first = startWorld(f, { behaviors: true });
first.frame(); 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 }); const second = startWorld(f, { behaviors: true });
second.frame(); 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. */ * Editing a declaration while the run is on. The run holds its values apart from the document, so
function declare(fixture: SceneFixture, name: string, kind: VariableKind, start: PropertyValue) { * the two have to meet on purpose: an edit reaches the run, and the run reaches nothing else.
const behavior = makeBehavior(fixture, name); */
fixture.stack.execute(new AddVariableCommand(behavior.id, 'score', kind)); describe('a declaration edited during the run', () => {
fixture.stack.execute(new SetAssetPropertyCommand(behavior.id, 'score', start)); /** Counts `score` up by one, with `lives` declared alongside it and left to the document. */
fixture.stack.execute(new SetVariableScopeCommand(behavior.id, 'score', 'global')); function counting() {
return behavior; 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', () => { it('reaches the running value', () => {
const f = makeSpriteScene(); const { f, shared, started, bag } = counting();
const counting = declare(f, 'Counting', 'number', 0); f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3));
declare(f, 'Naming', 'text', ''); started.behaviors!.syncDeclarations();
place(f, counting.id, { x: 0, y: 0 }); expect(bag().lives).toBe(3);
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', () => { it('leaves a value the run has moved on', () => {
const f = makeSpriteScene(); const { f, shared, started, bag } = counting();
const first = declare(f, 'First', 'number', 7); f.stack.execute(new SetAssetPropertyCommand(shared.id, 'lives', 3));
declare(f, 'Second', 'number', 99); started.behaviors!.syncDeclarations();
place(f, first.id, { x: 0, y: 0 }); // The document still declares score as 0, but the run counted it to 1 – and keeps counting.
const started = startWorld(f, { behaviors: true }); expect(bag().score).toBe(1);
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', () => { it('wins over the run for the very key it changes', () => {
const f = makeSpriteScene(); const { f, shared, started, bag } = counting();
declare(f, 'First', 'number', 7); f.stack.execute(new SetAssetPropertyCommand(shared.id, 'score', 10));
declare(f, 'Second', 'number', 7); started.behaviors!.syncDeclarations();
expect(globalConflicts(f.project)).toEqual([]); 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);
}); });
}); });
+13 -19
View File
@@ -5,7 +5,7 @@ import {
SetMachineScopeCommand, SetMachineScopeCommand,
} from '../../src/core/commands/assetCommands'; } from '../../src/core/commands/assetCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; 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 { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
@@ -26,10 +26,13 @@ describe('serialize', () => {
expect(loaded.props.runInEditor).toBe(true); 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 f = makeSpriteScene();
const behavior = makeBehavior(f, 'B', ['score']); const shared = new CreateAssetCommand('variables', 'Score');
f.stack.execute(new SetVariableScopeCommand(behavior.id, 'score', 'global')); 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'); const machine = new CreateAssetCommand('statemachine', 'M');
f.stack.execute(machine); f.stack.execute(machine);
f.stack.execute(new SetMachineScopeCommand(machine.asset.id, 'global')); 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. */ /** `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 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'); expect(JSON.stringify(serializeProject(f.project))).not.toContain('scope');
const loaded = deserializeProject({ const loaded = deserializeProject({
assets: [ assets: [{ id: 'm1', type: 'statemachine', name: 'M', scope: 'planet' }],
{
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[0]!;
const machine = loaded.assets[1]!; if (machine.type !== 'statemachine') throw new Error('wrong types');
if (reloaded.type !== 'behavior' || machine.type !== 'statemachine') throw new Error('wrong types');
expect(reloaded.variables[0]!.scope).toBeUndefined();
expect(machine.scope).toBeUndefined(); expect(machine.scope).toBeUndefined();
}); });
+10 -2
View File
@@ -11,7 +11,14 @@ import {
RemoveVariableCommand, RemoveVariableCommand,
RenameVariableCommand, RenameVariableCommand,
} from '../../src/core/commands/variableCommands'; } 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 type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup'; import { graphLookup } from '../../src/core/model/graphLookup';
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; 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, 'px', 'column'));
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'sum', 'total')); fixture.stack.execute(new RenameVariableCommand(behavior.id, 'sum', 'total'));
expect((get.ports ?? []).map((p) => p.key)).toEqual(['column', 'py']); 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.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(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 }); expect(run(behavior, fixture, { column: 0, py: 0, total: 0 })).toEqual({ column: 7, py: 9, total: 16 });
+68 -3
View File
@@ -3,11 +3,11 @@
* mode this one edits the saved model, so these tests watch what lands in the scene itself. * mode this one edits the saved model, so these tests watch what lands in the scene itself.
*/ */
import { describe, expect, it } from 'vitest'; 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 { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { instanceChildId } from '../../src/core/model/instance'; import { instanceChildId } from '../../src/core/model/instance';
import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; 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 { hasEditorRun, runsInEditor } from '../../src/core/properties/editorRun';
import { schemaFor } from '../../src/core/properties/PropertySchema'; import { schemaFor } from '../../src/core/properties/PropertySchema';
import { resolveSceneObjectProps } from '../../src/core/properties/resolve'; 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 { EditorRun } from '../../src/editor/EditorRun';
import type { World } from '../../src/runtime/World'; import type { World } from '../../src/runtime/World';
import { EditorState } from '../../src/editor/state'; 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. */ /** Wires `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts 100 units per second. */
function drift(f: SceneFixture, behavior: BehaviorAsset): void { function drift(f: SceneFixture, behavior: BehaviorAsset): void {
@@ -250,3 +258,60 @@ describe('the keyboard of an editor run', () => {
expect(f.keyboard.world).toBeNull(); 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();
});
});
+53
View File
@@ -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);
});
});
+13 -7
View File
@@ -1,15 +1,17 @@
/** /**
* What the inspector of a *placed* behavior lists. A global variable has no value of this object's * What the inspector of a *placed* behavior lists. Every variable a behavior declares is a property
* own, so it gets no row here – the switch on the declaration is the only place it is edited. * 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 { 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 { findAsset } from '../../src/core/model/Project';
import { objectRows } from '../../src/editor/inspector/objectSection'; import { objectRows } from '../../src/editor/inspector/objectSection';
import { makeBehavior, makeSpriteScene, place } from '../helpers'; import { makeBehavior, makeSpriteScene, place } from '../helpers';
describe('the rows of a placed behavior', () => { 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 f = makeSpriteScene();
const behavior = makeBehavior(f, 'Counter', ['score', 'lives']); const behavior = makeBehavior(f, 'Counter', ['score', 'lives']);
const objectId = place(f, behavior.id, { x: 0, y: 0 }); 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']); 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']); expect(rows()).toEqual(['lives']);
f.stack.undo(); f.stack.execute(new AddVariableCommand(behavior.id, 'coins', 'number'));
expect(rows()).toEqual(['score', 'lives']); expect(rows()).toEqual(['lives', 'coins']);
});
it('cannot place a Variables asset, so its values need no rows', () => {
expect(isPlaceableType('variables')).toBe(false);
}); });
}); });
+42
View File
@@ -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([]);
});
});
+8 -2
View File
@@ -35,7 +35,14 @@ const PLAIN: PropertyDef[] = [
function makeHost(): InspectorHost { function makeHost(): InspectorHost {
const state = new EditorState(); const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1); 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: () => {} }); const noHandlers = (): FieldHandlers => ({ onInput: () => {}, onChange: () => {} });
@@ -44,7 +51,6 @@ const withFields: FieldHandlers = { ...noHandlers(), structFieldHandlers: noHand
const EDIT: VariableEditing = { const EDIT: VariableEditing = {
rename: () => {}, rename: () => {},
remove: () => {}, remove: () => {},
setScope: () => {},
renameField: () => {}, renameField: () => {},
removeField: () => {}, removeField: () => {},
addField: () => {}, addField: () => {},
+13 -1
View File
@@ -21,7 +21,7 @@ import {
PROPERTIES, PROPERTIES,
VARIABLES, VARIABLES,
} from '../src/core/graph'; } 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 { findObject } from '../src/core/model/hierarchy';
import { worldPosition } from '../src/core/properties/transform'; import { worldPosition } from '../src/core/properties/transform';
import { resolvedHolder } from '../src/core/model/instance'; import { resolvedHolder } from '../src/core/model/instance';
@@ -90,6 +90,18 @@ export function place(
return command.objectId; 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. */ /** Adds a behavior asset declaring the given number variables. */
export function makeBehavior( export function makeBehavior(
fixture: ProjectFixture, fixture: ProjectFixture,
+55 -52
View File
@@ -2,20 +2,22 @@ import { edge, node, port, variable, written } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.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 * Shared state: a Variables asset holds one value per key for the whole run, and a state machine
* of one per behavior instance. The step checks the run (two objects counting into one number), the * declared `global` one current state. The step checks the run (two objects counting into one
* switch in the inspector, and that a global variable stops being a property of the placed object. * 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 = { const counterGraph = {
nodes: [ nodes: [
node('sc-start', 'OnStart', 0, 0), 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-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: [ edges: [
edge('sc-e1', 'sc-start', 'exec', 'sc-write', 'exec'), 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) { export async function scope(t) {
const { check, sleep, evaluate, json, shot, click, locate, asset, mutate, stop, has } = 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 evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(id)} })`);
await sleep(300); await sleep(300);
}; };
const selectObject = async (id) => { const selectNode = async (nodeId) => {
await evaluate( 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); await sleep(300);
}; };
@@ -45,9 +45,8 @@ export async function scope(t) {
await click(p.x, p.y); await click(p.x, p.y);
await sleep(250); 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(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
@@ -56,11 +55,17 @@ export async function scope(t) {
Object.assign(a, { Object.assign(a, {
id: 'sc-logic', id: 'sc-logic',
name: 'Counter', name: 'Counter',
props: { enabled: true, runInEditor: false, score: 0 }, props: { enabled: true, runInEditor: false },
variables: ${JSON.stringify([globalScore])}, variables: [],
graph: ${JSON.stringify(counterGraph)}, 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} ${MAKE_OBJECT}
scene.objects.push( scene.objects.push(
make('sc-box-a', 'crate', '', 1500, 'Counter box A'), make('sc-box-a', 'crate', '', 1500, 'Counter box A'),
@@ -76,10 +81,11 @@ export async function scope(t) {
await sleep(600); await sleep(600);
const world = await json('__gm.world()'); const world = await json('__gm.world()');
const carriers = world.entities.filter((e) => e.props && 'score' in e.props).map((e) => e.name); const carriers = world.entities.filter((e) => e.props && 'score' in e.props).map((e) => e.name);
const shared = world.shared.variables['sc-vars'];
check( check(
'two behavior instances count into one global variable', 'two behavior instances count into one shared variable',
world.shared.variables.score === 2, shared && shared.score === 2,
`score = ${JSON.stringify(world.shared.variables.score)} after both started – 1 would mean each kept its own`, `score = ${JSON.stringify(shared && shared.score)} after both started – 1 would mean each kept its own`,
); );
check( check(
'and the value belongs to the run, not to either placed object', 'and the value belongs to the run, not to either placed object',
@@ -89,46 +95,43 @@ export async function scope(t) {
await stop(); await stop();
await sleep(400); await sleep(400);
// 32c. A global variable shows no row on the object placed from the behavior – overriding one // 32c. The asset is where the declaration lives, and it says what it is for.
// value of something every object shares would mean nothing. await selectAsset('sc-vars');
await selectObject('sc-brain-a'); const declared = await has('score');
const rowWhileGlobal = await has('score'); const explains = await has('Every graph pointing here shares them.');
const saysWhereItWent = await has('Shared by the whole run: score.');
check( check(
'the object names the variable it no longer holds, instead of claiming there is none', 'the Variables asset lists its declarations and says what they are',
saysWhereItWent && !(await has('The behavior declares no variables.')), declared && explains,
`note found: ${saysWhereItWent}`, `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. // 32d. The node picks its ports from the asset it names – the whole point of naming one.
await selectAsset('sc-logic'); await selectNode('sc-read');
const picker = await locate('⊕'); const offersShared = await has('score');
const complains = await has('"score" is not a variable of this behavior.');
check( check(
'a global variable is marked as shared where it is declared', 'a node naming the asset offers its variables instead of the behavior’s',
Boolean(picker), offersShared && !complains,
JSON.stringify(picker), `row: ${offersShared}, warning shown: ${complains}`,
); );
await shot('02-declaration'); await shot('02-node-source');
await click(picker.x, picker.y);
await sleep(250); // Emptying the setting points the node back at the behavior, which declares nothing – so the
await clickLast('Local – one value per object'); // very same port is now a stranger. That is the switch doing something visible.
await sleep(350); 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( check(
'and the menu switches it back to one value per object', 'and clearing it points the node back at the behavior, which has no such variable',
(await scopeOfVariable()) === 'local', complainsNow,
`scope is now ${await scopeOfVariable()}`, `warning after clearing: ${complainsNow}`,
); );
await selectObject('sc-brain-a'); // 32e. A machine carries its scope on the asset too, with its own switch.
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(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
p.assets.unshift({ p.assets.unshift({
@@ -144,7 +147,7 @@ export async function scope(t) {
const pick = await locate('Local', true); const pick = await locate('Local', true);
await click(pick.x, pick.y); await click(pick.x, pick.y);
await sleep(250); await sleep(250);
await clickLast('Global – one current state for the whole run'); await clickLast('Global');
await sleep(350); await sleep(350);
const machine = await asset('sc-machine'); const machine = await asset('sc-machine');
check( check(