A shared value belongs to the run, so a declaration carries its scope (#91)
A variable or a state machine declared `global` keeps one value for the whole run instead of one per behavior instance. The scope sits on the declaration -- `PropertyDef.scope` and `StateMachineAsset.scope`, both optional, absence meaning `local` -- and `local` is never written, so every project made before this saves back unchanged. **The name is the key.** Two behaviors declaring a global `score` mean one variable; a behavior declaring `score` locally keeps its own. Kinds that disagree about one global name stop the run before it starts, with the two behaviors named; starting values that disagree are a warning on the behavior that declares it, and the first declaration wins (`globalConflicts`). The runtime needs no redirection layer. A global variable falls out of `schemaForObject`, so it never reaches `entity.props` -- and then the whole rule is "whoever declares the name takes their own value" (`graph/sharedState.ts`). The `Variables` node is untouched and stays transparent: switching a variable to global breaks no existing graph, because `GraphInterpreter.readAll` builds a fresh object per read anyway and only asks `port.key in all`. That same cut answers the inspector. A shared value has no owner, so the placed object shows no row for it and can hold no override -- `SetVariableScopeCommand` takes the deviations that were there and undo gives them back. Both places say it in words rather than leaving a glyph to be guessed: the declaration explains the mark below the list, and the placed object names the variable it no longer holds instead of claiming the behavior declares none -- which is what it said before, untruthfully. Screenshots of the e2e run turned that up; no check had been looking at it. `SharedState` hangs off `NodeContext`, is built in `BehaviorSystem.start()` from the *declarations* rather than from any instance (a global variable exists before its behavior is placed), and is thrown away with the run: nothing is written back, not even in the editor run. `GraphInterpreter.call` passes it on through `...ctx` while nested calls keep getting fresh local variables, which is why `program.defaults` is stripped of global keys. Two traps this hit. `defSignature`/`assetSignature` have to carry the scope or the inspector does not rebuild after the switch -- and a state machine has no rows at all, so its scope is appended to the signature by hand. And the object inspector does not go through `schemaForObject` for behaviors, it reads `asset.variables` directly; filtering in one place was not enough, and no unit test covered that path. The e2e caught it (`global: true` where false belonged), and the rule now lives in one named function, `objectRows`. Scopes `object` and `scene` are deliberately not built. The object case already has a route (`Object by name` -> `Properties`), and a run knows exactly one scene, so `scene` would be indistinguishable from `global` until #39 exists. Closes #59: a machine's scope is a word on the machine asset, not a setting per `Set state` node, so the same machine cannot live in two places at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9027a86edd
commit
d1fcfe8b81
27 files changed
+857
-45
No files matched your search
@@ -6,12 +6,13 @@ import { isFontFile } from '../model/font';
|
||||
import { removeSubtrees } from '../model/hierarchy';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { objectHolders, type Project } from '../model/Project';
|
||||
import { type Scope, scopeOf, setScope } from '../model/Scope';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile } from '../model/texture';
|
||||
import { coerceValue, defaultsFor } from '../properties/PropertySchema';
|
||||
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset, requirePropDef } from './require';
|
||||
import { requireAsset, requirePropDef, requireStateMachine } from './require';
|
||||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||||
|
||||
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
@@ -215,6 +216,29 @@ export class RenameAssetCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** A `global` machine keeps one current state for the whole run instead of one per behavior instance. */
|
||||
export class SetMachineScopeCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: Scope = 'local';
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly scope: Scope,
|
||||
) {
|
||||
this.label = `Make state machine ${scope}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireStateMachine(project, this.assetId);
|
||||
this.previous = scopeOf(asset);
|
||||
setScope(asset, this.scope);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
setScope(requireStateMachine(project, this.assetId), this.previous);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
|
||||
protected constructor(
|
||||
private readonly assetId: string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Lookups that throw a `CommandError` when the target does not exist. */
|
||||
import { findNode, type Graph, type GraphNode } from '../graph/Graph';
|
||||
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
||||
import type { Asset, BehaviorAsset } from '../model/Asset';
|
||||
import type { Asset, BehaviorAsset, StateMachineAsset } from '../model/Asset';
|
||||
import { findObject, type ObjectTree } from '../model/hierarchy';
|
||||
import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
|
||||
import {
|
||||
@@ -71,6 +71,12 @@ export function requireBehavior(project: Project, assetId: string): BehaviorAsse
|
||||
return asset;
|
||||
}
|
||||
|
||||
export function requireStateMachine(project: Project, assetId: string): StateMachineAsset {
|
||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||
if (!asset) throw new CommandError(`State machine ${assetId} not found`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
/** The graph of a behavior or of a state machine – what every graph command edits. */
|
||||
export function requireGraph(project: Project, assetId: string): Graph {
|
||||
const asset = findGraphAsset(project, assetId);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../model/Properties';
|
||||
import { overrideSites } from '../model/instance';
|
||||
import type { Project } from '../model/Project';
|
||||
import { type Scope, scopeOf, setScope } from '../model/Scope';
|
||||
import type { VariableKind } from '../properties/kinds';
|
||||
import {
|
||||
emptyFieldValue,
|
||||
@@ -51,11 +52,45 @@ export class AddVariableCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** One placed object's own value for a variable, remembered so that undo can put it back. */
|
||||
interface TakenOverride {
|
||||
holderId: string;
|
||||
objectId: string;
|
||||
path: string;
|
||||
value: PropertyValue;
|
||||
}
|
||||
|
||||
function takeOverrides(project: Project, assetId: string, key: string): TakenOverride[] {
|
||||
const taken: TakenOverride[] = [];
|
||||
for (const site of overrideSites(project, (id) => id === assetId)) {
|
||||
const value = site.bag[key];
|
||||
if (value === undefined) continue;
|
||||
taken.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value });
|
||||
delete site.bag[key];
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
function restoreOverrides(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
key: string,
|
||||
taken: readonly TakenOverride[],
|
||||
): void {
|
||||
const sites = overrideSites(project, (id) => id === assetId);
|
||||
for (const o of taken) {
|
||||
const site = sites.find(
|
||||
(s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path,
|
||||
);
|
||||
if (site) site.bag[key] = o.value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a variable together with its default and every instance override. */
|
||||
export class RemoveVariableCommand implements Command {
|
||||
readonly label: string;
|
||||
private removed?: { def: Removed<PropertyDef>; value: PropertyValue | undefined };
|
||||
private overrides: { holderId: string; objectId: string; path: string; value: PropertyValue }[] = [];
|
||||
private overrides: TakenOverride[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
@@ -70,13 +105,7 @@ export class RemoveVariableCommand implements Command {
|
||||
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
|
||||
this.removed = { def, value: asset.props[this.key] };
|
||||
delete asset.props[this.key];
|
||||
this.overrides = [];
|
||||
for (const site of overrideSites(project, (id) => id === this.assetId)) {
|
||||
const value = site.bag[this.key];
|
||||
if (value === undefined) continue;
|
||||
this.overrides.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value });
|
||||
delete site.bag[this.key];
|
||||
}
|
||||
this.overrides = takeOverrides(project, this.assetId, this.key);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
@@ -84,13 +113,40 @@ export class RemoveVariableCommand implements Command {
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
restoreAt(asset.variables, [this.removed.def]);
|
||||
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
|
||||
const sites = overrideSites(project, (id) => id === this.assetId);
|
||||
for (const o of this.overrides) {
|
||||
const site = sites.find(
|
||||
(s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path,
|
||||
);
|
||||
if (site) site.bag[this.key] = o.value;
|
||||
}
|
||||
restoreOverrides(project, this.assetId, this.key, this.overrides);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A shared variable has no per-instance value, so going `global` takes the deviations placed objects
|
||||
* kept for it. Undo gives them back.
|
||||
*/
|
||||
export class SetVariableScopeCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: Scope = 'local';
|
||||
private overrides: TakenOverride[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly scope: Scope,
|
||||
) {
|
||||
this.label = `Make variable ${key} ${scope}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = variableOf(project, this.assetId, this.key);
|
||||
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
|
||||
this.previous = scopeOf(def);
|
||||
setScope(def, this.scope);
|
||||
this.overrides = this.scope === 'global' ? takeOverrides(project, this.assetId, this.key) : [];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const def = variableOf(project, this.assetId, this.key);
|
||||
if (!def) return;
|
||||
setScope(def, this.previous);
|
||||
restoreOverrides(project, this.assetId, this.key, this.overrides);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,16 @@ export interface PointerState {
|
||||
released: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `global` declarations share for the length of one run: one value per variable name, one
|
||||
* current state per machine. A key that is not in here is not shared – which is what lets a behavior
|
||||
* declaring the same name for itself keep its own value (see `graph/sharedState.ts`).
|
||||
*/
|
||||
export interface SharedState {
|
||||
variables: PropertyBag;
|
||||
states: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the graph does to the world goes through here, so the core stays free of runtime
|
||||
* imports. Implemented by the runtime (BehaviorSystem) and by test doubles.
|
||||
@@ -83,6 +93,7 @@ export interface NodeContext {
|
||||
* as the state node's id. A machine not in here is in no state: `State branch` follows nothing.
|
||||
*/
|
||||
states: Record<string, string>;
|
||||
shared: SharedState;
|
||||
/** Seconds since the world started and since the last tick. */
|
||||
time: number;
|
||||
dt: number;
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './nested';
|
||||
export * from './NodeContext';
|
||||
export * from './NodeRegistry';
|
||||
export * from './propertyPorts';
|
||||
export * from './sharedState';
|
||||
export * from './stateMachine';
|
||||
export * from './values';
|
||||
export * from './variablePorts';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { stateBag } from '../../sharedState';
|
||||
import { machineOf, machinePort, statePorts } from '../../stateMachine';
|
||||
import { asString } from '../../values';
|
||||
import { exec } from '../shared';
|
||||
@@ -10,14 +11,15 @@ export const SetState: NodeDef = {
|
||||
category: 'action',
|
||||
description:
|
||||
'Puts the machine into the state whose input the flow arrived through, then continues. Each ' +
|
||||
'behavior instance keeps its own states.',
|
||||
'behavior instance keeps its own states, unless the machine is global.',
|
||||
inputs: [machinePort()],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
derivePorts: (source) => ({ inputs: statePorts(machineOf(source)) }),
|
||||
run: (ctx, inputs, _params, def, entry) => {
|
||||
const machine = asString(inputs.machine);
|
||||
if (machine && def.inputs.some((p) => p.type === 'exec' && p.key === entry)) ctx.states[machine] = entry;
|
||||
if (machine && def.inputs.some((p) => p.type === 'exec' && p.key === entry))
|
||||
stateBag(ctx, machine)[machine] = entry;
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { stateBag } from '../../sharedState';
|
||||
import { machineOf, machinePort, statePorts } from '../../stateMachine';
|
||||
import { asString } from '../../values';
|
||||
import { exec } from '../shared';
|
||||
@@ -16,7 +17,8 @@ export const StateBranch: NodeDef = {
|
||||
params: [],
|
||||
derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }),
|
||||
run: (ctx, inputs, _params, def) => {
|
||||
const current = ctx.states[asString(inputs.machine)];
|
||||
const machine = asString(inputs.machine);
|
||||
const current = stateBag(ctx, machine)[machine];
|
||||
return def.outputs.some((p) => p.key === current) ? { next: current } : {};
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { textOf } from '../../../model/Properties';
|
||||
import { flatDef } from '../../../properties/PropertySchema';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { stateBag } from '../../sharedState';
|
||||
import { machinePort, STATE_TRANSITION, TRANSITION_PARAM, transitionTo } from '../../stateMachine';
|
||||
import { asString } from '../../values';
|
||||
import { exec } from '../shared';
|
||||
@@ -18,11 +19,12 @@ export const StateTransition: NodeDef = {
|
||||
params: [flatDef(TRANSITION_PARAM, 'text', { label: 'Transition', value: '' })],
|
||||
run: (ctx, inputs, params, _def, _entry, lookup) => {
|
||||
const id = asString(inputs.machine);
|
||||
const current = ctx.states[id] ?? '';
|
||||
const states = stateBag(ctx, id);
|
||||
const current = states[id] ?? '';
|
||||
const machine = current ? lookup.machine(id) : null;
|
||||
const name = textOf(params[TRANSITION_PARAM] ?? '');
|
||||
const target = machine ? transitionTo(machine, current, name) : '';
|
||||
if (target) ctx.states[id] = target;
|
||||
if (target) states[id] = target;
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { collapsedTitle, portDefOf, VARIABLES } from '../../nested';
|
||||
import { type NodeDef, withInlineDefault } from '../../NodeRegistry';
|
||||
import { readableVariables, variableBag } from '../../sharedState';
|
||||
import { writeTyped } from '../../values';
|
||||
import { exec } from '../shared';
|
||||
|
||||
@@ -24,10 +25,10 @@ export const Variables: NodeDef = {
|
||||
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
|
||||
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape;
|
||||
},
|
||||
read: (ctx) => ctx.variables,
|
||||
read: (ctx) => readableVariables(ctx),
|
||||
run: (ctx, inputs, _params, def) => {
|
||||
for (const port of def.inputs)
|
||||
if (port.type !== 'exec') writeTyped(ctx.variables, port.key, inputs[port.key]);
|
||||
if (port.type !== 'exec') writeTyped(variableBag(ctx, port.key), port.key, inputs[port.key]);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { isGlobal } from '../model/Scope';
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import type { NodeContext, SharedState } from './NodeContext';
|
||||
|
||||
export function emptySharedState(): SharedState {
|
||||
return { variables: {}, states: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Built once per run, from the declarations rather than from any instance: a global variable exists
|
||||
* even before the behavior declaring it is placed. Two behaviors declaring the same name mean the
|
||||
* same variable – the first declaration sets the starting value (see `globalConflicts`).
|
||||
*/
|
||||
export function buildSharedState(project: Project): SharedState {
|
||||
const shared = emptySharedState();
|
||||
for (const asset of project.assets) {
|
||||
if (asset.type === 'behavior') {
|
||||
for (const def of asset.variables)
|
||||
if (isGlobal(def) && !(def.key in shared.variables))
|
||||
shared.variables[def.key] = asset.props[def.key] ?? def.default;
|
||||
} else if (asset.type === 'statemachine' && isGlobal(asset)) {
|
||||
shared.states[asset.id] = '';
|
||||
}
|
||||
}
|
||||
return shared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where two behaviors disagree about the same global name. A different kind is an error – one of the
|
||||
* two would silently read a value of a type it never declared – while a different starting value is
|
||||
* only a warning, since the first declaration decides and the run is well defined either way.
|
||||
*/
|
||||
export interface GlobalConflict {
|
||||
key: string;
|
||||
severity: 'error' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function globalConflicts(project: Project): GlobalConflict[] {
|
||||
const first = new Map<string, { def: PropertyDef; asset: string; value: PropertyValue }>();
|
||||
const conflicts: GlobalConflict[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const asset of project.assets) {
|
||||
if (asset.type !== 'behavior') continue;
|
||||
for (const def of asset.variables) {
|
||||
if (!isGlobal(def)) continue;
|
||||
const value = asset.props[def.key] ?? def.default;
|
||||
const held = first.get(def.key);
|
||||
if (!held) {
|
||||
first.set(def.key, { def, asset: asset.name, value });
|
||||
continue;
|
||||
}
|
||||
// One report per name: a third behavior disagreeing adds nothing the first line does not say.
|
||||
if (seen.has(def.key)) continue;
|
||||
if (held.def.kind !== def.kind) {
|
||||
seen.add(def.key);
|
||||
conflicts.push({
|
||||
key: def.key,
|
||||
severity: 'error',
|
||||
message: `Global variable "${def.key}" is a ${held.def.kind} in ${held.asset} and a ${def.kind} in ${asset.name}.`,
|
||||
});
|
||||
} else if (!sameValue(held.value, value)) {
|
||||
seen.add(def.key);
|
||||
conflicts.push({
|
||||
key: def.key,
|
||||
severity: 'warning',
|
||||
message: `Global variable "${def.key}" starts at ${display(held.value)} in ${held.asset} and at ${display(value)} in ${asset.name}; the first is used.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/** Structs and lists compare by their stored shape, which is what the project file holds anyway. */
|
||||
function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
|
||||
return a === b || JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
}
|
||||
|
||||
function display(value: PropertyValue | undefined): string {
|
||||
return typeof value === 'string' ? `"${value}"` : JSON.stringify(value ?? null);
|
||||
}
|
||||
|
||||
/** The bag a variable is written to: its own when the behavior declares it, else the shared one. */
|
||||
export function variableBag(ctx: NodeContext, key: string): PropertyBag {
|
||||
if (key in ctx.variables) return ctx.variables;
|
||||
return key in ctx.shared.variables ? ctx.shared.variables : ctx.variables;
|
||||
}
|
||||
|
||||
/** Everything the behavior can read under one name, its own declaration winning over a shared one. */
|
||||
export function readableVariables(ctx: NodeContext): PropertyBag {
|
||||
const shared = ctx.shared.variables;
|
||||
return isEmpty(shared) ? ctx.variables : { ...shared, ...ctx.variables };
|
||||
}
|
||||
|
||||
/** Where a machine's current state is kept: shared when the machine is `global`, else per instance. */
|
||||
export function stateBag(ctx: NodeContext, machineId: string): Record<string, string> {
|
||||
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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Graph } from '../graph/Graph';
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import type { AssetType } from './AssetTypes';
|
||||
import type { PropertyBag } from './Properties';
|
||||
import type { Scope } from './Scope';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
|
||||
export interface AssetBase {
|
||||
@@ -85,10 +86,12 @@ export interface AnimationAsset extends AssetBase {
|
||||
/**
|
||||
* The graph holds one `State` node per state and an edge per transition; a behavior reads the
|
||||
* machine's current state through `State branch` and moves it through `Set state` or `State transition`.
|
||||
* A `global` machine has one current state for the whole run instead of one per behavior instance.
|
||||
*/
|
||||
export interface StateMachineAsset extends AssetBase {
|
||||
type: 'statemachine';
|
||||
graph: Graph;
|
||||
scope?: Scope;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Whether a declaration keeps a value per behavior instance or shares one for the whole run.
|
||||
* Everything was `local` before scopes existed, so a missing field means `local` and old projects
|
||||
* load unchanged.
|
||||
*/
|
||||
export type Scope = 'local' | 'global';
|
||||
|
||||
export interface Scoped {
|
||||
scope?: Scope;
|
||||
}
|
||||
|
||||
export function scopeOf(owner: Scoped): Scope {
|
||||
return owner.scope === 'global' ? 'global' : 'local';
|
||||
}
|
||||
|
||||
export function isGlobal(owner: Scoped): boolean {
|
||||
return owner.scope === 'global';
|
||||
}
|
||||
|
||||
/** Reads a scope out of loaded data, where anything but the one known word means `local`. */
|
||||
export function readScope(raw: unknown): Scope {
|
||||
return raw === 'global' ? 'global' : 'local';
|
||||
}
|
||||
|
||||
/** Keeps `local` out of the document: the field is present only when it says `global`. */
|
||||
export function setScope(owner: Scoped, scope: Scope): void {
|
||||
if (scope === 'global') owner.scope = 'global';
|
||||
else delete owner.scope;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GraphLookup } from '../graph/lookup';
|
||||
import { withoutGlobals } from '../graph/sharedState';
|
||||
import { statesOf, transitionsOf } from '../graph/stateMachine';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
@@ -12,7 +13,8 @@ export function graphLookup(project: Project): GraphLookup {
|
||||
behavior: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'behavior');
|
||||
if (!asset) return null;
|
||||
return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(project, asset) };
|
||||
const defaults = withoutGlobals(resolveAssetProps(project, asset), asset);
|
||||
return { name: asset.name, graph: asset.graph, defaults };
|
||||
},
|
||||
machine: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type VariableKind,
|
||||
} from './kinds';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { isGlobal, type Scope } from '../model/Scope';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { flatListOf, listItemOf } from './listItems';
|
||||
import { FONT_KEY } from './fonts';
|
||||
@@ -57,10 +58,14 @@ type Extras<K extends FlatKind> = K extends 'number'
|
||||
|
||||
/** 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> = {
|
||||
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P> } & Extras<P>;
|
||||
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P>; scope?: Scope } & Extras<P>;
|
||||
}[K];
|
||||
|
||||
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
|
||||
/**
|
||||
* The inspector is generated from these entries, so a new property is one line in a schema below.
|
||||
* `scope` is only ever set on a behavior's own variables; the fixed schemas below leave it off, which
|
||||
* reads as `local`.
|
||||
*/
|
||||
export type PropertyDef =
|
||||
| FlatDef
|
||||
/**
|
||||
@@ -69,12 +74,19 @@ export type PropertyDef =
|
||||
* item kind: a list is stored in the project file, and an object is a live handle that no reload
|
||||
* survives.
|
||||
*/
|
||||
| { key: string; label: string; kind: 'list'; default: ListValue }
|
||||
| { key: string; label: string; kind: 'list'; default: ListValue; scope?: Scope }
|
||||
/**
|
||||
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
|
||||
* keep its shape while single fields are written (`mergeStruct`).
|
||||
*/
|
||||
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'struct';
|
||||
default: StructValue;
|
||||
fields: readonly StructFieldDef[];
|
||||
scope?: Scope;
|
||||
};
|
||||
|
||||
export interface PrimitiveFieldDef {
|
||||
key: string;
|
||||
@@ -299,10 +311,15 @@ export function schemaForAsset(project: Project, asset: Asset): PropertySchema {
|
||||
return schemaForBag(project, asset, asset.props);
|
||||
}
|
||||
|
||||
/** A placed object's rows: an object overriding its shader has that shader's parameters. */
|
||||
/**
|
||||
* A placed object's rows: an object overriding its shader has that shader's parameters. A global
|
||||
* variable is not among them – it belongs to the run, not to this object, so it shows no row here
|
||||
* and never reaches `entity.props` (which is what lets the shared bag answer for it instead).
|
||||
*/
|
||||
export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema {
|
||||
const shader = object.overrides[SHADER_KEY];
|
||||
return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader });
|
||||
const rows = schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader });
|
||||
return rows.some(isGlobal) ? rows.filter((def) => !isGlobal(def)) : rows;
|
||||
}
|
||||
|
||||
function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '../graph';
|
||||
import { isParticleNodeDef } from '../particles/nodes';
|
||||
import { isShaderNodeDef } from '../shader/nodes';
|
||||
import type { Asset, TextureFrame } from '../model/Asset';
|
||||
import type { Asset, StateMachineAsset, TextureFrame } from '../model/Asset';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
import { isFontFile } from '../model/font';
|
||||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
tagOf,
|
||||
} from '../model/Properties';
|
||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||
import { readScope } from '../model/Scope';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile, normalizeFrameRect } from '../model/texture';
|
||||
import { hasParentCycle } from '../model/walk';
|
||||
@@ -130,7 +131,15 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
graph: normalizeGraph(a.graph),
|
||||
});
|
||||
} else if (type === 'statemachine') {
|
||||
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, (def) => def.type === STATE) });
|
||||
const machine: StateMachineAsset = {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
props,
|
||||
graph: normalizeGraph(a.graph, (def) => def.type === STATE),
|
||||
};
|
||||
if (readScope(a.scope) === 'global') machine.scope = 'global';
|
||||
assets.push(machine);
|
||||
} else if (type === 'shader') {
|
||||
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
|
||||
} else if (type === 'particles') {
|
||||
@@ -251,6 +260,8 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
const fields = normalizeStructFields(v.fields);
|
||||
const def = variableDef(key, kind, undefined, fields);
|
||||
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
|
||||
// Written only when shared, so every project made before scopes existed saves back unchanged.
|
||||
if (readScope(v.scope) === 'global') def.scope = 'global';
|
||||
defs.push(def);
|
||||
}
|
||||
return defs;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* keyboard and the per-frame step. Play never touches the saved model – the world is built
|
||||
* from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away.
|
||||
*/
|
||||
import { worldPositionOf } from '../core/graph';
|
||||
import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph';
|
||||
import {
|
||||
addGameSystems,
|
||||
type BehaviorSystem,
|
||||
@@ -33,6 +33,8 @@ export interface WorldSnapshot {
|
||||
props: Record<string, unknown>;
|
||||
animation: AnimationClock | null;
|
||||
}[];
|
||||
/** What `global` declarations share this run – no entity carries it, so it is reported apart. */
|
||||
shared: SharedState;
|
||||
log: string[];
|
||||
}
|
||||
|
||||
@@ -169,6 +171,7 @@ export class PlaySession {
|
||||
animation: e.animation ? { ...e.animation } : null,
|
||||
};
|
||||
}),
|
||||
shared: this.behaviors?.shared ?? emptySharedState(),
|
||||
log: [...world.log],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
RenameAssetCommand,
|
||||
ResetAssetPropertyCommand,
|
||||
SetAssetPropertyCommand,
|
||||
SetMachineScopeCommand,
|
||||
} from '../../core/commands/assetCommands';
|
||||
import { nextFieldName, nextVariableName } from '../../core/commands/naming';
|
||||
import {
|
||||
@@ -11,10 +12,13 @@ import {
|
||||
RemoveVariableCommand,
|
||||
RenameStructFieldCommand,
|
||||
RenameVariableCommand,
|
||||
SetVariableScopeCommand,
|
||||
} from '../../core/commands/variableCommands';
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||
import type { Asset, BehaviorAsset, StateMachineAsset } from '../../core/model/Asset';
|
||||
import { globalConflicts } from '../../core/graph/sharedState';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import { isGlobal, type Scope, scopeOf } from '../../core/model/Scope';
|
||||
import { type StructFieldKind, VARIABLE_KINDS } from '../../core/properties/kinds';
|
||||
import { type PropertyDef, schemaForAsset, type StructFieldDef } from '../../core/properties/PropertySchema';
|
||||
import { markedAssetSchema } from '../../core/properties/deviation';
|
||||
@@ -22,7 +26,7 @@ import { isModified, resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { Dropdown, HStack, Label, Menu, Theme } from '../../ui';
|
||||
import { FontPreview } from '../font/FontPreview';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { actionIf, createFieldRow, type FieldHandlers, NAME_WIDTH } from './fieldFactory';
|
||||
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { buildVariable, kindMenu, type VariableEditing } from './variableRows';
|
||||
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
|
||||
@@ -36,13 +40,16 @@ const newField = (key: string, kind: StructFieldKind): StructFieldDef => ({ key,
|
||||
*/
|
||||
export function assetSignature(project: Project, assetId: string): string {
|
||||
const asset = findAsset(project, assetId);
|
||||
return asset ? schemaForAsset(project, asset).map(defSignature).join(',') : 'missing';
|
||||
if (!asset) return 'missing';
|
||||
// A machine's own rows are none, so its scope has to be named here or switching it rebuilds nothing.
|
||||
const machine = asset.type === 'statemachine' ? `|${scopeOf(asset)}` : '';
|
||||
return schemaForAsset(project, asset).map(defSignature).join(',') + machine;
|
||||
}
|
||||
|
||||
export function defSignature(def: PropertyDef): string {
|
||||
// Only a struct has rows of its own; a list is one row whatever its items are.
|
||||
const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : '';
|
||||
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
|
||||
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}@${scopeOf(def)}`;
|
||||
}
|
||||
|
||||
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
|
||||
@@ -59,6 +66,7 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void {
|
||||
|
||||
buildProps(host, asset, markedAssetSchema(host.state.project, asset));
|
||||
if (asset.type === 'behavior') buildVariables(host, asset);
|
||||
if (asset.type === 'statemachine') buildMachineScope(host, asset);
|
||||
// A font has no properties at all, so without this its inspector would be the name and nothing.
|
||||
if (asset.type === 'font')
|
||||
host.body.add(
|
||||
@@ -152,6 +160,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
remove: () => run(new RemoveVariableCommand(asset.id, def.key)),
|
||||
renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)),
|
||||
removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
|
||||
setScope: (scope) => run(new SetVariableScopeCommand(asset.id, def.key, scope)),
|
||||
addField: (kind) =>
|
||||
run(
|
||||
new AddStructFieldCommand(
|
||||
@@ -164,4 +173,35 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
const value = values[def.key] ?? def.default;
|
||||
host.fields.set(def.key, buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit }));
|
||||
}
|
||||
|
||||
// The mark alone says nothing to someone meeting it for the first time.
|
||||
if (asset.variables.some(isGlobal))
|
||||
addNote(host, '⊕ is one value for the whole run, shared by name.', 'small');
|
||||
|
||||
// A global name is shared with every other behavior declaring it, so the disagreement shows here.
|
||||
const own = new Set(asset.variables.map((v) => v.key));
|
||||
for (const conflict of globalConflicts(host.state.project))
|
||||
if (own.has(conflict.key)) addNote(host, conflict.message, 'warning');
|
||||
}
|
||||
|
||||
/** A state machine has no properties of its own, so the inspector shows just its name and this. */
|
||||
function buildMachineScope(host: InspectorHost, asset: StateMachineAsset): void {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.add(new Label('Scope', { style: 'muted', width: NAME_WIDTH, padding: Theme.fieldPadding }));
|
||||
const pick = new Dropdown(isGlobal(asset) ? 'Global' : 'Local', { variant: 'ghost' });
|
||||
const set = (scope: Scope) => () => void host.state.execute(new SetMachineScopeCommand(asset.id, scope));
|
||||
pick.onClick = () =>
|
||||
new Menu([
|
||||
{ label: 'Local \u2013 one current state per object', onSelect: set('local') },
|
||||
{ label: 'Global \u2013 one current state for the whole run', onSelect: set('global') },
|
||||
]).openBelow(pick);
|
||||
row.add(pick, 1);
|
||||
host.body.add(row);
|
||||
addNote(
|
||||
host,
|
||||
isGlobal(asset)
|
||||
? 'Every behavior using this machine shares its current state.'
|
||||
: 'Each behavior instance keeps its own current state.',
|
||||
'small',
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../../core/model/instance';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
|
||||
import { isGlobal } from '../../core/model/Scope';
|
||||
import type { SceneObject } from '../../core/model/SceneObject';
|
||||
import {
|
||||
COMMON_PROPS,
|
||||
@@ -52,6 +53,17 @@ import { buildVariable } from './variableRows';
|
||||
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
|
||||
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
|
||||
|
||||
/**
|
||||
* The rows a placed object shows for what it was placed from: a behavior's declared variables, any
|
||||
* other asset's schema minus the common switches. A global variable is none of them – it belongs to
|
||||
* the run, so this object has no value of its own to show or override.
|
||||
*/
|
||||
export function objectRows(project: Project, asset: Asset, object: SceneObject): PropertyDef[] {
|
||||
return asset.type === 'behavior'
|
||||
? asset.variables.filter((def) => !isGlobal(def))
|
||||
: schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key));
|
||||
}
|
||||
|
||||
/** Structure-defining facts of an object selection: parent, template link and schema. */
|
||||
export function objectSignature(
|
||||
project: Project,
|
||||
@@ -186,14 +198,17 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
|
||||
|
||||
// Asset properties (or behavior variables) with overrides; the switches are shown above.
|
||||
const values = resolveSceneObjectProps(host.state.project, asset, object);
|
||||
const own =
|
||||
asset.type === 'behavior'
|
||||
? asset.variables
|
||||
: schemaForObject(host.state.project, asset, object).filter((def) => !isCommonProp(def.key));
|
||||
const own = objectRows(host.state.project, asset, object);
|
||||
if (own.length > 0 || asset.type === 'behavior')
|
||||
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
|
||||
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
|
||||
// A global variable is missing from the rows above but not from the behavior, so saying it
|
||||
// declares none would be untrue – name it instead, and where it went.
|
||||
const shared = asset.type === 'behavior' ? asset.variables.filter(isGlobal) : [];
|
||||
if (own.length === 0 && shared.length === 0 && asset.type === 'behavior')
|
||||
addNote(host, 'The behavior declares no variables.');
|
||||
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, asset.type === 'behavior');
|
||||
if (shared.length > 0)
|
||||
addNote(host, `Shared by the whole run: ${shared.map((def) => def.key).join(', ')}.`, 'small');
|
||||
markObjectRows(host, object, asset, target);
|
||||
|
||||
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import { type Scope, scopeOf } from '../../core/model/Scope';
|
||||
import { kindLabel, kindMark, STRUCT_FIELD_KINDS, type StructFieldKind } from '../../core/properties/kinds';
|
||||
import type { PropertyDef, StructDef, StructFieldDef } from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
@@ -39,11 +40,21 @@ export function kindMenu<K extends string>(kinds: readonly K[], pick: (kind: K)
|
||||
export interface VariableEditing {
|
||||
rename: (next: string) => void;
|
||||
remove: () => void;
|
||||
setScope: (scope: Scope) => void;
|
||||
renameField: (field: StructFieldDef, next: string) => void;
|
||||
removeField: (field: StructFieldDef) => void;
|
||||
addField: (kind: StructFieldKind) => void;
|
||||
}
|
||||
|
||||
const SCOPE_MARK: Record<Scope, string> = { local: '◦', global: '⊕' };
|
||||
|
||||
function scopePicker(def: PropertyDef, edit: VariableEditing): Button {
|
||||
return markPicker(SCOPE_MARK[scopeOf(def)], [
|
||||
{ label: 'Local – one value per object', onSelect: () => edit.setScope('local') },
|
||||
{ label: 'Global – one value for the whole run', onSelect: () => edit.setScope('global') },
|
||||
]);
|
||||
}
|
||||
|
||||
export interface VariableOptions {
|
||||
edit?: VariableEditing;
|
||||
/** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */
|
||||
@@ -71,6 +82,7 @@ export function buildVariable(
|
||||
} else {
|
||||
setValue = addValue(head, createField(host, def, value, handlers));
|
||||
}
|
||||
if (edit) head.add(scopePicker(def, edit));
|
||||
if (edit) head.add(removeButton(edit.remove));
|
||||
const refreshActions = actions ? attachRowActions(head, actions) : () => {};
|
||||
host.body.add(head);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
asNumber,
|
||||
buildSharedState,
|
||||
emptySharedState,
|
||||
globalConflicts,
|
||||
type EntityHandle,
|
||||
type Graph,
|
||||
type GraphNode,
|
||||
GraphInterpreter,
|
||||
isHandleEnabled,
|
||||
type NodeContext,
|
||||
type SharedState,
|
||||
type TraceListener,
|
||||
type Value,
|
||||
} from '../../core/graph';
|
||||
@@ -83,6 +87,7 @@ export function behaviorError(world: World): string | undefined {
|
||||
export class BehaviorSystem implements System {
|
||||
private instances: Instance[] = [];
|
||||
private world: World | null = null;
|
||||
private sharedState: SharedState = emptySharedState();
|
||||
private readonly collisions: CollisionSystem | null;
|
||||
private readonly animations: AnimationSystem | null;
|
||||
|
||||
@@ -100,9 +105,24 @@ export class BehaviorSystem implements System {
|
||||
this.animations = sources.animations ?? null;
|
||||
}
|
||||
|
||||
/** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */
|
||||
get shared(): Readonly<SharedState> {
|
||||
return this.sharedState;
|
||||
}
|
||||
|
||||
start(world: World): void {
|
||||
this.world = world;
|
||||
this.instances = [];
|
||||
// Built fresh for every run: a shared variable starts at its declared value each time Play does.
|
||||
this.sharedState = buildSharedState(this.project);
|
||||
// Two behaviors declaring one global name with different kinds: whichever ran second would read
|
||||
// a value of a type it never declared, so nothing runs until that is settled.
|
||||
const blocking = globalConflicts(this.project).filter((c) => c.severity === 'error');
|
||||
if (blocking.length > 0) {
|
||||
for (const conflict of blocking) world.log.push(`${ERROR_PREFIX}: ${conflict.message}`);
|
||||
world.stopRequested = true;
|
||||
return;
|
||||
}
|
||||
this.syncInstances(world);
|
||||
}
|
||||
|
||||
@@ -341,6 +361,7 @@ export class BehaviorSystem implements System {
|
||||
owner: entity.parent,
|
||||
variables: entity.props,
|
||||
states,
|
||||
shared: this.sharedState,
|
||||
time: world.time,
|
||||
dt,
|
||||
findByName: (name) => world.findByName(name) ?? null,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* A variable or a state machine declared `global` keeps one value for the whole run instead of one
|
||||
* per behavior instance. The name is the key: two behaviors declaring the same global name mean the
|
||||
* same variable, while a behavior declaring it locally keeps its own.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CreateAssetCommand,
|
||||
SetAssetPropertyCommand,
|
||||
SetMachineScopeCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
|
||||
import { AddVariableCommand, SetVariableScopeCommand } from '../../src/core/commands/variableCommands';
|
||||
import { globalConflicts, STATE, STATE_NAME_PARAM } from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import type { PropertyValue } from '../../src/core/model/Properties';
|
||||
import type { VariableKind } from '../../src/core/properties/kinds';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers';
|
||||
|
||||
/** A behavior that adds one to `key` whenever it starts – the coin counter of the example game. */
|
||||
function makeCounter(fixture: SceneFixture, name: string, key: string, scope: 'local' | 'global') {
|
||||
const behavior = makeBehavior(fixture, name, [key]);
|
||||
if (scope === 'global') fixture.stack.execute(new SetVariableScopeCommand(behavior.id, key, scope));
|
||||
const editor = graphEditor(fixture, behavior);
|
||||
const start = editor.addNode('OnStart');
|
||||
const read = editor.variables([key]);
|
||||
const add = editor.addNode('Add', { b: 1 });
|
||||
const write = editor.variables([key], { write: true });
|
||||
editor.wire([
|
||||
[start, 'exec', write, 'exec'],
|
||||
[read, key, add, 'a'],
|
||||
[add, 'result', write, key],
|
||||
]);
|
||||
return behavior;
|
||||
}
|
||||
|
||||
/** Places `count` objects of the behavior and runs one frame, so every `On start` has fired. */
|
||||
function runWith(fixture: SceneFixture, behaviors: readonly BehaviorAsset[]) {
|
||||
for (const behavior of behaviors) place(fixture, behavior.id, { x: 0, y: 0 });
|
||||
const started = startWorld(fixture, { behaviors: true });
|
||||
started.frame();
|
||||
return started;
|
||||
}
|
||||
|
||||
describe('variable scope', () => {
|
||||
it('counts once per instance when local', () => {
|
||||
const f = makeSpriteScene();
|
||||
const counter = makeCounter(f, 'Counter', 'score', 'local');
|
||||
const started = runWith(f, [counter, counter]);
|
||||
const values = started.world.entities.filter((e) => e.type === 'behavior').map((e) => e.props.score);
|
||||
expect(values).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it('counts once for the whole run when global', () => {
|
||||
const f = makeSpriteScene();
|
||||
const counter = makeCounter(f, 'Counter', 'score', 'global');
|
||||
const started = runWith(f, [counter, counter]);
|
||||
expect(started.behaviors!.shared.variables.score).toBe(2);
|
||||
// The value belongs to the run, so it is no longer a property of any placed object.
|
||||
for (const entity of started.world.entities) expect(entity.props.score).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shares one variable between two behaviors declaring the same global name', () => {
|
||||
const f = makeSpriteScene();
|
||||
const one = makeCounter(f, 'One', 'score', 'global');
|
||||
const other = makeCounter(f, 'Other', 'score', 'global');
|
||||
const started = runWith(f, [one, other]);
|
||||
expect(started.behaviors!.shared.variables.score).toBe(2);
|
||||
});
|
||||
|
||||
/** The scope sits on the declaration, so declaring the name yourself is how you opt out. */
|
||||
it('lets a behavior declaring the name locally keep its own value', () => {
|
||||
const f = makeSpriteScene();
|
||||
const shared = makeCounter(f, 'Shared', 'score', 'global');
|
||||
const own = makeCounter(f, 'Own', 'score', 'local');
|
||||
const started = runWith(f, [shared, shared, own]);
|
||||
expect(started.behaviors!.shared.variables.score).toBe(2);
|
||||
const ownEntity = started.world.entities.find((e) => e.assetId === own.id)!;
|
||||
expect(ownEntity.props.score).toBe(1);
|
||||
});
|
||||
|
||||
it('starts a global variable at its declared value again on the next run', () => {
|
||||
const f = makeSpriteScene();
|
||||
const counter = makeCounter(f, 'Counter', 'score', 'global');
|
||||
place(f, counter.id, { x: 0, y: 0 });
|
||||
const first = startWorld(f, { behaviors: true });
|
||||
first.frame();
|
||||
expect(first.behaviors!.shared.variables.score).toBe(1);
|
||||
|
||||
const second = startWorld(f, { behaviors: true });
|
||||
second.frame();
|
||||
expect(second.behaviors!.shared.variables.score).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('two behaviors declaring the same global name', () => {
|
||||
/** Declares `score` as `kind` with `start` as its value, global. */
|
||||
function declare(fixture: SceneFixture, name: string, kind: VariableKind, start: PropertyValue) {
|
||||
const behavior = makeBehavior(fixture, name);
|
||||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'score', kind));
|
||||
fixture.stack.execute(new SetAssetPropertyCommand(behavior.id, 'score', start));
|
||||
fixture.stack.execute(new SetVariableScopeCommand(behavior.id, 'score', 'global'));
|
||||
return behavior;
|
||||
}
|
||||
|
||||
it('refuses to run when the kinds disagree', () => {
|
||||
const f = makeSpriteScene();
|
||||
const counting = declare(f, 'Counting', 'number', 0);
|
||||
declare(f, 'Naming', 'text', '');
|
||||
place(f, counting.id, { x: 0, y: 0 });
|
||||
const started = startWorld(f, { behaviors: true });
|
||||
|
||||
expect(started.world.stopRequested).toBe(true);
|
||||
expect(started.world.log.join('\n')).toContain('is a number in Counting and a text in Naming');
|
||||
expect(started.behaviors!.shared.variables.score).toBe(0);
|
||||
});
|
||||
|
||||
it('runs on the first starting value when only the defaults disagree', () => {
|
||||
const f = makeSpriteScene();
|
||||
const first = declare(f, 'First', 'number', 7);
|
||||
declare(f, 'Second', 'number', 99);
|
||||
place(f, first.id, { x: 0, y: 0 });
|
||||
const started = startWorld(f, { behaviors: true });
|
||||
started.frame();
|
||||
|
||||
expect(started.world.stopRequested).toBe(false);
|
||||
expect(started.behaviors!.shared.variables.score).toBe(7);
|
||||
const conflicts = globalConflicts(f.project);
|
||||
expect(conflicts.map((c) => c.severity)).toEqual(['warning']);
|
||||
expect(conflicts[0]!.message).toContain('starts at 7 in First and at 99 in Second');
|
||||
});
|
||||
|
||||
it('says nothing when they agree', () => {
|
||||
const f = makeSpriteScene();
|
||||
declare(f, 'First', 'number', 7);
|
||||
declare(f, 'Second', 'number', 7);
|
||||
expect(globalConflicts(f.project)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state machine scope', () => {
|
||||
/** A machine with one state, plus a behavior that enters it on start. */
|
||||
function makeMachineAndSetter(fixture: SceneFixture, scope: 'local' | 'global') {
|
||||
const machine = new CreateAssetCommand('statemachine', 'Doors');
|
||||
fixture.stack.execute(machine);
|
||||
const state = new AddNodeCommand(machine.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: 'Open' });
|
||||
fixture.stack.execute(state);
|
||||
if (scope === 'global') fixture.stack.execute(new SetMachineScopeCommand(machine.asset.id, scope));
|
||||
|
||||
const behavior = makeBehavior(fixture, 'Opener');
|
||||
const editor = graphEditor(fixture, behavior);
|
||||
const start = editor.addNode('OnStart');
|
||||
const set = editor.addNode('SetState', { machine: machine.asset.id });
|
||||
fixture.stack.execute(
|
||||
new ConnectCommand(
|
||||
behavior.id,
|
||||
{ node: start.id, port: 'exec' },
|
||||
{ node: set.id, port: state.node.id },
|
||||
),
|
||||
);
|
||||
return { machineId: machine.asset.id, stateId: state.node.id, behavior };
|
||||
}
|
||||
|
||||
/**
|
||||
* A second behavior that never sets the state, only branches on it: it writes `sawOpen = 1` when
|
||||
* the machine is already in Open. That is the whole difference a scope makes.
|
||||
*/
|
||||
function makeReader(fixture: SceneFixture, machineId: string, stateId: string) {
|
||||
const behavior = makeBehavior(fixture, 'Reader', ['sawOpen']);
|
||||
const editor = graphEditor(fixture, behavior);
|
||||
const start = editor.addNode('OnStart');
|
||||
const branch = editor.addNode('StateBranch', { machine: machineId });
|
||||
const one = editor.addNode('Add', { a: 1, b: 0 });
|
||||
const write = editor.variables(['sawOpen'], { write: true });
|
||||
editor.wire([
|
||||
[start, 'exec', branch, 'exec'],
|
||||
[branch, stateId, write, 'exec'],
|
||||
[one, 'result', write, 'sawOpen'],
|
||||
]);
|
||||
return behavior;
|
||||
}
|
||||
|
||||
it('hides the state from another instance when local', () => {
|
||||
const f = makeSpriteScene();
|
||||
const { machineId, stateId, behavior } = makeMachineAndSetter(f, 'local');
|
||||
const reader = makeReader(f, machineId, stateId);
|
||||
const started = runWith(f, [behavior, reader]);
|
||||
expect(started.behaviors!.shared.states[machineId]).toBeUndefined();
|
||||
const seen = started.world.entities.find((e) => e.assetId === reader.id)!;
|
||||
expect(seen.props.sawOpen).toBe(0);
|
||||
});
|
||||
|
||||
it('shows the state to another instance when global', () => {
|
||||
const f = makeSpriteScene();
|
||||
const { machineId, stateId, behavior } = makeMachineAndSetter(f, 'global');
|
||||
const reader = makeReader(f, machineId, stateId);
|
||||
const started = runWith(f, [behavior, reader]);
|
||||
expect(started.behaviors!.shared.states[machineId]).toBe(stateId);
|
||||
const seen = started.world.entities.find((e) => e.assetId === reader.id)!;
|
||||
expect(seen.props.sawOpen).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import {
|
||||
CreateAssetCommand,
|
||||
SetAssetPropertyCommand,
|
||||
SetMachineScopeCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { SetVariableScopeCommand } from '../../src/core/commands/variableCommands';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
|
||||
describe('serialize', () => {
|
||||
@@ -21,6 +26,40 @@ describe('serialize', () => {
|
||||
expect(loaded.props.runInEditor).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips the scope of a variable and of a state machine', () => {
|
||||
const f = makeSpriteScene();
|
||||
const behavior = makeBehavior(f, 'B', ['score']);
|
||||
f.stack.execute(new SetVariableScopeCommand(behavior.id, 'score', 'global'));
|
||||
const machine = new CreateAssetCommand('statemachine', 'M');
|
||||
f.stack.execute(machine);
|
||||
f.stack.execute(new SetMachineScopeCommand(machine.asset.id, 'global'));
|
||||
expect(roundTrip(f.project)).toEqual(f.project);
|
||||
});
|
||||
|
||||
/** `local` is the absence of the field, so a project made before scopes existed saves back byte-alike. */
|
||||
it('writes no scope for a local variable and reads an unknown one as local', () => {
|
||||
const f = makeSpriteScene();
|
||||
const behavior = makeBehavior(f, 'B', ['score']);
|
||||
expect(JSON.stringify(serializeProject(f.project))).not.toContain('scope');
|
||||
|
||||
const loaded = deserializeProject({
|
||||
assets: [
|
||||
{
|
||||
id: behavior.id,
|
||||
type: 'behavior',
|
||||
name: 'B',
|
||||
variables: [{ key: 'score', kind: 'number', scope: 'planet' }],
|
||||
},
|
||||
{ id: 'm1', type: 'statemachine', name: 'M', scope: 'planet' },
|
||||
],
|
||||
});
|
||||
const reloaded = loaded.assets[0]!;
|
||||
const machine = loaded.assets[1]!;
|
||||
if (reloaded.type !== 'behavior' || machine.type !== 'statemachine') throw new Error('wrong types');
|
||||
expect(reloaded.variables[0]!.scope).toBeUndefined();
|
||||
expect(machine.scope).toBeUndefined();
|
||||
});
|
||||
|
||||
it('repairs broken documents', () => {
|
||||
const project = deserializeProject(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* What the inspector of a *placed* behavior lists. A global variable has no value of this object's
|
||||
* own, so it gets no row here – the switch on the declaration is the only place it is edited.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SetVariableScopeCommand } from '../../src/core/commands/variableCommands';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { objectRows } from '../../src/editor/inspector/objectSection';
|
||||
import { makeBehavior, makeSpriteScene, place } from '../helpers';
|
||||
|
||||
describe('the rows of a placed behavior', () => {
|
||||
it('lists a local variable and leaves out a global one', () => {
|
||||
const f = makeSpriteScene();
|
||||
const behavior = makeBehavior(f, 'Counter', ['score', 'lives']);
|
||||
const objectId = place(f, behavior.id, { x: 0, y: 0 });
|
||||
const rows = () => {
|
||||
const asset = findAsset(f.project, behavior.id)!;
|
||||
return objectRows(f.project, asset, f.object(objectId)).map((def) => def.key);
|
||||
};
|
||||
|
||||
expect(rows()).toEqual(['score', 'lives']);
|
||||
f.stack.execute(new SetVariableScopeCommand(behavior.id, 'score', 'global'));
|
||||
expect(rows()).toEqual(['lives']);
|
||||
f.stack.undo();
|
||||
expect(rows()).toEqual(['score', 'lives']);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ const withFields: FieldHandlers = { ...noHandlers(), structFieldHandlers: noHand
|
||||
const EDIT: VariableEditing = {
|
||||
rename: () => {},
|
||||
remove: () => {},
|
||||
setScope: () => {},
|
||||
renameField: () => {},
|
||||
removeField: () => {},
|
||||
addField: () => {},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { requireNodeDef } from '../src/core/commands/require';
|
||||
import { AddVariableCommand } from '../src/core/commands/variableCommands';
|
||||
import {
|
||||
type DeclaredPort,
|
||||
emptySharedState,
|
||||
ENTITY_HANDLE,
|
||||
type EntityHandle,
|
||||
type GraphLookup,
|
||||
@@ -185,6 +186,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
owner: fakeEntity('owner'),
|
||||
variables: { speed: 100 },
|
||||
states: {},
|
||||
shared: emptySharedState(),
|
||||
time: 0,
|
||||
dt: 0.5,
|
||||
logs,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { edge, node, port, variable, written } from '../lib/builders.mjs';
|
||||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* Scopes: a variable or a state machine declared `global` keeps one value for the whole run instead
|
||||
* of one per behavior instance. The step checks the run (two objects counting into one number), the
|
||||
* switch in the inspector, and that a global variable stops being a property of the placed object.
|
||||
*
|
||||
* The step brings its own boxes, behavior and machine; everything it adds is removed at the end.
|
||||
*/
|
||||
|
||||
/** `On start → Variables{set score} ← Add(score, 1)`: every instance adds one to the same number. */
|
||||
const counterGraph = {
|
||||
nodes: [
|
||||
node('sc-start', 'OnStart', 0, 0),
|
||||
node('sc-read', 'Variables', 0, 200, {}, [port('score', 'number')]),
|
||||
node('sc-add', 'Add', 260, 200, { b: 1 }),
|
||||
node('sc-write', 'Variables', 560, 0, {}, [written('score', 'number')]),
|
||||
],
|
||||
edges: [
|
||||
edge('sc-e1', 'sc-start', 'exec', 'sc-write', 'exec'),
|
||||
edge('sc-e2', 'sc-read', 'score', 'sc-add', 'a'),
|
||||
edge('sc-e3', 'sc-add', 'result', 'sc-write', 'score'),
|
||||
],
|
||||
};
|
||||
|
||||
const globalScore = { ...variable('score', 'number', 0), scope: 'global' };
|
||||
|
||||
export async function scope(t) {
|
||||
const { check, sleep, evaluate, json, shot, click, locate, asset, mutate, stop, has } = t;
|
||||
|
||||
const selectAsset = async (id) => {
|
||||
await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(id)} })`);
|
||||
await sleep(300);
|
||||
};
|
||||
const selectObject = async (id) => {
|
||||
await evaluate(
|
||||
`__gm.state.select({ kind: 'object', holderId: __gm.state.activeScene.id, objectId: ${JSON.stringify(id)} })`,
|
||||
);
|
||||
await sleep(300);
|
||||
};
|
||||
/** The inspector is drawn over the graph, so the last match is the row rather than a port. */
|
||||
const clickLast = async (text) => {
|
||||
const p = await locate(text, true);
|
||||
await click(p.x, p.y);
|
||||
await sleep(250);
|
||||
};
|
||||
const scopeOfVariable = async () => (await asset('sc-logic')).variables[0].scope ?? 'local';
|
||||
|
||||
// 32a. Two boxes wearing one behavior whose `score` is global.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const mover = p.assets.find((a) => a.id === 'mover');
|
||||
const a = structuredClone(mover);
|
||||
Object.assign(a, {
|
||||
id: 'sc-logic',
|
||||
name: 'Counter',
|
||||
props: { enabled: true, runInEditor: false, score: 0 },
|
||||
variables: ${JSON.stringify([globalScore])},
|
||||
graph: ${JSON.stringify(counterGraph)},
|
||||
});
|
||||
p.assets.unshift(a);
|
||||
${MAKE_OBJECT}
|
||||
scene.objects.push(
|
||||
make('sc-box-a', 'crate', '', 1500, 'Counter box A'),
|
||||
make('sc-brain-a', 'sc-logic', 'sc-box-a', 0, 'Counter A'),
|
||||
make('sc-box-b', 'crate', '', 1700, 'Counter box B'),
|
||||
make('sc-brain-b', 'sc-logic', 'sc-box-b', 0, 'Counter B'),
|
||||
);
|
||||
`);
|
||||
await sleep(400);
|
||||
|
||||
// 32b. The run: two instances, one number. A per-instance variable would stand at 1.
|
||||
await t.play();
|
||||
await sleep(600);
|
||||
const world = await json('__gm.world()');
|
||||
const carriers = world.entities.filter((e) => e.props && 'score' in e.props).map((e) => e.name);
|
||||
check(
|
||||
'two behavior instances count into one global variable',
|
||||
world.shared.variables.score === 2,
|
||||
`score = ${JSON.stringify(world.shared.variables.score)} after both started – 1 would mean each kept its own`,
|
||||
);
|
||||
check(
|
||||
'and the value belongs to the run, not to either placed object',
|
||||
carriers.length === 0,
|
||||
`objects carrying "score": ${JSON.stringify(carriers)}`,
|
||||
);
|
||||
await stop();
|
||||
await sleep(400);
|
||||
|
||||
// 32c. A global variable shows no row on the object placed from the behavior – overriding one
|
||||
// value of something every object shares would mean nothing.
|
||||
await selectObject('sc-brain-a');
|
||||
const rowWhileGlobal = await has('score');
|
||||
const saysWhereItWent = await has('Shared by the whole run: score.');
|
||||
check(
|
||||
'the object names the variable it no longer holds, instead of claiming there is none',
|
||||
saysWhereItWent && !(await has('The behavior declares no variables.')),
|
||||
`note found: ${saysWhereItWent}`,
|
||||
);
|
||||
await shot('01-object-without-global');
|
||||
|
||||
// 32d. The switch sits on the declaration, in the behavior's own inspector.
|
||||
await selectAsset('sc-logic');
|
||||
const picker = await locate('⊕');
|
||||
check(
|
||||
'a global variable is marked as shared where it is declared',
|
||||
Boolean(picker),
|
||||
JSON.stringify(picker),
|
||||
);
|
||||
await shot('02-declaration');
|
||||
await click(picker.x, picker.y);
|
||||
await sleep(250);
|
||||
await clickLast('Local – one value per object');
|
||||
await sleep(350);
|
||||
check(
|
||||
'and the menu switches it back to one value per object',
|
||||
(await scopeOfVariable()) === 'local',
|
||||
`scope is now ${await scopeOfVariable()}`,
|
||||
);
|
||||
|
||||
await selectObject('sc-brain-a');
|
||||
const rowWhileLocal = await has('score');
|
||||
check(
|
||||
'the row appears on the placed object exactly while the variable is local',
|
||||
!rowWhileGlobal && rowWhileLocal,
|
||||
`global: ${rowWhileGlobal}, local: ${rowWhileLocal}`,
|
||||
);
|
||||
|
||||
// 32e. A machine carries the same switch, and its own inspector is where it lives.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets.unshift({
|
||||
id: 'sc-machine',
|
||||
type: 'statemachine',
|
||||
name: 'Shared doors',
|
||||
props: {},
|
||||
graph: { nodes: [], edges: [] },
|
||||
});
|
||||
`);
|
||||
await selectAsset('sc-machine');
|
||||
const machineScope = await has('Local');
|
||||
const pick = await locate('Local', true);
|
||||
await click(pick.x, pick.y);
|
||||
await sleep(250);
|
||||
await clickLast('Global – one current state for the whole run');
|
||||
await sleep(350);
|
||||
const machine = await asset('sc-machine');
|
||||
check(
|
||||
'a state machine is switched between one current state per object and one for the run',
|
||||
machineScope && machine.scope === 'global',
|
||||
`dropdown found: ${machineScope}, scope now ${JSON.stringify(machine.scope)}`,
|
||||
);
|
||||
await shot('03-machine-scope');
|
||||
|
||||
// 32f. Everything this step added goes again.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('sc-'));
|
||||
p.assets = p.assets.filter((a) => !a.id.startsWith('sc-'));
|
||||
__gm.state.select(null);
|
||||
`);
|
||||
await sleep(300);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { shader } from './28-shader.mjs';
|
||||
import { transform } from './29-transform.mjs';
|
||||
import { font } from './30-font.mjs';
|
||||
import { particles } from './31-particles.mjs';
|
||||
import { scope } from './32-scope.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -67,4 +68,5 @@ export const STEPS = [
|
||||
transform,
|
||||
font,
|
||||
particles,
|
||||
scope,
|
||||
];
|
||||
Reference in new issue
Block a user