A shared value belongs to no behavior, so it becomes an asset of its own (#91)
game-maker / suite (push) Failing after 8m59s
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:
1 parent
d1fcfe8b81
commit
8143130ed2
37 files changed
+679
-419
No files matched your search
@@ -30,6 +30,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'text' };
|
||||
case 'behavior':
|
||||
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
|
||||
case 'variables':
|
||||
return { ...base, type: 'variables', variables: [] };
|
||||
case 'collider':
|
||||
return { ...base, type: 'collider' };
|
||||
case 'container':
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BehaviorAsset } from '../model/Asset';
|
||||
import type { BehaviorAsset, VariablesAsset } from '../model/Asset';
|
||||
import type { Project } from '../model/Project';
|
||||
|
||||
function firstFree(taken: ReadonlySet<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. */
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
OBJECT_BEHAVIOR_PARAM,
|
||||
propertyTargetOf,
|
||||
RUN_BEHAVIOR,
|
||||
variablesSourceOf,
|
||||
} from '../graph';
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
@@ -355,7 +356,11 @@ export function renameVariablePorts(project: Project, behaviorId: string, from:
|
||||
}
|
||||
|
||||
function namesVariablesOf(node: GraphNode, graphOwner: string, behaviorId: string): boolean {
|
||||
if (declaresVariablePorts(node.type)) return graphOwner === behaviorId;
|
||||
if (declaresVariablePorts(node.type)) {
|
||||
// A node naming a Variables asset follows that asset wherever it sits, not the graph around it.
|
||||
const source = variablesSourceOf(node.params);
|
||||
return source ? source === behaviorId : graphOwner === behaviorId;
|
||||
}
|
||||
return (
|
||||
declaresPropertyPorts(node.type) &&
|
||||
propertyTargetOf(node.params) === 'behavior' &&
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Lookups that throw a `CommandError` when the target does not exist. */
|
||||
import { findNode, type Graph, type GraphNode } from '../graph/Graph';
|
||||
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
||||
import type { Asset, BehaviorAsset, StateMachineAsset } from '../model/Asset';
|
||||
import type { Asset, BehaviorAsset, StateMachineAsset, VariablesAsset } from '../model/Asset';
|
||||
import { findObject, type ObjectTree } from '../model/hierarchy';
|
||||
import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
|
||||
import {
|
||||
@@ -71,6 +71,14 @@ export function requireBehavior(project: Project, assetId: string): BehaviorAsse
|
||||
return asset;
|
||||
}
|
||||
|
||||
/** An asset that declares variables: a behavior, or a Variables asset shared by the whole run. */
|
||||
export function requireVariableOwner(project: Project, assetId: string): BehaviorAsset | VariablesAsset {
|
||||
const asset = findAsset(project, assetId);
|
||||
if (asset?.type !== 'behavior' && asset?.type !== 'variables')
|
||||
throw new CommandError(`Variable owner ${assetId} not found`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
export function requireStateMachine(project: Project, assetId: string): StateMachineAsset {
|
||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||
if (!asset) throw new CommandError(`State machine ${assetId} not found`);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '../model/Properties';
|
||||
import { overrideSites } from '../model/instance';
|
||||
import type { Project } from '../model/Project';
|
||||
import { type Scope, scopeOf, setScope } from '../model/Scope';
|
||||
import type { VariableKind } from '../properties/kinds';
|
||||
import {
|
||||
emptyFieldValue,
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { renameVariablePorts } from './portCommands';
|
||||
import { requireBehavior } from './require';
|
||||
import { requireVariableOwner } from './require';
|
||||
|
||||
/** The variable's default value is written to `props` as well. */
|
||||
export class AddVariableCommand implements Command {
|
||||
@@ -37,7 +36,7 @@ export class AddVariableCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
const asset = requireVariableOwner(project, this.assetId);
|
||||
if (!isValidVariableName(this.def.key)) throw new CommandError(INVALID_NAME);
|
||||
if (asset.variables.some((v) => v.key === this.def.key))
|
||||
throw new CommandError(`Variable "${this.def.key}" already exists`);
|
||||
@@ -46,7 +45,7 @@ export class AddVariableCommand implements Command {
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
const asset = requireVariableOwner(project, this.assetId);
|
||||
asset.variables = asset.variables.filter((v) => v.key !== this.def.key);
|
||||
delete asset.props[this.def.key];
|
||||
}
|
||||
@@ -100,7 +99,7 @@ export class RemoveVariableCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
const asset = requireVariableOwner(project, this.assetId);
|
||||
const def = removeFirst(asset.variables, (v) => v.key === this.key);
|
||||
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
|
||||
this.removed = { def, value: asset.props[this.key] };
|
||||
@@ -110,46 +109,13 @@ export class RemoveVariableCommand implements Command {
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
const asset = requireVariableOwner(project, this.assetId);
|
||||
restoreAt(asset.variables, [this.removed.def]);
|
||||
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
|
||||
restoreOverrides(project, this.assetId, this.key, this.overrides);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A shared variable has no per-instance value, so going `global` takes the deviations placed objects
|
||||
* kept for it. Undo gives them back.
|
||||
*/
|
||||
export class SetVariableScopeCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: Scope = 'local';
|
||||
private overrides: TakenOverride[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly scope: Scope,
|
||||
) {
|
||||
this.label = `Make variable ${key} ${scope}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = variableOf(project, this.assetId, this.key);
|
||||
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
|
||||
this.previous = scopeOf(def);
|
||||
setScope(def, this.scope);
|
||||
this.overrides = this.scope === 'global' ? takeOverrides(project, this.assetId, this.key) : [];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const def = variableOf(project, this.assetId, this.key);
|
||||
if (!def) return;
|
||||
setScope(def, this.previous);
|
||||
restoreOverrides(project, this.assetId, this.key, this.overrides);
|
||||
}
|
||||
}
|
||||
|
||||
/** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */
|
||||
export class RenameVariableCommand implements Command {
|
||||
readonly label: string;
|
||||
@@ -171,7 +137,7 @@ export class RenameVariableCommand implements Command {
|
||||
}
|
||||
|
||||
private rename(project: Project, from: string, to: string): void {
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
const asset = requireVariableOwner(project, this.assetId);
|
||||
if (from === to) return;
|
||||
if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME);
|
||||
const def = asset.variables.find((v) => v.key === from);
|
||||
@@ -202,7 +168,7 @@ interface FieldOwner {
|
||||
}
|
||||
|
||||
function variableOf(project: Project, assetId: string, key: string): PropertyDef | undefined {
|
||||
return requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
||||
return requireVariableOwner(project, assetId).variables.find((v) => v.key === key);
|
||||
}
|
||||
|
||||
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
|
||||
@@ -244,7 +210,7 @@ interface ValueHolder {
|
||||
}
|
||||
|
||||
function valueHolders(project: Project, assetId: string, key: string): ValueHolder[] {
|
||||
const asset = requireBehavior(project, assetId);
|
||||
const asset = requireVariableOwner(project, assetId);
|
||||
const def = asset.variables.find((v) => v.key === key);
|
||||
const holders: ValueHolder[] = [];
|
||||
if (def)
|
||||
|
||||
@@ -70,12 +70,12 @@ export interface PointerState {
|
||||
}
|
||||
|
||||
/**
|
||||
* What `global` declarations share for the length of one run: one value per variable name, one
|
||||
* current state per machine. A key that is not in here is not shared – which is what lets a behavior
|
||||
* declaring the same name for itself keep its own value (see `graph/sharedState.ts`).
|
||||
* What is shared for the length of one run: the values of each Variables asset, keyed by its id,
|
||||
* and the current state of each `global` machine. Two Variables assets may declare the same name
|
||||
* without meeting, since a node names the asset it reads (see `graph/sharedState.ts`).
|
||||
*/
|
||||
export interface SharedState {
|
||||
variables: PropertyBag;
|
||||
variables: Record<string, PropertyBag>;
|
||||
states: Record<string, string>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { textOf } from '../model/Properties';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { isTaggedKind } from '../properties/kinds';
|
||||
import { flatDef, isValidFieldName } from '../properties/PropertySchema';
|
||||
import { flatDef, isValidFieldName, type PropertyDef } from '../properties/PropertySchema';
|
||||
import { type DeclaredPort, declaredPortType, type Graph, type GraphEdge, type GraphNode } from './Graph';
|
||||
import type { GraphLookup } from './lookup';
|
||||
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
||||
@@ -12,6 +12,20 @@ export const VARIABLES = 'Variables';
|
||||
export const PROPERTIES = 'Properties';
|
||||
|
||||
export const BEHAVIOR_PARAM = 'behavior';
|
||||
export const VARIABLES_PARAM = 'variablesAsset';
|
||||
|
||||
/** Whose declarations a `Variables` node reads; empty is the behavior the node sits in. */
|
||||
export const VARIABLES_SETTING: PropertyDef = {
|
||||
key: VARIABLES_PARAM,
|
||||
label: 'Variables',
|
||||
kind: 'asset',
|
||||
default: '',
|
||||
assetTypes: ['variables'],
|
||||
};
|
||||
|
||||
export function variablesSourceOf(params: PropertyBag): string {
|
||||
return textOf(params[VARIABLES_PARAM] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nodes whose declared ports are not names of their own but variables of the behavior around them.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { collapsedTitle, portDefOf, VARIABLES } from '../../nested';
|
||||
import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested';
|
||||
import { type NodeDef, withInlineDefault } from '../../NodeRegistry';
|
||||
import { readableVariables, variableBag } from '../../sharedState';
|
||||
import { variableBag } from '../../sharedState';
|
||||
import { writeTyped } from '../../values';
|
||||
import { exec } from '../shared';
|
||||
|
||||
@@ -13,11 +13,11 @@ export const Variables: NodeDef = {
|
||||
label: 'Variables',
|
||||
category: 'variable',
|
||||
description:
|
||||
"Reads this behavior's variables at any time. Those switched to Set are written together when " +
|
||||
'the flow arrives, and a read after that sees the new value.',
|
||||
"Reads variables at any time: this behavior's own, or those of a Variables asset, which every " +
|
||||
'graph pointing at it shares. Those switched to Set are written when the flow arrives.',
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
params: [],
|
||||
params: [VARIABLES_SETTING],
|
||||
declaresPorts: 'both',
|
||||
declaredPort: (port) => withInlineDefault(portDefOf(port)),
|
||||
derivePorts: ({ node }) => {
|
||||
@@ -25,10 +25,10 @@ export const Variables: NodeDef = {
|
||||
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
|
||||
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape;
|
||||
},
|
||||
read: (ctx) => readableVariables(ctx),
|
||||
run: (ctx, inputs, _params, def) => {
|
||||
for (const port of def.inputs)
|
||||
if (port.type !== 'exec') writeTyped(variableBag(ctx, port.key), port.key, inputs[port.key]);
|
||||
read: (ctx, params) => variableBag(ctx, variablesSourceOf(params)),
|
||||
run: (ctx, inputs, params, def) => {
|
||||
const bag = variableBag(ctx, variablesSourceOf(params));
|
||||
for (const port of def.inputs) if (port.type !== 'exec') writeTyped(bag, port.key, inputs[port.key]);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -1,115 +1,70 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { isGlobal } from '../model/Scope';
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import type { NodeContext, SharedState } from './NodeContext';
|
||||
|
||||
export function emptySharedState(): SharedState {
|
||||
return { variables: {}, states: {} };
|
||||
}
|
||||
|
||||
/** What every Variables asset declares right now: the value set on the asset, else the default. */
|
||||
export function declaredVariables(project: Project): Record<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
|
||||
* even before the behavior declaring it is placed. Two behaviors declaring the same name mean the
|
||||
* same variable – the first declaration sets the starting value (see `globalConflicts`).
|
||||
* Built once per run, from the declarations rather than from any instance: a Variables asset holds
|
||||
* its values whether or not a graph ever reads them, and nothing is written back when the run ends.
|
||||
*/
|
||||
export function buildSharedState(project: Project): SharedState {
|
||||
const shared = emptySharedState();
|
||||
for (const asset of project.assets) {
|
||||
if (asset.type === 'behavior') {
|
||||
for (const def of asset.variables)
|
||||
if (isGlobal(def) && !(def.key in shared.variables))
|
||||
shared.variables[def.key] = asset.props[def.key] ?? def.default;
|
||||
} else if (asset.type === 'statemachine' && isGlobal(asset)) {
|
||||
shared.states[asset.id] = '';
|
||||
}
|
||||
}
|
||||
const shared: SharedState = { variables: declaredVariables(project), states: {} };
|
||||
for (const asset of project.assets)
|
||||
if (asset.type === 'statemachine' && isGlobal(asset)) shared.states[asset.id] = '';
|
||||
return shared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where two behaviors disagree about the same global name. A different kind is an error – one of the
|
||||
* two would silently read a value of a type it never declared – while a different starting value is
|
||||
* only a warning, since the first declaration decides and the run is well defined either way.
|
||||
* Moves edits made to the declarations during a run into the values it shares. A key is taken over
|
||||
* only where the document changed it since `seen`, which is what leaves a value a graph has moved
|
||||
* on alone – the panel can show the running value and set it without the two fighting.
|
||||
*/
|
||||
export interface GlobalConflict {
|
||||
key: string;
|
||||
severity: 'error' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function globalConflicts(project: Project): GlobalConflict[] {
|
||||
const first = new Map<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.`,
|
||||
});
|
||||
}
|
||||
export function adoptDeclarations(
|
||||
live: Record<string, PropertyBag>,
|
||||
seen: Record<string, PropertyBag>,
|
||||
now: Record<string, PropertyBag>,
|
||||
): void {
|
||||
for (const [assetId, declared] of Object.entries(now)) {
|
||||
const bag = live[assetId];
|
||||
if (!bag) {
|
||||
live[assetId] = { ...declared };
|
||||
continue;
|
||||
}
|
||||
const before = seen[assetId] ?? {};
|
||||
for (const [key, value] of Object.entries(declared))
|
||||
if (!(key in bag) || before[key] !== value) bag[key] = value;
|
||||
for (const key of Object.keys(bag)) if (!(key in declared)) delete bag[key];
|
||||
}
|
||||
return conflicts;
|
||||
for (const assetId of Object.keys(live)) if (!(assetId in now)) delete live[assetId];
|
||||
}
|
||||
|
||||
/** Structs and lists compare by their stored shape, which is what the project file holds anyway. */
|
||||
function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
|
||||
return a === b || JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
}
|
||||
|
||||
function display(value: PropertyValue | undefined): string {
|
||||
return typeof value === 'string' ? `"${value}"` : JSON.stringify(value ?? null);
|
||||
}
|
||||
|
||||
/** The bag a variable is written to: its own when the behavior declares it, else the shared one. */
|
||||
export function variableBag(ctx: NodeContext, key: string): PropertyBag {
|
||||
if (key in ctx.variables) return ctx.variables;
|
||||
return key in ctx.shared.variables ? ctx.shared.variables : ctx.variables;
|
||||
}
|
||||
|
||||
/** Everything the behavior can read under one name, its own declaration winning over a shared one. */
|
||||
export function readableVariables(ctx: NodeContext): PropertyBag {
|
||||
const shared = ctx.shared.variables;
|
||||
return isEmpty(shared) ? ctx.variables : { ...shared, ...ctx.variables };
|
||||
/**
|
||||
* The values a `Variables` node reads and writes: those of the asset it names, else the behavior's
|
||||
* own. A name that no asset answers to is a fresh bag, so a node left pointing at a deleted asset
|
||||
* writes nowhere instead of into the behavior it sits in.
|
||||
*/
|
||||
export function variableBag(ctx: NodeContext, source: string): PropertyBag {
|
||||
if (!source) return ctx.variables;
|
||||
return ctx.shared.variables[source] ?? {};
|
||||
}
|
||||
|
||||
/** Where a machine's current state is kept: shared when the machine is `global`, else per instance. */
|
||||
export function stateBag(ctx: NodeContext, machineId: string): Record<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;
|
||||
}
|
||||
@@ -36,6 +36,16 @@ export interface BehaviorAsset extends AssetBase {
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declarations shared by the whole run: a `Variables` node naming this asset reads and writes one
|
||||
* value per key, however many graphs point at it. Nothing is placed, so nothing overrides it –
|
||||
* `props` holds the starting values, as a behavior's does for its own variables.
|
||||
*/
|
||||
export interface VariablesAsset extends AssetBase {
|
||||
type: 'variables';
|
||||
variables: PropertyDef[];
|
||||
}
|
||||
|
||||
/** Placed as a child of the object it belongs to; its position is the offset from that body. */
|
||||
export interface ColliderAsset extends AssetBase {
|
||||
type: 'collider';
|
||||
@@ -129,6 +139,7 @@ export type Asset =
|
||||
| TextAsset
|
||||
| SceneAsset
|
||||
| BehaviorAsset
|
||||
| VariablesAsset
|
||||
| ColliderAsset
|
||||
| ContainerAsset
|
||||
| TextureAsset
|
||||
|
||||
@@ -4,6 +4,7 @@ export const ASSET_TYPES = [
|
||||
'sprite',
|
||||
'text',
|
||||
'behavior',
|
||||
'variables',
|
||||
'collider',
|
||||
'container',
|
||||
'texture',
|
||||
@@ -22,6 +23,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
sprite: 'Sprite',
|
||||
text: 'Text',
|
||||
behavior: 'Behavior',
|
||||
variables: 'Variables',
|
||||
collider: 'Collider',
|
||||
container: 'Container',
|
||||
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`,
|
||||
* as a shader is worn through `shader` and a font through `font`. A state machine is not either:
|
||||
* a behavior refers to it by id.
|
||||
* a behavior refers to it by id, and a Variables asset is declarations a graph reads by id.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
'scene' | 'texture' | 'animation' | 'statemachine' | 'shader' | 'font'
|
||||
'scene' | 'texture' | 'animation' | 'statemachine' | 'shader' | 'font' | 'variables'
|
||||
>;
|
||||
|
||||
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
@@ -49,6 +51,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'statemachine',
|
||||
'shader',
|
||||
'font',
|
||||
'variables',
|
||||
]);
|
||||
|
||||
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Whether a declaration keeps a value per behavior instance or shares one for the whole run.
|
||||
* Everything was `local` before scopes existed, so a missing field means `local` and old projects
|
||||
* load unchanged.
|
||||
* Whether a state machine keeps its current state per behavior instance or shares one for the whole
|
||||
* run. Everything was `local` before scopes existed, so a missing field means `local` and old
|
||||
* projects load unchanged. Variables are shared by living on a Variables asset instead (#91).
|
||||
*/
|
||||
export type Scope = 'local' | 'global';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { GraphLookup } from '../graph/lookup';
|
||||
import { withoutGlobals } from '../graph/sharedState';
|
||||
import { statesOf, transitionsOf } from '../graph/stateMachine';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
@@ -13,8 +12,7 @@ export function graphLookup(project: Project): GraphLookup {
|
||||
behavior: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'behavior');
|
||||
if (!asset) return null;
|
||||
const defaults = withoutGlobals(resolveAssetProps(project, asset), asset);
|
||||
return { name: asset.name, graph: asset.graph, defaults };
|
||||
return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(project, asset) };
|
||||
},
|
||||
machine: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
type VariableKind,
|
||||
} from './kinds';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { isGlobal, type Scope } from '../model/Scope';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { flatListOf, listItemOf } from './listItems';
|
||||
import { FONT_KEY } from './fonts';
|
||||
@@ -58,14 +57,10 @@ type Extras<K extends 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>; scope?: Scope } & Extras<P>;
|
||||
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P> } & Extras<P>;
|
||||
}[K];
|
||||
|
||||
/**
|
||||
* The inspector is generated from these entries, so a new property is one line in a schema below.
|
||||
* `scope` is only ever set on a behavior's own variables; the fixed schemas below leave it off, which
|
||||
* reads as `local`.
|
||||
*/
|
||||
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
|
||||
export type PropertyDef =
|
||||
| FlatDef
|
||||
/**
|
||||
@@ -74,7 +69,7 @@ export type PropertyDef =
|
||||
* item kind: a list is stored in the project file, and an object is a live handle that no reload
|
||||
* survives.
|
||||
*/
|
||||
| { key: string; label: string; kind: 'list'; default: ListValue; scope?: Scope }
|
||||
| { key: string; label: string; kind: 'list'; default: ListValue }
|
||||
/**
|
||||
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
|
||||
* keep its shape while single fields are written (`mergeStruct`).
|
||||
@@ -85,7 +80,6 @@ export type PropertyDef =
|
||||
kind: 'struct';
|
||||
default: StructValue;
|
||||
fields: readonly StructFieldDef[];
|
||||
scope?: Scope;
|
||||
};
|
||||
|
||||
export interface PrimitiveFieldDef {
|
||||
@@ -220,6 +214,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
],
|
||||
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
|
||||
behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }],
|
||||
// Every row is declared on the asset itself – see `schemaForBag`.
|
||||
variables: [],
|
||||
// The states are the asset's graph (see StateMachineAsset), not a property.
|
||||
statemachine: [],
|
||||
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
|
||||
@@ -311,19 +307,16 @@ export function schemaForAsset(project: Project, asset: Asset): PropertySchema {
|
||||
return schemaForBag(project, asset, asset.props);
|
||||
}
|
||||
|
||||
/**
|
||||
* A placed object's rows: an object overriding its shader has that shader's parameters. A global
|
||||
* variable is not among them – it belongs to the run, not to this object, so it shows no row here
|
||||
* and never reaches `entity.props` (which is what lets the shared bag answer for it instead).
|
||||
*/
|
||||
/** A placed object's rows: an object overriding its shader has that shader's parameters. */
|
||||
export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema {
|
||||
const shader = object.overrides[SHADER_KEY];
|
||||
const rows = schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader });
|
||||
return rows.some(isGlobal) ? rows.filter((def) => !isGlobal(def)) : rows;
|
||||
return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader });
|
||||
}
|
||||
|
||||
function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {
|
||||
if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables];
|
||||
// Nothing but declarations: the starting values live in `props` under the very same keys.
|
||||
if (asset.type === 'variables') return asset.variables;
|
||||
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
|
||||
// Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up.
|
||||
if (asset.type === 'particles') {
|
||||
|
||||
@@ -11,7 +11,10 @@ import { deviatesFromAsset, isModified } from './resolve';
|
||||
* default is declared on the variable itself, so the asset value cannot differ from it.
|
||||
*/
|
||||
export function markedAssetSchema(project: Project, asset: Asset): PropertySchema {
|
||||
return asset.type === 'behavior' ? schemaFor('behavior') : schemaForAsset(project, asset);
|
||||
// Declared variables are drawn by the inspector's own section, not as asset rows.
|
||||
return asset.type === 'behavior' || asset.type === 'variables'
|
||||
? schemaFor(asset.type)
|
||||
: schemaForAsset(project, asset);
|
||||
}
|
||||
|
||||
/** Whether any asset property the inspector would mark gold differs from its default. */
|
||||
|
||||
@@ -73,12 +73,12 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
|
||||
rawProps.set(a.id, asRecord(a.props));
|
||||
if (a.type === 'sprite' || a.type === 'particles') wearers.set(a.id, a.type);
|
||||
if (a.type === 'behavior') {
|
||||
if (a.type === 'behavior' || a.type === 'variables') {
|
||||
const vars = normalizeVariables(a.variables);
|
||||
variables.set(a.id, vars);
|
||||
// The full schema, not just the common rows: this is what pass 2 coerces `props` against, and
|
||||
// a behavior's own `runInEditor` would be dropped as undeclared.
|
||||
schemas.set(a.id, [...schemaFor('behavior'), ...vars]);
|
||||
schemas.set(a.id, [...schemaFor(a.type), ...vars]);
|
||||
} else if (a.type === 'particles') {
|
||||
// A source's rows end with its own graph's parameters, so the graph is needed before the
|
||||
// overrides of the objects placed from it can be coerced.
|
||||
@@ -130,6 +130,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
variables: variables.get(id) ?? [],
|
||||
graph: normalizeGraph(a.graph),
|
||||
});
|
||||
} else if (type === 'variables') {
|
||||
assets.push({ id, type, name, props, variables: variables.get(id) ?? [] });
|
||||
} else if (type === 'statemachine') {
|
||||
const machine: StateMachineAsset = {
|
||||
id,
|
||||
@@ -260,8 +262,6 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
const fields = normalizeStructFields(v.fields);
|
||||
const def = variableDef(key, kind, undefined, fields);
|
||||
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
|
||||
// Written only when shared, so every project made before scopes existed saves back unchanged.
|
||||
if (readScope(v.scope) === 'global') def.scope = 'global';
|
||||
defs.push(def);
|
||||
}
|
||||
return defs;
|
||||
|
||||
Reference in new issue
Block a user