A state change is something the graph can react to, so the machine announces it (#53, #54, #55, #57)
game-maker / suite (push) Successful in 10m58s
game-maker / suite (push) Successful in 10m58s
Four issues that all reach for the same line: how the current state is read and written. Splitting them into four commits would mean four states of the tree that do not compile, so they arrive together. #55 start state — the machine asset carries an optional `start` holding a State node's id. Right-click a state to set or remove it, a ring in the node's title says which one it is. A dead id (the state was deleted) reads as "no start", so nothing has to clean up after a deletion. #53 entered/exited — `On state entered` and `On state exited`, one exec output per state. Every change now goes through a single `setState` that reports it; BehaviorSystem delivers it after the exec flow that caused it, `exited` of the old before `entered` of the new, round after round until the queue is empty, with a cap that stops a ping-pong graph with an endless-loop error. A local machine tells the instance that switched, a global one tells every instance holding a matching event node. The start state fires `entered` too: per instance for a local machine, once per run for a global one. #57 reading — `Current state` with `name` for showing and `id` for comparing, `Is in state` with one boolean per state. Neither has a `run`, so `read` learned `def` and `lookup`, and the catalog test knows this third kind of node. #54 seeing it — during a run the state machine tab marks every state some instance is holding faintly, and the one held by the selected scene object's instance strongly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc57f61c91
commit
7babf88636
30 files changed
+906
-44
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
import { createEmptyGraph } from '../graph/Graph';
|
import { createEmptyGraph } from '../graph/Graph';
|
||||||
import { newId } from '../ids';
|
import { newId } from '../ids';
|
||||||
import type { Asset, FontAsset, TextureAsset } from '../model/Asset';
|
import type { Asset, FontAsset, StateMachineAsset, TextureAsset } from '../model/Asset';
|
||||||
import type { AssetType } from '../model/AssetTypes';
|
import type { AssetType } from '../model/AssetTypes';
|
||||||
import { isFontFile } from '../model/font';
|
import { isFontFile } from '../model/font';
|
||||||
import { removeSubtrees } from '../model/hierarchy';
|
import { removeSubtrees } from '../model/hierarchy';
|
||||||
@@ -241,6 +241,39 @@ export class SetMachineScopeCommand implements Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which state a machine starts in, as a state node's id – `''` takes the mark off again, and the
|
||||||
|
* machine is back to starting in no state. Only one state can be the start because there is only
|
||||||
|
* one field to hold it.
|
||||||
|
*/
|
||||||
|
export class SetMachineStartCommand implements Command {
|
||||||
|
readonly label: string;
|
||||||
|
private previous = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly start: string,
|
||||||
|
) {
|
||||||
|
this.label = start ? 'Set start state' : 'Remove start state';
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const asset = requireStateMachine(project, this.assetId);
|
||||||
|
this.previous = asset.start ?? '';
|
||||||
|
this.write(asset, this.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
this.write(requireStateMachine(project, this.assetId), this.previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kept out of the document when there is none, the way a `local` scope is. */
|
||||||
|
private write(asset: StateMachineAsset, start: string): void {
|
||||||
|
if (start) asset.start = start;
|
||||||
|
else delete asset.start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
|
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
|
||||||
protected constructor(
|
protected constructor(
|
||||||
private readonly assetId: string,
|
private readonly assetId: string,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type PortDef,
|
type PortDef,
|
||||||
type RepeatResult,
|
type RepeatResult,
|
||||||
} from './NodeRegistry';
|
} from './NodeRegistry';
|
||||||
|
import { machineIdOf } from './stateMachine';
|
||||||
import type { Value } from './values';
|
import type { Value } from './values';
|
||||||
|
|
||||||
/** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */
|
/** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */
|
||||||
@@ -72,6 +73,8 @@ export class GraphInterpreter {
|
|||||||
ctx: NodeContext,
|
ctx: NodeContext,
|
||||||
outputs: Record<string, Value> = {},
|
outputs: Record<string, Value> = {},
|
||||||
filter?: (node: GraphNode) => boolean,
|
filter?: (node: GraphNode) => boolean,
|
||||||
|
/** Which of the event node's exec outputs the pulse leaves through – a state event names one. */
|
||||||
|
port = 'exec',
|
||||||
): number {
|
): number {
|
||||||
let fired = 0;
|
let fired = 0;
|
||||||
for (const node of this.eventNodes(type)) {
|
for (const node of this.eventNodes(type)) {
|
||||||
@@ -82,12 +85,28 @@ export class GraphInterpreter {
|
|||||||
this.frame.outputCache.set(node.id, { ...outputs });
|
this.frame.outputCache.set(node.id, { ...outputs });
|
||||||
this.trace?.onNode(node.id);
|
this.trace?.onNode(node.id);
|
||||||
this.record(node.id, outputs);
|
this.record(node.id, outputs);
|
||||||
this.follow(node, 'exec', ctx, 0);
|
this.follow(node, port, ctx, 0);
|
||||||
fired++;
|
fired++;
|
||||||
}
|
}
|
||||||
return fired;
|
return fired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `On state entered` / `On state exited` for one move: only the nodes watching that machine, and
|
||||||
|
* out of the one output the state stands for. A node whose machine input carries a running value
|
||||||
|
* rather than a pick has no state outputs at all, so it is never among them.
|
||||||
|
*/
|
||||||
|
fireStateEvent(type: string, ctx: NodeContext, machineId: string, stateId: string): number {
|
||||||
|
if (!machineId || !stateId) return 0;
|
||||||
|
return this.fire(
|
||||||
|
type,
|
||||||
|
ctx,
|
||||||
|
{},
|
||||||
|
(node) => machineIdOf({ node, graph: this.graph, lookup: this.lookup }) === machineId,
|
||||||
|
stateId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void {
|
private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void {
|
||||||
for (const edge of edgesOutOf(this.frame.graph, node.id, port)) {
|
for (const edge of edgesOutOf(this.frame.graph, node.id, port)) {
|
||||||
const target = this.node(edge.to.node);
|
const target = this.node(edge.to.node);
|
||||||
@@ -135,8 +154,22 @@ export class GraphInterpreter {
|
|||||||
this.calledBehaviors.add(call.behaviorId);
|
this.calledBehaviors.add(call.behaviorId);
|
||||||
try {
|
try {
|
||||||
this.trace?.onNode(start.id);
|
this.trace?.onNode(start.id);
|
||||||
// Fresh variables and fresh states alike: a call keeps nothing, and sees nothing of the caller's.
|
// Fresh variables and fresh states alike: a call keeps nothing, and sees nothing of the
|
||||||
this.follow(start, 'exec', { ...ctx, variables: { ...program.defaults }, states: {} }, depth + 1);
|
// caller's. What it does to a local machine therefore dies with the call and is not reported;
|
||||||
|
// a `global` machine is the run's, so moving that one is a move like any other.
|
||||||
|
this.follow(
|
||||||
|
start,
|
||||||
|
'exec',
|
||||||
|
{
|
||||||
|
...ctx,
|
||||||
|
variables: { ...program.defaults },
|
||||||
|
states: {},
|
||||||
|
stateChanged: (machineId, from, to) => {
|
||||||
|
if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
depth + 1,
|
||||||
|
);
|
||||||
const end = terminalNode(program.graph, BEHAVIOR_END);
|
const end = terminalNode(program.graph, BEHAVIOR_END);
|
||||||
return (end && this.frame.outputCache.get(end.id)) ?? {};
|
return (end && this.frame.outputCache.get(end.id)) ?? {};
|
||||||
} finally {
|
} finally {
|
||||||
@@ -211,7 +244,7 @@ export class GraphInterpreter {
|
|||||||
return found ? this.inputValue(node, found, ctx) : null;
|
return found ? this.inputValue(node, found, ctx) : null;
|
||||||
};
|
};
|
||||||
// `read` may hand back a whole bag (the Variables node returns the object's variables, live).
|
// `read` may hand back a whole bag (the Variables node returns the object's variables, live).
|
||||||
const all = def.read?.(ctx, node.params, input) ?? {};
|
const all = def.read?.(ctx, node.params, input, def, this.lookup) ?? {};
|
||||||
const own: Record<string, Value> = {};
|
const own: Record<string, Value> = {};
|
||||||
for (const port of def.outputs) if (port.key in all) own[port.key] = all[port.key] ?? null;
|
for (const port of def.outputs) if (port.key in all) own[port.key] = all[port.key] ?? null;
|
||||||
return own;
|
return own;
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ export interface NodeContext {
|
|||||||
*/
|
*/
|
||||||
states: Record<string, string>;
|
states: Record<string, string>;
|
||||||
shared: SharedState;
|
shared: SharedState;
|
||||||
|
/**
|
||||||
|
* A machine moved from one state to another – what `On state exited` / `On state entered` are
|
||||||
|
* fired from. Reported rather than fired on the spot: the events belong after the exec flow that
|
||||||
|
* caused them, never in the middle of it (see `BehaviorSystem`).
|
||||||
|
*/
|
||||||
|
stateChanged(machineId: string, from: string, to: string): void;
|
||||||
/** Seconds since the world started and since the last tick. */
|
/** Seconds since the world started and since the last tick. */
|
||||||
time: number;
|
time: number;
|
||||||
dt: number;
|
dt: number;
|
||||||
|
|||||||
@@ -106,8 +106,16 @@ export interface NodeDef {
|
|||||||
* Outputs readable at any moment, even while the node's exec input has not fired in this pulse.
|
* Outputs readable at any moment, even while the node's exec input has not fired in this pulse.
|
||||||
* Never cached, so a read after a write sees the new value. `input` pulls one input on demand:
|
* Never cached, so a read after a write sees the new value. `input` pulls one input on demand:
|
||||||
* pulling all of them would loop through an edge that feeds the node's own output back in.
|
* pulling all of them would loop through an edge that feeds the node's own output back in.
|
||||||
|
* `def` and `lookup` are as in `run`: the definition resolved for this very node, and the project
|
||||||
|
* around it – what a reader with one output per state needs to answer for all of them.
|
||||||
*/
|
*/
|
||||||
read?(ctx: NodeContext, params: PropertyBag, input: (key: string) => Value): Record<string, Value>;
|
read?(
|
||||||
|
ctx: NodeContext,
|
||||||
|
params: PropertyBag,
|
||||||
|
input: (key: string) => Value,
|
||||||
|
def: NodeDef,
|
||||||
|
lookup: GraphLookup,
|
||||||
|
): Record<string, Value>;
|
||||||
/**
|
/**
|
||||||
* Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at
|
* Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at
|
||||||
* hand, the only way a node with derived ports knows how many it has. `entry` is the exec input
|
* hand, the only way a node with derived ports knows how many it has. `entry` is the exec input
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface StateMachine {
|
|||||||
name: string;
|
name: string;
|
||||||
states: MachineState[];
|
states: MachineState[];
|
||||||
transitions: MachineTransition[];
|
transitions: MachineTransition[];
|
||||||
|
/** Id of the state every instance begins in, empty when the machine has no start (see #55). */
|
||||||
|
start: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
|
import { currentState } from '../../sharedState';
|
||||||
|
import { machinePort } from '../../stateMachine';
|
||||||
|
import { asString } from '../../values';
|
||||||
|
import { str } from '../shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data side of `State branch`: two outputs, because the two uses pull apart. `name` is what
|
||||||
|
* `Join` and a `Text` want to show, `id` is what a comparison should hold – a rename moves the
|
||||||
|
* name, never the id. Both are empty while the machine is in no state.
|
||||||
|
*/
|
||||||
|
export const CurrentState: NodeDef = {
|
||||||
|
type: 'CurrentState',
|
||||||
|
label: 'Current state',
|
||||||
|
category: 'data',
|
||||||
|
description: 'The state the machine is in: its name to show, its id to compare. Empty for no state.',
|
||||||
|
inputs: [machinePort()],
|
||||||
|
outputs: [str('name'), str('id')],
|
||||||
|
params: [],
|
||||||
|
read: (ctx, _params, input, _def, lookup) => {
|
||||||
|
const machine = asString(input('machine'));
|
||||||
|
const id = currentState(ctx, machine);
|
||||||
|
const name = (id && lookup.machine(machine)?.states.find((s) => s.id === id)?.name) || '';
|
||||||
|
return { name, id };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
|
import { currentState } from '../../sharedState';
|
||||||
|
import { machineOf, machinePort, statePorts } from '../../stateMachine';
|
||||||
|
import { asString, type Value } from '../../values';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One boolean per state, all false while the machine is in none – the data twin of `State branch`,
|
||||||
|
* for the places a flow cannot go: `Branch`, `And`, a property. Every output is answered, so a
|
||||||
|
* state nobody is in reads `false` rather than nothing at all.
|
||||||
|
*/
|
||||||
|
export const IsInState: NodeDef = {
|
||||||
|
type: 'IsInState',
|
||||||
|
label: 'Is in state',
|
||||||
|
category: 'data',
|
||||||
|
description: 'True on the output of the state the machine is in, false on every other.',
|
||||||
|
inputs: [machinePort()],
|
||||||
|
outputs: [],
|
||||||
|
params: [],
|
||||||
|
derivePorts: (source) => ({ outputs: statePorts(machineOf(source), 'boolean') }),
|
||||||
|
read: (ctx, _params, input, def) => {
|
||||||
|
const current = currentState(ctx, asString(input('machine')));
|
||||||
|
const values: Record<string, Value> = {};
|
||||||
|
for (const port of def.outputs) values[port.key] = port.key === current;
|
||||||
|
return values;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
|
import { machineOf, machinePort, statePorts } from '../../stateMachine';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One exec output per state, like `State branch` – the pulse leaves through the state that was
|
||||||
|
* entered. Fired after the flow that caused the move, never inside it. A `global` machine is
|
||||||
|
* entered once for the whole run, and every instance watching it hears that one move.
|
||||||
|
*/
|
||||||
|
export const OnStateEntered: NodeDef = {
|
||||||
|
type: 'OnStateEntered',
|
||||||
|
label: 'On state entered',
|
||||||
|
category: 'event',
|
||||||
|
description:
|
||||||
|
"Runs when the machine enters a state, along that state's output. The start state counts as " +
|
||||||
|
'entered when the instance begins; entering the state the machine is already in does not.',
|
||||||
|
inputs: [machinePort()],
|
||||||
|
outputs: [],
|
||||||
|
params: [],
|
||||||
|
derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
|
import { machineOf, machinePort, statePorts } from '../../stateMachine';
|
||||||
|
|
||||||
|
/** The other half of the move: the pulse leaves through the state that was left, before `entered`. */
|
||||||
|
export const OnStateExited: NodeDef = {
|
||||||
|
type: 'OnStateExited',
|
||||||
|
label: 'On state exited',
|
||||||
|
category: 'event',
|
||||||
|
description:
|
||||||
|
"Runs when the machine leaves a state, along that state's output. A machine coming out of no " +
|
||||||
|
'state at all has nothing to leave, so nothing runs.',
|
||||||
|
inputs: [machinePort()],
|
||||||
|
outputs: [],
|
||||||
|
params: [],
|
||||||
|
derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }),
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { stateBag } from '../../sharedState';
|
import { setState } from '../../sharedState';
|
||||||
import { machineOf, machinePort, statePorts } from '../../stateMachine';
|
import { machineOf, machinePort, statePorts } from '../../stateMachine';
|
||||||
import { asString } from '../../values';
|
import { asString } from '../../values';
|
||||||
import { exec } from '../shared';
|
import { exec } from '../shared';
|
||||||
@@ -19,7 +19,7 @@ export const SetState: NodeDef = {
|
|||||||
run: (ctx, inputs, _params, def, entry) => {
|
run: (ctx, inputs, _params, def, entry) => {
|
||||||
const machine = asString(inputs.machine);
|
const machine = asString(inputs.machine);
|
||||||
if (machine && def.inputs.some((p) => p.type === 'exec' && p.key === entry))
|
if (machine && def.inputs.some((p) => p.type === 'exec' && p.key === entry))
|
||||||
stateBag(ctx, machine)[machine] = entry;
|
setState(ctx, machine, entry);
|
||||||
return { next: 'exec' };
|
return { next: 'exec' };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { stateBag } from '../../sharedState';
|
import { currentState } from '../../sharedState';
|
||||||
import { machineOf, machinePort, statePorts } from '../../stateMachine';
|
import { machineOf, machinePort, statePorts } from '../../stateMachine';
|
||||||
import { asString } from '../../values';
|
import { asString } from '../../values';
|
||||||
import { exec } from '../shared';
|
import { exec } from '../shared';
|
||||||
@@ -10,15 +10,14 @@ export const StateBranch: NodeDef = {
|
|||||||
label: 'State branch',
|
label: 'State branch',
|
||||||
category: 'flow',
|
category: 'flow',
|
||||||
description:
|
description:
|
||||||
'Continues along the output of the state the machine is in. Before "Set state" has run, the ' +
|
'Continues along the output of the state the machine is in. A machine with no start state is ' +
|
||||||
'machine is in no state and nothing continues.',
|
'in no state until "Set state" has run, and nothing continues.',
|
||||||
inputs: [exec(), machinePort()],
|
inputs: [exec(), machinePort()],
|
||||||
outputs: [],
|
outputs: [],
|
||||||
params: [],
|
params: [],
|
||||||
derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }),
|
derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }),
|
||||||
run: (ctx, inputs, _params, def) => {
|
run: (ctx, inputs, _params, def) => {
|
||||||
const machine = asString(inputs.machine);
|
const current = currentState(ctx, asString(inputs.machine));
|
||||||
const current = stateBag(ctx, machine)[machine];
|
|
||||||
return def.outputs.some((p) => p.key === current) ? { next: current } : {};
|
return def.outputs.some((p) => p.key === current) ? { next: current } : {};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { textOf } from '../../../model/Properties';
|
import { textOf } from '../../../model/Properties';
|
||||||
import { flatDef } from '../../../properties/PropertySchema';
|
import { flatDef } from '../../../properties/PropertySchema';
|
||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { stateBag } from '../../sharedState';
|
import { currentState, setState } from '../../sharedState';
|
||||||
import { machinePort, STATE_TRANSITION, TRANSITION_PARAM, transitionTo } from '../../stateMachine';
|
import { machinePort, STATE_TRANSITION, TRANSITION_PARAM, transitionTo } from '../../stateMachine';
|
||||||
import { asString } from '../../values';
|
import { asString } from '../../values';
|
||||||
import { exec } from '../shared';
|
import { exec } from '../shared';
|
||||||
@@ -19,12 +19,11 @@ export const StateTransition: NodeDef = {
|
|||||||
params: [flatDef(TRANSITION_PARAM, 'text', { label: 'Transition', value: '' })],
|
params: [flatDef(TRANSITION_PARAM, 'text', { label: 'Transition', value: '' })],
|
||||||
run: (ctx, inputs, params, _def, _entry, lookup) => {
|
run: (ctx, inputs, params, _def, _entry, lookup) => {
|
||||||
const id = asString(inputs.machine);
|
const id = asString(inputs.machine);
|
||||||
const states = stateBag(ctx, id);
|
const current = currentState(ctx, id);
|
||||||
const current = states[id] ?? '';
|
|
||||||
const machine = current ? lookup.machine(id) : null;
|
const machine = current ? lookup.machine(id) : null;
|
||||||
const name = textOf(params[TRANSITION_PARAM] ?? '');
|
const name = textOf(params[TRANSITION_PARAM] ?? '');
|
||||||
const target = machine ? transitionTo(machine, current, name) : '';
|
const target = machine ? transitionTo(machine, current, name) : '';
|
||||||
if (target) states[id] = target;
|
if (target) setState(ctx, id, target);
|
||||||
return { next: 'exec' };
|
return { next: 'exec' };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -23,6 +23,7 @@ import { GetPosition } from './data/GetPosition';
|
|||||||
import { IsAsset } from './data/IsAsset';
|
import { IsAsset } from './data/IsAsset';
|
||||||
import { IsEnabled } from './data/IsEnabled';
|
import { IsEnabled } from './data/IsEnabled';
|
||||||
import { IsHovered } from './data/IsHovered';
|
import { IsHovered } from './data/IsHovered';
|
||||||
|
import { IsInState } from './data/IsInState';
|
||||||
import { IsTouching } from './data/IsTouching';
|
import { IsTouching } from './data/IsTouching';
|
||||||
import { Join } from './data/Join';
|
import { Join } from './data/Join';
|
||||||
import { KeyIsDown } from './data/KeyIsDown';
|
import { KeyIsDown } from './data/KeyIsDown';
|
||||||
@@ -61,6 +62,7 @@ import { ToNumber } from './data/ToNumber';
|
|||||||
import { ToText } from './data/ToText';
|
import { ToText } from './data/ToText';
|
||||||
import { Concat } from './data/Concat';
|
import { Concat } from './data/Concat';
|
||||||
import { Count } from './data/Count';
|
import { Count } from './data/Count';
|
||||||
|
import { CurrentState } from './data/CurrentState';
|
||||||
import { ExpandStruct } from './data/ExpandStruct';
|
import { ExpandStruct } from './data/ExpandStruct';
|
||||||
import { Fill } from './data/Fill';
|
import { Fill } from './data/Fill';
|
||||||
import { IndexOf } from './data/IndexOf';
|
import { IndexOf } from './data/IndexOf';
|
||||||
@@ -79,6 +81,8 @@ import { OnKeyDown } from './events/OnKeyDown';
|
|||||||
import { OnKeyHeld } from './events/OnKeyHeld';
|
import { OnKeyHeld } from './events/OnKeyHeld';
|
||||||
import { OnKeyUp } from './events/OnKeyUp';
|
import { OnKeyUp } from './events/OnKeyUp';
|
||||||
import { OnStart } from './events/OnStart';
|
import { OnStart } from './events/OnStart';
|
||||||
|
import { OnStateEntered } from './events/OnStateEntered';
|
||||||
|
import { OnStateExited } from './events/OnStateExited';
|
||||||
import { OnTimer } from './events/OnTimer';
|
import { OnTimer } from './events/OnTimer';
|
||||||
import { OnUpdate } from './events/OnUpdate';
|
import { OnUpdate } from './events/OnUpdate';
|
||||||
import { BehaviorEnd } from './flow/BehaviorEnd';
|
import { BehaviorEnd } from './flow/BehaviorEnd';
|
||||||
@@ -114,6 +118,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
|||||||
OnCollisionExit,
|
OnCollisionExit,
|
||||||
OnClick,
|
OnClick,
|
||||||
OnAnimationFinished,
|
OnAnimationFinished,
|
||||||
|
OnStateEntered,
|
||||||
|
OnStateExited,
|
||||||
BehaviorStart,
|
BehaviorStart,
|
||||||
Branch,
|
Branch,
|
||||||
Sequence,
|
Sequence,
|
||||||
@@ -122,6 +128,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
|||||||
StateBranch,
|
StateBranch,
|
||||||
SetState,
|
SetState,
|
||||||
StateTransition,
|
StateTransition,
|
||||||
|
CurrentState,
|
||||||
|
IsInState,
|
||||||
BehaviorEnd,
|
BehaviorEnd,
|
||||||
Variables,
|
Variables,
|
||||||
Self,
|
Self,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { PropertyBag } from '../model/Properties';
|
|||||||
import type { Project } from '../model/Project';
|
import type { Project } from '../model/Project';
|
||||||
import { isGlobal } from '../model/Scope';
|
import { isGlobal } from '../model/Scope';
|
||||||
import type { NodeContext, SharedState } from './NodeContext';
|
import type { NodeContext, SharedState } from './NodeContext';
|
||||||
|
import { startStateOf } from './stateMachine';
|
||||||
|
|
||||||
export function emptySharedState(): SharedState {
|
export function emptySharedState(): SharedState {
|
||||||
return { variables: {}, states: {} };
|
return { variables: {}, states: {} };
|
||||||
@@ -26,10 +27,25 @@ export function declaredVariables(project: Project): Record<string, PropertyBag>
|
|||||||
export function buildSharedState(project: Project): SharedState {
|
export function buildSharedState(project: Project): SharedState {
|
||||||
const shared: SharedState = { variables: declaredVariables(project), states: {} };
|
const shared: SharedState = { variables: declaredVariables(project), states: {} };
|
||||||
for (const asset of project.assets)
|
for (const asset of project.assets)
|
||||||
if (asset.type === 'statemachine' && isGlobal(asset)) shared.states[asset.id] = '';
|
if (asset.type === 'statemachine' && isGlobal(asset))
|
||||||
|
shared.states[asset.id] = startStateOf(asset.graph, asset.start);
|
||||||
return shared;
|
return shared;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The start state of every machine that has a usable one, keyed by asset id. A machine without one
|
||||||
|
* is absent, which is what keeps "no start" and "starts in a deleted state" the same thing.
|
||||||
|
*/
|
||||||
|
export function startStates(project: Project): Record<string, string> {
|
||||||
|
const starts: Record<string, string> = {};
|
||||||
|
for (const asset of project.assets) {
|
||||||
|
if (asset.type !== 'statemachine') continue;
|
||||||
|
const start = startStateOf(asset.graph, asset.start);
|
||||||
|
if (start) starts[asset.id] = start;
|
||||||
|
}
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Moves edits made to the declarations during a run into the values it shares. A key is taken over
|
* 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
|
* only where the document changed it since `seen`, which is what leaves a value a graph has moved
|
||||||
@@ -68,3 +84,22 @@ export function variableBag(ctx: NodeContext, source: string): PropertyBag {
|
|||||||
export function stateBag(ctx: NodeContext, machineId: string): Record<string, string> {
|
export function stateBag(ctx: NodeContext, machineId: string): Record<string, string> {
|
||||||
return machineId in ctx.shared.states ? ctx.shared.states : ctx.states;
|
return machineId in ctx.shared.states ? ctx.shared.states : ctx.states;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The current state of a machine, `''` when it is in none. */
|
||||||
|
export function currentState(ctx: NodeContext, machineId: string): string {
|
||||||
|
return (machineId && stateBag(ctx, machineId)[machineId]) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one way a machine moves: writes the new state and reports the move. Into the state it is
|
||||||
|
* already in is no move – nothing is written, and nothing fires, which is what keeps a `Set state`
|
||||||
|
* run every frame from firing `entered` every frame.
|
||||||
|
*/
|
||||||
|
export function setState(ctx: NodeContext, machineId: string, next: string): void {
|
||||||
|
if (!machineId) return;
|
||||||
|
const bag = stateBag(ctx, machineId);
|
||||||
|
const previous = bag[machineId] ?? '';
|
||||||
|
if (previous === next) return;
|
||||||
|
bag[machineId] = next;
|
||||||
|
ctx.stateChanged(machineId, previous, next);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { textOf } from '../model/Properties';
|
import { textOf } from '../model/Properties';
|
||||||
import { flatDef } from '../properties/PropertySchema';
|
import { flatDef } from '../properties/PropertySchema';
|
||||||
import { edgesInto, type Graph, type GraphNode } from './Graph';
|
import { edgesInto, type Graph, type GraphNode, type PortType } from './Graph';
|
||||||
import type { MachineState, MachineTransition, StateMachine } from './lookup';
|
import type { MachineState, MachineTransition, StateMachine } from './lookup';
|
||||||
import type { NodeDef, PortDef, PortSource } from './NodeRegistry';
|
import type { NodeDef, PortDef, PortSource } from './NodeRegistry';
|
||||||
|
|
||||||
@@ -47,6 +47,15 @@ export function statesOf(graph: Graph): MachineState[] {
|
|||||||
return graph.nodes.filter(isStateNode).map((node) => ({ id: node.id, name: stateName(node) }));
|
return graph.nodes.filter(isStateNode).map((node) => ({ id: node.id, name: stateName(node) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state a machine starts in, as far as its graph agrees: the marked id when a state of that id
|
||||||
|
* is still there, else none. A start pointing at a deleted state is not an error to repair – it
|
||||||
|
* reads as "no start", which is exactly what the machine had before anyone marked one.
|
||||||
|
*/
|
||||||
|
export function startStateOf(graph: Graph, start: string | undefined): string {
|
||||||
|
return start && graph.nodes.some((n) => n.id === start && isStateNode(n)) ? start : '';
|
||||||
|
}
|
||||||
|
|
||||||
/** The transitions of a machine's graph: every edge between two states, in edge order. */
|
/** The transitions of a machine's graph: every edge between two states, in edge order. */
|
||||||
export function transitionsOf(graph: Graph): MachineTransition[] {
|
export function transitionsOf(graph: Graph): MachineTransition[] {
|
||||||
const states = new Set(graph.nodes.filter(isStateNode).map((n) => n.id));
|
const states = new Set(graph.nodes.filter(isStateNode).map((n) => n.id));
|
||||||
@@ -86,22 +95,24 @@ export const machinePort = (): PortDef => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The machine a node's `machine` input names while the graph is drawn: the inline pick, or the
|
* The machine asset a node's `machine` input names while the graph is drawn: the inline pick, or the
|
||||||
* `State machine` literal an edge brings in. A port is derived from the graph, not from a running
|
* `State machine` literal an edge brings in. A port is derived from the graph, not from a running
|
||||||
* value, so a variable or a list item feeding the input yields no state ports.
|
* value, so a variable or a list item feeding the input yields nothing.
|
||||||
*/
|
*/
|
||||||
export function machineOf({ node, graph, lookup }: PortSource): StateMachine | null {
|
export function machineIdOf({ node, graph }: PortSource): string {
|
||||||
const edge = edgesInto(graph, node.id, MACHINE_PORT)[0];
|
const edge = edgesInto(graph, node.id, MACHINE_PORT)[0];
|
||||||
const source = edge && graph.nodes.find((n) => n.id === edge.from.node);
|
const source = edge && graph.nodes.find((n) => n.id === edge.from.node);
|
||||||
const id = edge
|
if (!edge) return textOf(node.params[MACHINE_PORT] ?? '');
|
||||||
? source?.type === STATE_MACHINE
|
return source?.type === STATE_MACHINE ? textOf(source.params.value ?? '') : '';
|
||||||
? textOf(source.params.value ?? '')
|
|
||||||
: ''
|
|
||||||
: textOf(node.params[MACHINE_PORT] ?? '');
|
|
||||||
return id ? lookup.machine(id) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One exec port per state, keyed by the state's id – a rename keeps every edge. */
|
/** What that machine is, looked up – null where the input names none, or none that exists. */
|
||||||
export function statePorts(machine: StateMachine | null): PortDef[] {
|
export function machineOf(source: PortSource): StateMachine | null {
|
||||||
return (machine?.states ?? []).map((state) => ({ key: state.id, label: state.name, type: 'exec' }));
|
const id = machineIdOf(source);
|
||||||
|
return id ? source.lookup.machine(id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One port per state, keyed by the state's id – a rename keeps every edge. */
|
||||||
|
export function statePorts(machine: StateMachine | null, type: PortType = 'exec'): PortDef[] {
|
||||||
|
return (machine?.states ?? []).map((state) => ({ key: state.id, label: state.name, type }));
|
||||||
}
|
}
|
||||||
@@ -97,11 +97,17 @@ export interface AnimationAsset extends AssetBase {
|
|||||||
* The graph holds one `State` node per state and an edge per transition; a behavior reads the
|
* 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`.
|
* 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.
|
* A `global` machine has one current state for the whole run instead of one per behavior instance.
|
||||||
|
*
|
||||||
|
* `start` is the id of the state every instance begins in, empty when the machine has no start –
|
||||||
|
* then it is in no state until `Set state` runs. It lives on the asset rather than on the node
|
||||||
|
* because only one state may be the start, and that is structural here instead of a rule someone
|
||||||
|
* has to keep. A deleted state leaves a dead id behind, which reads as no start.
|
||||||
*/
|
*/
|
||||||
export interface StateMachineAsset extends AssetBase {
|
export interface StateMachineAsset extends AssetBase {
|
||||||
type: 'statemachine';
|
type: 'statemachine';
|
||||||
graph: Graph;
|
graph: Graph;
|
||||||
scope?: Scope;
|
scope?: Scope;
|
||||||
|
start?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GraphLookup } from '../graph/lookup';
|
import type { GraphLookup } from '../graph/lookup';
|
||||||
import { statesOf, transitionsOf } from '../graph/stateMachine';
|
import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine';
|
||||||
import { resolveAssetProps } from '../properties/resolve';
|
import { resolveAssetProps } from '../properties/resolve';
|
||||||
import { findAssetOf, type Project } from './Project';
|
import { findAssetOf, type Project } from './Project';
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ export function graphLookup(project: Project): GraphLookup {
|
|||||||
name: asset.name,
|
name: asset.name,
|
||||||
states: statesOf(asset.graph),
|
states: statesOf(asset.graph),
|
||||||
transitions: transitionsOf(asset.graph),
|
transitions: transitionsOf(asset.graph),
|
||||||
|
start: startStateOf(asset.graph, asset.start),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
|||||||
graph: normalizeGraph(a.graph, (def) => def.type === STATE),
|
graph: normalizeGraph(a.graph, (def) => def.type === STATE),
|
||||||
};
|
};
|
||||||
if (readScope(a.scope) === 'global') machine.scope = 'global';
|
if (readScope(a.scope) === 'global') machine.scope = 'global';
|
||||||
|
// Kept as written even where it names no state any more: the same as a deleted start.
|
||||||
|
if (typeof a.start === 'string' && a.start !== '') machine.start = a.start;
|
||||||
assets.push(machine);
|
assets.push(machine);
|
||||||
} else if (type === 'shader') {
|
} else if (type === 'shader') {
|
||||||
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
|
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
|
||||||
|
|||||||
@@ -177,6 +177,11 @@ export class Editor implements PlayHost {
|
|||||||
|
|
||||||
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
|
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
|
||||||
this.graphEditor.canvas.timeProvider = () => this.play.time;
|
this.graphEditor.canvas.timeProvider = () => this.play.time;
|
||||||
|
// Only one of the two ever runs a given machine, for the reason the live variables are one call.
|
||||||
|
this.stateMachineEditor.canvas.stateProvider = (assetId) => {
|
||||||
|
const playing = this.play.machineStates(assetId);
|
||||||
|
return playing.length > 0 ? playing : this.editorRun.machineStates(assetId);
|
||||||
|
};
|
||||||
// Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`).
|
// Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`).
|
||||||
this.inspector.liveVariables = (assetId) =>
|
this.inspector.liveVariables = (assetId) =>
|
||||||
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
|
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
|
||||||
@@ -343,6 +348,9 @@ export class Editor implements PlayHost {
|
|||||||
this.gameView.animate();
|
this.gameView.animate();
|
||||||
this.console.refresh();
|
this.console.refresh();
|
||||||
if (this.tab === 'animation') this.animationEditor.tick(step);
|
if (this.tab === 'animation') this.animationEditor.tick(step);
|
||||||
|
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
||||||
|
// to do it, and its states move just the same.
|
||||||
|
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
||||||
if (this.tab === 'particles') this.particlesEditor.tick();
|
if (this.tab === 'particles') this.particlesEditor.tick();
|
||||||
if (this.movesByItself()) requestRender();
|
if (this.movesByItself()) requestRender();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { hasEditorRun, runsInEditor } from '../core/properties/editorRun';
|
|||||||
import { addGameSystems, type BehaviorSystem, behaviorError } from '../runtime/systems/BehaviorSystem';
|
import { addGameSystems, type BehaviorSystem, behaviorError } from '../runtime/systems/BehaviorSystem';
|
||||||
import { Input } from '../runtime/systems/Input';
|
import { Input } from '../runtime/systems/Input';
|
||||||
import { type ModelWrite, World } from '../runtime/World';
|
import { type ModelWrite, World } from '../runtime/World';
|
||||||
|
import type { LiveMachineState } from './graph/GraphCanvas';
|
||||||
import type { EditorState } from './state';
|
import type { EditorState } from './state';
|
||||||
|
|
||||||
export interface EditorRunHooks {
|
export interface EditorRunHooks {
|
||||||
@@ -69,6 +70,11 @@ export class EditorRun {
|
|||||||
if (!write.values && !write.structure && this.showsVariables()) this.state.runtimeTick();
|
if (!write.values && !write.structure && this.showsVariables()) this.state.runtimeTick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which state each instance of a machine holds in this run; empty when no editor run is on. */
|
||||||
|
machineStates(machineId: string): LiveMachineState[] {
|
||||||
|
return this.behaviors?.machineStates(machineId) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/** The values a Variables asset shares in this run; null when no editor run is on. */
|
/** The values a Variables asset shares in this run; null when no editor run is on. */
|
||||||
liveVariables(assetId: string): PropertyBag | null {
|
liveVariables(assetId: string): PropertyBag | null {
|
||||||
return this.behaviors?.liveVariables(assetId) ?? null;
|
return this.behaviors?.liveVariables(assetId) ?? null;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { World } from '../runtime/World';
|
|||||||
import { ui } from '../ui';
|
import { ui } from '../ui';
|
||||||
import type { GameViewport } from './viewport/GameViewport';
|
import type { GameViewport } from './viewport/GameViewport';
|
||||||
import type { GraphEditor } from './graph/GraphEditor';
|
import type { GraphEditor } from './graph/GraphEditor';
|
||||||
|
import type { LiveMachineState } from './graph/GraphCanvas';
|
||||||
|
import type { StateMachineEditor } from './statemachine/StateMachineEditor';
|
||||||
import type { EditorState } from './state';
|
import type { EditorState } from './state';
|
||||||
|
|
||||||
/** Facts about the running world for the check scripts. */
|
/** Facts about the running world for the check scripts. */
|
||||||
@@ -42,6 +44,7 @@ export interface WorldSnapshot {
|
|||||||
export interface PlayHost {
|
export interface PlayHost {
|
||||||
gameView: GameViewport;
|
gameView: GameViewport;
|
||||||
graphEditor: GraphEditor;
|
graphEditor: GraphEditor;
|
||||||
|
stateMachineEditor: StateMachineEditor;
|
||||||
showGame(): void;
|
showGame(): void;
|
||||||
/** Leave the game view if it is the one on screen. */
|
/** Leave the game view if it is the one on screen. */
|
||||||
leaveGame(): void;
|
leaveGame(): void;
|
||||||
@@ -77,6 +80,11 @@ export class PlaySession {
|
|||||||
return this.behaviors?.traceFor(assetId) ?? null;
|
return this.behaviors?.traceFor(assetId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which state each running instance of a machine holds; empty when nothing is playing. */
|
||||||
|
machineStates(machineId: string): LiveMachineState[] {
|
||||||
|
return this.behaviors?.machineStates(machineId) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
get time(): number {
|
get time(): number {
|
||||||
return this.world?.time ?? 0;
|
return this.world?.time ?? 0;
|
||||||
}
|
}
|
||||||
@@ -157,6 +165,8 @@ export class PlaySession {
|
|||||||
if (world.syncToModel().structure) this.state.preview();
|
if (world.syncToModel().structure) this.state.preview();
|
||||||
this.state.runtimeTick();
|
this.state.runtimeTick();
|
||||||
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
|
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
|
||||||
|
// The machine tab shows the state a run is in, so it repaints for the same reason the graph does.
|
||||||
|
if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot(): WorldSnapshot | null {
|
snapshot(): WorldSnapshot | null {
|
||||||
|
|||||||
@@ -303,6 +303,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
danglingEdge: () => editor.stateMachineEditor.canvas.danglingEdgeVisible(),
|
danglingEdge: () => editor.stateMachineEditor.canvas.danglingEdgeVisible(),
|
||||||
emptySpot: () => editor.stateMachineEditor.canvas.emptySpotGlobal(),
|
emptySpot: () => editor.stateMachineEditor.canvas.emptySpotGlobal(),
|
||||||
selected: () => state.selectedNodeIds,
|
selected: () => state.selectedNodeIds,
|
||||||
|
/** The start ring and the live marks as drawn – what no colour check could tell apart. */
|
||||||
|
startRing: (nodeId: string) => editor.stateMachineEditor.canvas.startRingDrawn(nodeId),
|
||||||
|
stateMarks: () => editor.stateMachineEditor.canvas.stateMarksDrawn(),
|
||||||
},
|
},
|
||||||
particles: {
|
particles: {
|
||||||
current: () => editor.particlesEditor.currentAssetId,
|
current: () => editor.particlesEditor.currentAssetId,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ConnectCommand,
|
ConnectCommand,
|
||||||
MoveNodeCommand,
|
MoveNodeCommand,
|
||||||
} from '../../core/commands/graphCommands';
|
} from '../../core/commands/graphCommands';
|
||||||
|
import { SetMachineStartCommand } from '../../core/commands/assetCommands';
|
||||||
import {
|
import {
|
||||||
BEHAVIOR_PARAM,
|
BEHAVIOR_PARAM,
|
||||||
callProblem,
|
callProblem,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
RUN_BEHAVIOR,
|
RUN_BEHAVIOR,
|
||||||
STATE_IN,
|
STATE_IN,
|
||||||
STATE_OUT,
|
STATE_OUT,
|
||||||
|
startStateOf,
|
||||||
} from '../../core/graph';
|
} from '../../core/graph';
|
||||||
import type { GraphAsset } from '../../core/model/Asset';
|
import type { GraphAsset } from '../../core/model/Asset';
|
||||||
import { graphLookup } from '../../core/model/graphLookup';
|
import { graphLookup } from '../../core/model/graphLookup';
|
||||||
@@ -64,6 +66,14 @@ import { NodePalette } from './NodePalette';
|
|||||||
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
||||||
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
|
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
|
||||||
|
|
||||||
|
/** One running instance's answer for a machine: which state it holds, and whose instance it is. */
|
||||||
|
export interface LiveMachineState {
|
||||||
|
state: string;
|
||||||
|
/** Object id of the behavior itself, and of the object it hangs under (empty when it has none). */
|
||||||
|
objectId: string;
|
||||||
|
ownerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */
|
/** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */
|
||||||
const GRID_STEP = 24;
|
const GRID_STEP = 24;
|
||||||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||||||
@@ -134,6 +144,8 @@ export class GraphCanvas extends Widget {
|
|||||||
private beadAsset = '';
|
private beadAsset = '';
|
||||||
/** Supplies live execution data while playing (null otherwise). */
|
/** Supplies live execution data while playing (null otherwise). */
|
||||||
traceProvider: (assetId: string) => Trace | null = () => null;
|
traceProvider: (assetId: string) => Trace | null = () => null;
|
||||||
|
/** Which state each running instance of this machine holds; empty when nothing runs. */
|
||||||
|
stateProvider: (assetId: string) => readonly LiveMachineState[] = () => [];
|
||||||
timeProvider: () => number = () => 0;
|
timeProvider: () => number = () => 0;
|
||||||
/** A double-clicked call node; the tab opens the behavior and records the step. */
|
/** A double-clicked call node; the tab opens the behavior and records the step. */
|
||||||
onOpenBehavior: (assetId: string) => void = (assetId) => this.state.select({ kind: 'asset', assetId });
|
onOpenBehavior: (assetId: string) => void = (assetId) => this.state.select({ kind: 'asset', assetId });
|
||||||
@@ -269,6 +281,51 @@ export class GraphCanvas extends Widget {
|
|||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one entry that marks a state as the machine's start, or takes the mark off it again – the
|
||||||
|
* same place, so it reads as the switch it is. Only a state machine has one to give.
|
||||||
|
*/
|
||||||
|
private startItems(nodeId: string): MenuItem[] {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (asset?.type !== 'statemachine') return [];
|
||||||
|
const isStart = startStateOf(asset.graph, asset.start) === nodeId;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: isStart ? 'Remove start state' : 'Set as start state',
|
||||||
|
onSelect: () => this.state.execute(new SetMachineStartCommand(asset.id, isStart ? '' : nodeId)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which states to light up while something runs: every state an instance holds, and strongly the
|
||||||
|
* one held by the selected object's instance. Without a selection nothing is strong – that keeps
|
||||||
|
* "many instances, many states" readable instead of picking one of them at random.
|
||||||
|
*/
|
||||||
|
private stateMarks(asset: GraphAsset): Map<string, 'held' | 'current'> {
|
||||||
|
const marks = new Map<string, 'held' | 'current'>();
|
||||||
|
if (asset.type !== 'statemachine') return marks;
|
||||||
|
const selection = this.state.selection;
|
||||||
|
const picked = selection?.kind === 'object' ? selection.objectId : '';
|
||||||
|
for (const live of this.stateProvider(asset.id)) {
|
||||||
|
const mine = picked !== '' && (live.objectId === picked || live.ownerId === picked);
|
||||||
|
if (mine || marks.get(live.state) !== 'current') marks.set(live.state, mine ? 'current' : 'held');
|
||||||
|
}
|
||||||
|
return marks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the last redraw marked, for the checks: the drawn state, not a recomputation of it. */
|
||||||
|
stateMarksDrawn(): Record<string, string> {
|
||||||
|
const marks: Record<string, string> = {};
|
||||||
|
for (const [id, view] of this.views) if (view.stateMark !== 'none') marks[id] = view.stateMark;
|
||||||
|
return marks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the start ring is drawn on this node – the same rule. */
|
||||||
|
startRingDrawn(nodeId: string): boolean {
|
||||||
|
return this.views.get(nodeId)?.isStart ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Frames the graph's nodes (or the origin) in the view. Waits for a real size. */
|
/** Frames the graph's nodes (or the origin) in the view. Waits for a real size. */
|
||||||
private fitView(): void {
|
private fitView(): void {
|
||||||
if (this.w === 0 || this.h === 0) return;
|
if (this.w === 0 || this.h === 0) return;
|
||||||
@@ -383,7 +440,10 @@ export class GraphCanvas extends Widget {
|
|||||||
const graphPoint = this.screenToGraph(at.x, at.y);
|
const graphPoint = this.screenToGraph(at.x, at.y);
|
||||||
const extras: MenuItem[] =
|
const extras: MenuItem[] =
|
||||||
view.def.transitionEdges === true
|
view.def.transitionEdges === true
|
||||||
? [{ label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) }]
|
? [
|
||||||
|
{ label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) },
|
||||||
|
...this.startItems(id),
|
||||||
|
]
|
||||||
: [];
|
: [];
|
||||||
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y, extras);
|
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y, extras);
|
||||||
}
|
}
|
||||||
@@ -808,9 +868,13 @@ export class GraphCanvas extends Widget {
|
|||||||
const wall = performance.now();
|
const wall = performance.now();
|
||||||
const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null;
|
const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null;
|
||||||
if (values) this.valuesShownAt = wall;
|
if (values) this.valuesShownAt = wall;
|
||||||
|
const marks = this.stateMarks(asset);
|
||||||
|
const start = asset.type === 'statemachine' ? startStateOf(asset.graph, asset.start) : '';
|
||||||
for (const view of this.views.values()) {
|
for (const view of this.views.values()) {
|
||||||
view.selected = selectedNodes.has(view.node.id);
|
view.selected = selectedNodes.has(view.node.id);
|
||||||
view.hot = isHot(trace?.nodes, view.node.id);
|
view.hot = isHot(trace?.nodes, view.node.id);
|
||||||
|
view.isStart = view.node.id === start;
|
||||||
|
view.stateMark = marks.get(view.node.id) ?? 'none';
|
||||||
if (values) view.setLiveValues(values.get(view.node.id) ?? null, this.state.project);
|
if (values) view.setLiveValues(values.get(view.node.id) ?? null, this.state.project);
|
||||||
view.targetPort =
|
view.targetPort =
|
||||||
target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null;
|
target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null;
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ export class NodeView extends Widget {
|
|||||||
private problem: string | null = null;
|
private problem: string | null = null;
|
||||||
selected = false;
|
selected = false;
|
||||||
hot = false;
|
hot = false;
|
||||||
|
/** The state a machine begins in wears a ring, so the entry point is visible at a glance. */
|
||||||
|
isStart = false;
|
||||||
|
/**
|
||||||
|
* While something is running: `held` for a state some instance of the machine is in, `current`
|
||||||
|
* for the one the selected object's instance holds. Nothing outside a run.
|
||||||
|
*/
|
||||||
|
stateMark: 'none' | 'held' | 'current' = 'none';
|
||||||
/** Port a running edge drag would connect to, drawn with a ring so the drop is predictable. */
|
/** Port a running edge drag would connect to, drawn with a ring so the drop is predictable. */
|
||||||
targetPort: { port: string; isOutput: boolean } | null = null;
|
targetPort: { port: string; isOutput: boolean } | null = null;
|
||||||
/** Data inputs that currently have a connection (their inline value is hidden). */
|
/** Data inputs that currently have a connection (their inline value is hidden). */
|
||||||
@@ -390,10 +397,20 @@ export class NodeView extends Widget {
|
|||||||
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
|
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
|
||||||
.fill(categoryColor(this.def.category));
|
.fill(categoryColor(this.def.category));
|
||||||
this.drawChevron(PAD + 4, TITLE_HEIGHT / 2);
|
this.drawChevron(PAD + 4, TITLE_HEIGHT / 2);
|
||||||
|
// The ring sits where a state's ports never are: a state draws none (see `portCentres`).
|
||||||
|
if (this.isStart)
|
||||||
|
this.box
|
||||||
|
.circle(this.w - PAD - PORT_RADIUS, TITLE_HEIGHT / 2, PORT_RADIUS)
|
||||||
|
.stroke({ width: 2, color: c.text });
|
||||||
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : this.problem ? c.danger : c.nodeBorder;
|
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : this.problem ? c.danger : c.nodeBorder;
|
||||||
this.box
|
this.box
|
||||||
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
|
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
|
||||||
.stroke({ width: this.selected || this.hot || this.problem ? 2 : 1, color: border });
|
.stroke({ width: this.selected || this.hot || this.problem ? 2 : 1, color: border });
|
||||||
|
// Inside the border, so a selected state still shows both: what is picked, and what is live.
|
||||||
|
if (this.stateMark !== 'none')
|
||||||
|
this.box
|
||||||
|
.roundRect(2.5, 2.5, this.w - 5, this.h - 5, Theme.radius)
|
||||||
|
.stroke({ width: 2, color: c.hot, alpha: this.stateMark === 'current' ? 1 : 0.35 });
|
||||||
|
|
||||||
this.ports.clear();
|
this.ports.clear();
|
||||||
const r = PORT_RADIUS;
|
const r = PORT_RADIUS;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
isHandleEnabled,
|
isHandleEnabled,
|
||||||
type NodeContext,
|
type NodeContext,
|
||||||
type SharedState,
|
type SharedState,
|
||||||
|
startStates,
|
||||||
type TraceListener,
|
type TraceListener,
|
||||||
type Value,
|
type Value,
|
||||||
} from '../../core/graph';
|
} from '../../core/graph';
|
||||||
@@ -41,6 +42,21 @@ interface Produced {
|
|||||||
values: Record<string, Value>;
|
values: Record<string, Value>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A move one graph made, waiting for its events. `source` is the instance that caused it, and who
|
||||||
|
* hears it when the machine is local; a `global` machine is heard by every instance, so the move it
|
||||||
|
* reports has no owner (`null` for the start state entered before any instance existed).
|
||||||
|
*/
|
||||||
|
interface StateChange {
|
||||||
|
machineId: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
source: Instance | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How often state events may cause the next move before the run is called endless. */
|
||||||
|
const MAX_STATE_ROUNDS = 100;
|
||||||
|
|
||||||
interface Instance {
|
interface Instance {
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
interpreter: GraphInterpreter;
|
interpreter: GraphInterpreter;
|
||||||
@@ -89,6 +105,8 @@ export class BehaviorSystem implements System {
|
|||||||
private instances: Instance[] = [];
|
private instances: Instance[] = [];
|
||||||
private world: World | null = null;
|
private world: World | null = null;
|
||||||
private sharedState: SharedState = emptySharedState();
|
private sharedState: SharedState = emptySharedState();
|
||||||
|
/** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */
|
||||||
|
private pending: StateChange[] = [];
|
||||||
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
|
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
|
||||||
private declared: Record<string, PropertyBag> = {};
|
private declared: Record<string, PropertyBag> = {};
|
||||||
private readonly collisions: CollisionSystem | null;
|
private readonly collisions: CollisionSystem | null;
|
||||||
@@ -128,9 +146,14 @@ export class BehaviorSystem implements System {
|
|||||||
start(world: World): void {
|
start(world: World): void {
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.instances = [];
|
this.instances = [];
|
||||||
|
this.pending = [];
|
||||||
// Built fresh for every run: a shared variable starts at its declared value each time Play does.
|
// Built fresh for every run: a shared variable starts at its declared value each time Play does.
|
||||||
this.sharedState = buildSharedState(this.project);
|
this.sharedState = buildSharedState(this.project);
|
||||||
this.declared = declaredVariables(this.project);
|
this.declared = declaredVariables(this.project);
|
||||||
|
// A `global` machine is entered once for the whole run, before the first instance exists – so
|
||||||
|
// the move is queued here and every instance the first sync creates hears that one entry.
|
||||||
|
for (const [machineId, start] of Object.entries(this.sharedState.states))
|
||||||
|
if (start) this.pending.push({ machineId, from: '', to: start, source: null });
|
||||||
this.syncInstances(world);
|
this.syncInstances(world);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +161,7 @@ export class BehaviorSystem implements System {
|
|||||||
stop(): void {
|
stop(): void {
|
||||||
for (const instance of this.instances) this.finish(instance);
|
for (const instance of this.instances) this.finish(instance);
|
||||||
this.instances = [];
|
this.instances = [];
|
||||||
|
this.pending = [];
|
||||||
this.world = null;
|
this.world = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +171,8 @@ export class BehaviorSystem implements System {
|
|||||||
*/
|
*/
|
||||||
private syncInstances(world: World): void {
|
private syncInstances(world: World): void {
|
||||||
this.reap(world);
|
this.reap(world);
|
||||||
|
// Read once, and only where an instance is actually born: most frames create none at all.
|
||||||
|
let starts: Record<string, string> | null = null;
|
||||||
for (const entity of world.entities) {
|
for (const entity of world.entities) {
|
||||||
if (
|
if (
|
||||||
entity.type !== 'behavior' ||
|
entity.type !== 'behavior' ||
|
||||||
@@ -171,6 +197,7 @@ export class BehaviorSystem implements System {
|
|||||||
} satisfies TraceListener;
|
} satisfies TraceListener;
|
||||||
const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true };
|
const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true };
|
||||||
this.instances.push(instance);
|
this.instances.push(instance);
|
||||||
|
this.enterStartStates(instance, (starts ??= startStates(this.project)));
|
||||||
this.run(
|
this.run(
|
||||||
instance,
|
instance,
|
||||||
(i, ctx) => {
|
(i, ctx) => {
|
||||||
@@ -180,6 +207,74 @@ export class BehaviorSystem implements System {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
this.flushStateChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fresh instance begins in each local machine's start state, and hears `On state entered` for
|
||||||
|
* it – after its `On start`, like every other move. A `global` machine is not entered here: it
|
||||||
|
* was entered once when the run began, and a later instance joins it where it already is.
|
||||||
|
*/
|
||||||
|
private enterStartStates(instance: Instance, starts: Record<string, string>): void {
|
||||||
|
for (const [machineId, start] of Object.entries(starts)) {
|
||||||
|
if (machineId in this.sharedState.states) continue;
|
||||||
|
instance.states[machineId] = start;
|
||||||
|
this.pending.push({ machineId, from: '', to: start, source: instance });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events every queued move owes, and those the events themselves cause, until none are left.
|
||||||
|
* Each move is delivered whole – `exited` to everyone who hears it, then `entered` – so the two
|
||||||
|
* never interleave across instances.
|
||||||
|
*/
|
||||||
|
private flushStateChanges(): void {
|
||||||
|
const world = this.world;
|
||||||
|
if (!world) {
|
||||||
|
this.pending = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let round = 0; this.pending.length > 0; round++) {
|
||||||
|
if (round >= MAX_STATE_ROUNDS) {
|
||||||
|
this.pending = [];
|
||||||
|
world.log.push(`${ERROR_PREFIX} graphs: state changes keep causing each other (endless loop?)`);
|
||||||
|
world.stopRequested = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const batch = this.pending;
|
||||||
|
this.pending = [];
|
||||||
|
for (const change of batch) this.deliver(change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deliver(change: StateChange): void {
|
||||||
|
const global = change.machineId in this.sharedState.states;
|
||||||
|
const targets = (global ? this.instances : [change.source]).filter(
|
||||||
|
(i): i is Instance => i !== null && this.instances.includes(i),
|
||||||
|
);
|
||||||
|
const fire = (type: string, state: string) => {
|
||||||
|
for (const instance of targets)
|
||||||
|
this.run(instance, (i, ctx) => i.fireStateEvent(type, ctx, change.machineId, state), 0);
|
||||||
|
};
|
||||||
|
fire('OnStateExited', change.from);
|
||||||
|
fire('OnStateEntered', change.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which state each instance holds of one machine, for the State machine tab's live highlight.
|
||||||
|
* A `global` machine answers even with no instance running: its state belongs to the run.
|
||||||
|
*/
|
||||||
|
machineStates(machineId: string): { state: string; objectId: string; ownerId: string }[] {
|
||||||
|
const shared = this.sharedState.states[machineId];
|
||||||
|
const held = this.instances
|
||||||
|
.map((i) => ({
|
||||||
|
state: shared ?? i.states[machineId] ?? '',
|
||||||
|
objectId: i.entity.objectId,
|
||||||
|
ownerId: i.entity.parent?.objectId ?? '',
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.state !== '');
|
||||||
|
if (held.length === 0 && shared) return [{ state: shared, objectId: '', ownerId: '' }];
|
||||||
|
return held;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Instances whose entity left the world get their last events and are dropped. */
|
/** Instances whose entity left the world get their last events and are dropped. */
|
||||||
@@ -250,8 +345,12 @@ export class BehaviorSystem implements System {
|
|||||||
},
|
},
|
||||||
dt,
|
dt,
|
||||||
);
|
);
|
||||||
|
// After this instance's frame, not inside it: a state entered halfway through `On update`
|
||||||
|
// must not push its events between the nodes that are still to run.
|
||||||
|
this.flushStateChanges();
|
||||||
}
|
}
|
||||||
this.reap(world);
|
this.reap(world);
|
||||||
|
this.flushStateChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -360,7 +459,8 @@ export class BehaviorSystem implements System {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private context({ entity, states }: Instance, world: World, dt: number): NodeContext {
|
private context(instance: Instance, world: World, dt: number): NodeContext {
|
||||||
|
const { entity, states } = instance;
|
||||||
// Handles arriving from the graph are the entities themselves, or test doubles with the same id.
|
// Handles arriving from the graph are the entities themselves, or test doubles with the same id.
|
||||||
const resolve = (handle: EntityHandle | null): Entity | undefined =>
|
const resolve = (handle: EntityHandle | null): Entity | undefined =>
|
||||||
handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined;
|
handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined;
|
||||||
@@ -370,6 +470,7 @@ export class BehaviorSystem implements System {
|
|||||||
variables: entity.props,
|
variables: entity.props,
|
||||||
states,
|
states,
|
||||||
shared: this.sharedState,
|
shared: this.sharedState,
|
||||||
|
stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }),
|
||||||
time: world.time,
|
time: world.time,
|
||||||
dt,
|
dt,
|
||||||
findByName: (name) => world.findByName(name) ?? null,
|
findByName: (name) => world.findByName(name) ?? null,
|
||||||
|
|||||||
@@ -72,13 +72,15 @@ describe('node registry', () => {
|
|||||||
const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
|
const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
|
||||||
expect(new Set(def.inputs.map((p) => p.key)).size).toBe(def.inputs.length);
|
expect(new Set(def.inputs.map((p) => p.key)).size).toBe(def.inputs.length);
|
||||||
expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length);
|
expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length);
|
||||||
// An event is seeded by the runtime, so nothing flows into it.
|
// An event is seeded by the runtime, so no flow arrives at one. What it may take is a data
|
||||||
if (def.category === 'event') expect(def.inputs).toHaveLength(0);
|
// input that says which event it means – the machine `On state entered` watches.
|
||||||
|
if (def.category === 'event') expect(def.inputs.filter((p) => p.type === 'exec')).toHaveLength(0);
|
||||||
// A state is a name in a machine; its ports only anchor transitions, so it never runs.
|
// A state is a name in a machine; its ports only anchor transitions, so it never runs.
|
||||||
else if (def.category === 'state') expect(def.run).toBeUndefined();
|
else if (def.category === 'state') expect(def.run).toBeUndefined();
|
||||||
// A shader node is compiled to GLSL and a particle node to a function: neither is ever run.
|
// A shader node is compiled to GLSL and a particle node to a function: neither is ever run.
|
||||||
else if (def.category === 'shader' || def.category === 'particles') expect('emit' in def).toBe(true);
|
else if (def.category === 'shader' || def.category === 'particles') expect('emit' in def).toBe(true);
|
||||||
else expect(def.run).toBeTypeOf('function');
|
// Everything else does one of the two: a step (`run`), or an answer whenever asked (`read`).
|
||||||
|
else expect(typeof (def.run ?? def.read)).toBe('function');
|
||||||
expect(keys.every((k) => k.length > 0)).toBe(true);
|
expect(keys.every((k) => k.length > 0)).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,11 +45,19 @@ interface Case {
|
|||||||
after?(ctx: FakeContext): void;
|
after?(ctx: FakeContext): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A lookup holding one machine "m" with the given states – what the two state readers name. */
|
||||||
|
const machineWithStates = (states: { id: string; name: string }[]): GraphLookup => ({
|
||||||
|
...NO_LOOKUP,
|
||||||
|
machine: (id) => (id === 'm' ? { name: 'M', states, start: '', transitions: [] } : null),
|
||||||
|
});
|
||||||
|
|
||||||
/** A lookup holding one machine "m" with the given transitions – what `State transition` reads. */
|
/** A lookup holding one machine "m" with the given transitions – what `State transition` reads. */
|
||||||
const machineLookup = (transitions: { from: string; to: string; name?: string }[]): GraphLookup => ({
|
const machineLookup = (transitions: { from: string; to: string; name?: string }[]): GraphLookup => ({
|
||||||
...NO_LOOKUP,
|
...NO_LOOKUP,
|
||||||
machine: (id) =>
|
machine: (id) =>
|
||||||
id === 'm' ? { name: 'M', states: [], transitions: transitions.map((t) => ({ name: '', ...t })) } : null,
|
id === 'm'
|
||||||
|
? { name: 'M', states: [], start: '', transitions: transitions.map((t) => ({ name: '', ...t })) }
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
|
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
|
||||||
@@ -63,7 +71,12 @@ const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } =>
|
|||||||
outputs: [...registered.outputs, ...execPorts(c.derivedOutputs)],
|
outputs: [...registered.outputs, ...execPorts(c.derivedOutputs)],
|
||||||
};
|
};
|
||||||
const lookup = c.lookup ?? NO_LOOKUP;
|
const lookup = c.lookup ?? NO_LOOKUP;
|
||||||
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def, c.entry ?? 'exec', lookup), ctx };
|
// A pure reader has no `run`: its outputs are what it answers at the moment they are asked for,
|
||||||
|
// which is exactly what the interpreter pulls (see `GraphInterpreter.readAll`).
|
||||||
|
const result = def.run
|
||||||
|
? def.run(ctx, c.inputs ?? {}, c.params ?? {}, def, c.entry ?? 'exec', lookup)
|
||||||
|
: { outputs: def.read!(ctx, c.params ?? {}, (key) => c.inputs?.[key] ?? null, def, lookup) };
|
||||||
|
return { result, ctx };
|
||||||
};
|
};
|
||||||
|
|
||||||
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
|
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
|
||||||
@@ -151,6 +164,44 @@ const CASES: Record<string, Case[]> = {
|
|||||||
after: (ctx) => expect(ctx.states).toEqual({}),
|
after: (ctx) => expect(ctx.states).toEqual({}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
CurrentState: [
|
||||||
|
{
|
||||||
|
name: 'names the state the machine is in, and hands out its id as well',
|
||||||
|
inputs: { machine: 'm' },
|
||||||
|
context: { states: { m: 's1' } },
|
||||||
|
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
|
||||||
|
outputs: { name: 'Open', id: 's1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'in no state, both outputs are empty',
|
||||||
|
inputs: { machine: 'm' },
|
||||||
|
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
|
||||||
|
outputs: { name: '', id: '' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A state of another machine, or one that was deleted: the id stands, the name is gone.
|
||||||
|
name: 'a state the machine does not know keeps its id but has no name',
|
||||||
|
inputs: { machine: 'm' },
|
||||||
|
context: { states: { m: 'gone' } },
|
||||||
|
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
|
||||||
|
outputs: { name: '', id: 'gone' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
IsInState: [
|
||||||
|
{
|
||||||
|
name: 'is true on the current state and false on every other',
|
||||||
|
inputs: { machine: 'm' },
|
||||||
|
context: { states: { m: 's2' } },
|
||||||
|
derivedOutputs: ['s1', 's2'],
|
||||||
|
outputs: { s1: false, s2: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'in no state, every output is false rather than unanswered',
|
||||||
|
inputs: { machine: 'm' },
|
||||||
|
derivedOutputs: ['s1', 's2'],
|
||||||
|
outputs: { s1: false, s2: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
StateTransition: [
|
StateTransition: [
|
||||||
{
|
{
|
||||||
name: 'follows the transition of the asked-for name out of the current state',
|
name: 'follows the transition of the asked-for name out of the current state',
|
||||||
@@ -786,18 +837,20 @@ const EVENT_NODES: Record<string, string> = {
|
|||||||
OnCollisionExit: 'collider.test.ts',
|
OnCollisionExit: 'collider.test.ts',
|
||||||
OnClick: 'nodes.test.ts',
|
OnClick: 'nodes.test.ts',
|
||||||
OnAnimationFinished: 'animation.test.ts',
|
OnAnimationFinished: 'animation.test.ts',
|
||||||
|
OnStateEntered: 'state-machine.test.ts',
|
||||||
|
OnStateExited: 'state-machine.test.ts',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('node catalog', () => {
|
describe('node catalog', () => {
|
||||||
it('covers every built-in node', () => {
|
it('covers every built-in node', () => {
|
||||||
const uncovered = BUILT_IN_NODES.filter((def) =>
|
const uncovered = BUILT_IN_NODES.filter((def) =>
|
||||||
def.run ? !CASES[def.type] : !EVENT_NODES[def.type],
|
(def.run ?? def.read) ? !CASES[def.type] : !EVENT_NODES[def.type],
|
||||||
).map((def) => def.type);
|
).map((def) => def.type);
|
||||||
expect(uncovered).toEqual([]);
|
expect(uncovered).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists exactly the run-less nodes as events', () => {
|
it('lists exactly the nodes that neither run nor read as events', () => {
|
||||||
const runless = BUILT_IN_NODES.filter((def) => !def.run).map((def) => def.type);
|
const runless = BUILT_IN_NODES.filter((def) => !def.run && !def.read).map((def) => def.type);
|
||||||
expect(Object.keys(EVENT_NODES).sort()).toEqual(runless.sort());
|
expect(Object.keys(EVENT_NODES).sort()).toEqual(runless.sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
* input per state); the current state lives with the behavior instance, like its variables.
|
* input per state); the current state lives with the behavior instance, like its variables.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
import {
|
||||||
|
CreateAssetCommand,
|
||||||
|
SetMachineScopeCommand,
|
||||||
|
SetMachineStartCommand,
|
||||||
|
} from '../../src/core/commands/assetCommands';
|
||||||
import {
|
import {
|
||||||
AddNodeCommand,
|
AddNodeCommand,
|
||||||
ConnectCommand,
|
ConnectCommand,
|
||||||
@@ -24,6 +28,7 @@ import {
|
|||||||
STATE_IN,
|
STATE_IN,
|
||||||
STATE_NAME_PARAM,
|
STATE_NAME_PARAM,
|
||||||
STATE_OUT,
|
STATE_OUT,
|
||||||
|
startStateOf,
|
||||||
statesOf,
|
statesOf,
|
||||||
transitionNames,
|
transitionNames,
|
||||||
transitionsOf,
|
transitionsOf,
|
||||||
@@ -301,6 +306,242 @@ describe('a running state machine', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the state a machine starts in', () => {
|
||||||
|
it('is kept on the asset, can be taken off again, and survives a save', () => {
|
||||||
|
const f = makeProject();
|
||||||
|
const machine = makeMachine(f);
|
||||||
|
const [open] = machine.ids as [string, string];
|
||||||
|
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||||||
|
expect(machine.asset().start).toBe(open);
|
||||||
|
expect(findAssetOf(roundTrip(f.project), machine.id, 'statemachine')?.start).toBe(open);
|
||||||
|
|
||||||
|
// The same entry takes the mark off: only one state can be the start, so there is one field.
|
||||||
|
f.stack.execute(new SetMachineStartCommand(machine.id, ''));
|
||||||
|
expect(machine.asset().start).toBeUndefined();
|
||||||
|
f.stack.undo();
|
||||||
|
expect(machine.asset().start).toBe(open);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads as no start once that state is gone', () => {
|
||||||
|
const f = makeProject();
|
||||||
|
const machine = makeMachine(f);
|
||||||
|
const [open] = machine.ids as [string, string];
|
||||||
|
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||||||
|
f.stack.execute(new RemoveNodeCommand(machine.id, open));
|
||||||
|
// The dead id stays in the document; what the graph agrees to is what counts.
|
||||||
|
expect(machine.asset().start).toBe(open);
|
||||||
|
expect(startStateOf(machine.asset().graph, machine.asset().start)).toBe('');
|
||||||
|
expect(graphLookup(f.project).machine(machine.id)?.start).toBe('');
|
||||||
|
f.stack.undo();
|
||||||
|
expect(startStateOf(machine.asset().graph, machine.asset().start)).toBe(open);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts every instance in it before anything reads the machine', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const machine = makeMachine(f);
|
||||||
|
const [open, closed] = machine.ids as [string, string];
|
||||||
|
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||||||
|
const behavior = makeBehavior(f, 'Door');
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const update = edit.addNode('OnUpdate');
|
||||||
|
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||||||
|
const logOpen = edit.addNode('Log', { message: 'open' });
|
||||||
|
const logClosed = edit.addNode('Log', { message: 'closed' });
|
||||||
|
edit.wire([
|
||||||
|
[update, 'exec', branch, 'exec'],
|
||||||
|
[branch, open, logOpen, 'exec'],
|
||||||
|
[branch, closed, logClosed, 'exec'],
|
||||||
|
]);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 });
|
||||||
|
const started = startWorld(f, { behaviors: true });
|
||||||
|
started.frame();
|
||||||
|
// Without a start state this branch would follow nothing at all (that is the old behaviour).
|
||||||
|
expect(started.world.log).toEqual(['open']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('On state entered and On state exited', () => {
|
||||||
|
/** A behavior that logs every entry and exit of the machine, one Log per state and direction. */
|
||||||
|
function watcher(f: ProjectFixture, machineId: string, ids: readonly string[], name = 'Watcher') {
|
||||||
|
const behavior = makeBehavior(f, name);
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const entered = edit.addNode('OnStateEntered', { [MACHINE_PORT]: machineId });
|
||||||
|
const exited = edit.addNode('OnStateExited', { [MACHINE_PORT]: machineId });
|
||||||
|
for (const [index, state] of ids.entries()) {
|
||||||
|
const label = index === 0 ? 'open' : 'closed';
|
||||||
|
const inLog = edit.addNode('Log', { message: `${name}: entered ${label}` });
|
||||||
|
const outLog = edit.addNode('Log', { message: `${name}: exited ${label}` });
|
||||||
|
edit.wire([
|
||||||
|
[entered, state, inLog, 'exec'],
|
||||||
|
[exited, state, outLog, 'exec'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return { behavior, edit };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fires for the start state too, with nothing to exit before it', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const machine = makeMachine(f);
|
||||||
|
f.stack.execute(new SetMachineStartCommand(machine.id, machine.ids[0]!));
|
||||||
|
const { behavior } = watcher(f, machine.id, machine.ids);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 });
|
||||||
|
const started = startWorld(f, { behaviors: true });
|
||||||
|
started.frame();
|
||||||
|
expect(started.world.log).toEqual(['Watcher: entered open']);
|
||||||
|
// Once, not once per frame: the instance is already in that state the second time round.
|
||||||
|
started.frame();
|
||||||
|
expect(started.world.log).toEqual(['Watcher: entered open']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires exited before entered, after the flow that moved the machine', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const machine = makeMachine(f);
|
||||||
|
const [open, closed] = machine.ids as [string, string];
|
||||||
|
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||||||
|
const { behavior, edit } = watcher(f, machine.id, machine.ids);
|
||||||
|
// The pulse that moves the machine logs after the move: its own line comes first, the events after.
|
||||||
|
const key = edit.addNode('OnUpdate');
|
||||||
|
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||||
|
const done = edit.addNode('Log', { message: 'flow finished' });
|
||||||
|
edit.wire([
|
||||||
|
[key, 'exec', set, closed],
|
||||||
|
[set, 'exec', done, 'exec'],
|
||||||
|
]);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 });
|
||||||
|
const started = startWorld(f, { behaviors: true });
|
||||||
|
started.frame();
|
||||||
|
expect(started.world.log).toEqual([
|
||||||
|
'Watcher: entered open',
|
||||||
|
'flow finished',
|
||||||
|
'Watcher: exited open',
|
||||||
|
'Watcher: entered closed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing when a set puts the machine into the state it is already in', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const machine = makeMachine(f);
|
||||||
|
const [open] = machine.ids as [string, string];
|
||||||
|
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||||||
|
const { behavior, edit } = watcher(f, machine.id, machine.ids);
|
||||||
|
const update = edit.addNode('OnUpdate');
|
||||||
|
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||||
|
edit.wire([[update, 'exec', set, open]]);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 });
|
||||||
|
const started = startWorld(f, { behaviors: true });
|
||||||
|
started.frame();
|
||||||
|
started.frame();
|
||||||
|
expect(started.world.log).toEqual(['Watcher: entered open']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches only the instance that moved while the machine is local', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const machine = makeMachine(f);
|
||||||
|
const [open, closed] = machine.ids as [string, string];
|
||||||
|
const { behavior, edit } = watcher(f, machine.id, machine.ids);
|
||||||
|
// Both instances move themselves; what is asserted is that neither hears the other's move.
|
||||||
|
const start = edit.addNode('OnStart');
|
||||||
|
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||||
|
edit.wire([[start, 'exec', set, closed]]);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 });
|
||||||
|
place(f, behavior.id, { x: 10, y: 0 });
|
||||||
|
const started = startWorld(f, { behaviors: true });
|
||||||
|
started.frame();
|
||||||
|
// Each instance hears its own move and nobody else's: two entries, no exits (no state before).
|
||||||
|
expect(started.world.log).toEqual(['Watcher: entered closed', 'Watcher: entered closed']);
|
||||||
|
expect(started.world.log.filter((line) => line.includes('exited'))).toEqual([]);
|
||||||
|
expect(started.behaviors?.machineStates(machine.id).map((s) => s.state)).toEqual([closed, closed]);
|
||||||
|
expect(open).not.toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches every instance when the machine is global, and enters its start once', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const machine = makeMachine(f);
|
||||||
|
const [open, closed] = machine.ids as [string, string];
|
||||||
|
f.stack.execute(new SetMachineScopeCommand(machine.id, 'global'));
|
||||||
|
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||||||
|
const { behavior } = watcher(f, machine.id, machine.ids);
|
||||||
|
const mover = watcher(f, machine.id, machine.ids, 'Mover');
|
||||||
|
const start = mover.edit.addNode('OnStart');
|
||||||
|
const set = mover.edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||||
|
mover.edit.wire([[start, 'exec', set, closed]]);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 });
|
||||||
|
place(f, mover.behavior.id, { x: 10, y: 0 });
|
||||||
|
const started = startWorld(f, { behaviors: true });
|
||||||
|
started.frame();
|
||||||
|
// The start is entered once for the whole run and heard by both; so is the move one of them made.
|
||||||
|
expect(started.world.log).toEqual([
|
||||||
|
'Watcher: entered open',
|
||||||
|
'Mover: entered open',
|
||||||
|
'Watcher: exited open',
|
||||||
|
'Mover: exited open',
|
||||||
|
'Watcher: entered closed',
|
||||||
|
'Mover: entered closed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops a run whose state events keep moving the machine', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const machine = makeMachine(f);
|
||||||
|
const [open, closed] = machine.ids as [string, string];
|
||||||
|
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||||||
|
const behavior = makeBehavior(f, 'Flapper');
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const entered = edit.addNode('OnStateEntered', { [MACHINE_PORT]: machine.id });
|
||||||
|
const toClosed = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||||
|
const toOpen = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||||
|
edit.wire([
|
||||||
|
[entered, open, toClosed, closed],
|
||||||
|
[entered, closed, toOpen, open],
|
||||||
|
]);
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 });
|
||||||
|
const started = startWorld(f, { behaviors: true });
|
||||||
|
started.frame();
|
||||||
|
expect(started.world.stopRequested).toBe(true);
|
||||||
|
expect(started.world.log.some((line) => line.includes('endless loop'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reading the current state', () => {
|
||||||
|
it('hands out the name to show and the id to compare', () => {
|
||||||
|
const f = makeProject();
|
||||||
|
const machine = makeMachine(f);
|
||||||
|
const [open] = machine.ids as [string, string];
|
||||||
|
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||||||
|
const behavior = makeBehavior(f, 'B');
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const start = edit.addNode('OnStart');
|
||||||
|
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||||
|
const current = edit.addNode('CurrentState', { [MACHINE_PORT]: machine.id });
|
||||||
|
const log = edit.addNode('Log');
|
||||||
|
edit.wire([
|
||||||
|
[start, 'exec', set, open],
|
||||||
|
[set, 'exec', log, 'exec'],
|
||||||
|
[current, 'name', log, 'message'],
|
||||||
|
]);
|
||||||
|
const ctx = fakeContext();
|
||||||
|
new GraphInterpreter(behavior.graph, graphLookup(f.project)).fire('OnStart', ctx);
|
||||||
|
expect(ctx.logs).toEqual(['Open']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is one boolean per state, false everywhere while the machine is in none', () => {
|
||||||
|
const f = makeProject();
|
||||||
|
const machine = makeMachine(f);
|
||||||
|
const [open, closed] = machine.ids as [string, string];
|
||||||
|
const behavior = makeBehavior(f, 'B');
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const isIn = edit.addNode('IsInState', { [MACHINE_PORT]: machine.id });
|
||||||
|
const node = behavior.graph.nodes.find((n) => n.id === isIn.id)!;
|
||||||
|
const def = resolveDef(node, behavior.graph, graphLookup(f.project))!;
|
||||||
|
expect(def.outputs.map((p) => `${p.key}:${p.type}`)).toEqual([`${open}:boolean`, `${closed}:boolean`]);
|
||||||
|
const ctx = fakeContext({ states: { [machine.id]: closed } });
|
||||||
|
expect(def.read!(ctx, {}, () => machine.id, def, graphLookup(f.project))).toEqual({
|
||||||
|
[open]: false,
|
||||||
|
[closed]: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('transitions between states', () => {
|
describe('transitions between states', () => {
|
||||||
/** Draws a transition the way the canvas does: out of one state's `out`, into the other's `in`. */
|
/** Draws a transition the way the canvas does: out of one state's `out`, into the other's `in`. */
|
||||||
const connect = (f: ProjectFixture, machineId: string, from: string, to: string) =>
|
const connect = (f: ProjectFixture, machineId: string, from: string, to: string) =>
|
||||||
@@ -407,6 +648,7 @@ describe('transitions between states', () => {
|
|||||||
const machine = {
|
const machine = {
|
||||||
name: 'Guard',
|
name: 'Guard',
|
||||||
states: [],
|
states: [],
|
||||||
|
start: '',
|
||||||
transitions: [
|
transitions: [
|
||||||
{ from: 'patrolling', to: 'chasing', name: 'alarm' },
|
{ from: 'patrolling', to: 'chasing', name: 'alarm' },
|
||||||
{ from: 'searching', to: 'chasing', name: 'alarm' },
|
{ from: 'searching', to: 'chasing', name: 'alarm' },
|
||||||
|
|||||||
+8
-1
@@ -188,7 +188,12 @@ export function fakeHandle(props: PropertyBag, parent: EntityHandle | null = nul
|
|||||||
return { [ENTITY_HANDLE]: true, id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent };
|
return { [ENTITY_HANDLE]: true, id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FakeContext = NodeContext & { logs: string[]; stopped: boolean };
|
/** `changes` records the state moves the context was told about, in the order they were reported. */
|
||||||
|
export type FakeContext = NodeContext & {
|
||||||
|
logs: string[];
|
||||||
|
stopped: boolean;
|
||||||
|
changes: { machineId: string; from: string; to: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
/** A NodeContext double: self/owner handles, `speed = 100`, dt 0.5, moves applied directly, `log` and `stop` recorded. */
|
/** A NodeContext double: self/owner handles, `speed = 100`, dt 0.5, moves applied directly, `log` and `stop` recorded. */
|
||||||
export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||||
@@ -199,6 +204,8 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
|||||||
variables: { speed: 100 },
|
variables: { speed: 100 },
|
||||||
states: {},
|
states: {},
|
||||||
shared: emptySharedState(),
|
shared: emptySharedState(),
|
||||||
|
changes: [],
|
||||||
|
stateChanged: (machineId, from, to) => ctx.changes.push({ machineId, from, to }),
|
||||||
time: 0,
|
time: 0,
|
||||||
dt: 0.5,
|
dt: 0.5,
|
||||||
logs,
|
logs,
|
||||||
|
|||||||
@@ -422,6 +422,124 @@ export async function stateMachine(t) {
|
|||||||
await sleep(200);
|
await sleep(200);
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
|
|
||||||
|
// 26f3. A start state: marked from the state's own menu, worn as a ring, entered by every
|
||||||
|
// instance before anything reads the machine – and heard as `On state entered`.
|
||||||
|
await evaluate("__gm.showTab('statemachine')");
|
||||||
|
await sleep(300);
|
||||||
|
const startOf = () =>
|
||||||
|
json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).start ?? ''`);
|
||||||
|
const ringOn = (id) => json(`__gm.stateMachine.startRing(${JSON.stringify(id)})`);
|
||||||
|
const marks = () => json('__gm.stateMachine.stateMarks()');
|
||||||
|
const startCentre = await centreOfState(first);
|
||||||
|
await click(startCentre.x, startCentre.y, 'right');
|
||||||
|
await sleep(300);
|
||||||
|
const stateMenu = await json('__gm.overlayTexts()');
|
||||||
|
check(
|
||||||
|
'a state offers to become the start state',
|
||||||
|
stateMenu.includes('Set as start state'),
|
||||||
|
JSON.stringify(stateMenu),
|
||||||
|
);
|
||||||
|
await clickLast('Set as start state');
|
||||||
|
await sleep(400);
|
||||||
|
check(
|
||||||
|
'the mark is kept on the asset and drawn as a ring on that state alone',
|
||||||
|
(await startOf()) === first && (await ringOn(first)) === true && (await ringOn(second)) === false,
|
||||||
|
JSON.stringify({ start: await startOf(), first: await ringOn(first), second: await ringOn(second) }),
|
||||||
|
);
|
||||||
|
await shot('05-start-state');
|
||||||
|
|
||||||
|
// Nothing sets the machine any more: what the branch follows can only be the start state.
|
||||||
|
await mutate(`
|
||||||
|
const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
|
||||||
|
b.graph.edges = b.graph.edges.filter((e) => e.id !== 'sm-e1');
|
||||||
|
b.graph.nodes.push(
|
||||||
|
${JSON.stringify(node('sm-entered', 'OnStateEntered', 900, 520, { machine: made.id }))},
|
||||||
|
${JSON.stringify(node('sm-entered-log', 'Log', 1200, 520, { message: 'entered the start' }))},
|
||||||
|
);
|
||||||
|
b.graph.edges.push(${JSON.stringify(edge('sm-e6', 'sm-entered', first, 'sm-entered-log', 'exec'))});
|
||||||
|
`);
|
||||||
|
await sleep(300);
|
||||||
|
await play();
|
||||||
|
await sleep(600);
|
||||||
|
const started = (await world())?.log ?? [];
|
||||||
|
check(
|
||||||
|
'every instance begins in the start state, with no Set state anywhere',
|
||||||
|
started.includes('first state') && !started.includes('second state'),
|
||||||
|
JSON.stringify([...new Set(started)]),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'and hears On state entered for it',
|
||||||
|
started.filter((line) => line === 'entered the start').length === 1,
|
||||||
|
JSON.stringify(started.filter((line) => line === 'entered the start')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 26f4. The tab shows where the run is: weakly for any instance, strongly for the selected one.
|
||||||
|
await evaluate("__gm.showTab('statemachine')");
|
||||||
|
await evaluate('__gm.state.select(null)');
|
||||||
|
await sleep(400);
|
||||||
|
const loose = await marks();
|
||||||
|
check(
|
||||||
|
'while playing, the state the machine is in is marked',
|
||||||
|
loose[first] === 'held' && loose[second] === undefined,
|
||||||
|
JSON.stringify(loose),
|
||||||
|
);
|
||||||
|
await evaluate(
|
||||||
|
"__gm.state.select({ kind: 'object', holderId: __gm.state.activeScene.id, objectId: 'sm-box', objectIds: ['sm-box'] })",
|
||||||
|
);
|
||||||
|
await sleep(400);
|
||||||
|
const picked2 = await marks();
|
||||||
|
check(
|
||||||
|
"selecting the object makes its instance's state the strong one",
|
||||||
|
picked2[first] === 'current',
|
||||||
|
JSON.stringify(picked2),
|
||||||
|
);
|
||||||
|
await shot('06-live-state');
|
||||||
|
await stop();
|
||||||
|
await evaluate("__gm.showTab('statemachine')");
|
||||||
|
await sleep(400);
|
||||||
|
check(
|
||||||
|
'nothing is marked once the run is over',
|
||||||
|
Object.keys(await marks()).length === 0,
|
||||||
|
JSON.stringify(await marks()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 26f5. The same entry takes the mark off again, and the machine is back to starting nowhere.
|
||||||
|
await evaluate('__gm.state.select(null)');
|
||||||
|
await sleep(200);
|
||||||
|
const againCentre = await centreOfState(first);
|
||||||
|
await click(againCentre.x, againCentre.y, 'right');
|
||||||
|
await sleep(300);
|
||||||
|
const onMenu = await json('__gm.overlayTexts()');
|
||||||
|
check(
|
||||||
|
'the same place offers to take the mark off again',
|
||||||
|
onMenu.includes('Remove start state') && !onMenu.includes('Set as start state'),
|
||||||
|
JSON.stringify(onMenu),
|
||||||
|
);
|
||||||
|
await clickLast('Remove start state');
|
||||||
|
await sleep(400);
|
||||||
|
check(
|
||||||
|
'the mark and its ring are gone',
|
||||||
|
(await startOf()) === '' && (await ringOn(first)) === false,
|
||||||
|
JSON.stringify({ start: await startOf(), ring: await ringOn(first) }),
|
||||||
|
);
|
||||||
|
await play();
|
||||||
|
await sleep(600);
|
||||||
|
const nowhere = (await world())?.log ?? [];
|
||||||
|
check(
|
||||||
|
'without a start state the branch follows nothing again',
|
||||||
|
!nowhere.includes('first state') && !nowhere.includes('entered the start'),
|
||||||
|
JSON.stringify([...new Set(nowhere)]),
|
||||||
|
);
|
||||||
|
await stop();
|
||||||
|
// Back to the graph the later sections expect: the set on start, and no state watcher.
|
||||||
|
await mutate(`
|
||||||
|
const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
|
||||||
|
b.graph.nodes = b.graph.nodes.filter((n) => n.id !== 'sm-entered' && n.id !== 'sm-entered-log');
|
||||||
|
b.graph.edges = b.graph.edges.filter((e) => e.id !== 'sm-e6');
|
||||||
|
b.graph.edges.push(${JSON.stringify(edge('sm-e1', 'sm-start', 'exec', 'sm-set', first))});
|
||||||
|
`);
|
||||||
|
await sleep(300);
|
||||||
|
|
||||||
// 26g. An unconnected machine input shows a picker; picking a machine grows the state ports.
|
// 26g. An unconnected machine input shows a picker; picking a machine grows the state ports.
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const a = __gm.state.project.assets.find((x) => x.id === 'sm-logic');
|
const a = __gm.state.project.assets.find((x) => x.id === 'sm-logic');
|
||||||
|
|||||||
Reference in new issue
Block a user