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

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:
adminandClaude Opus 5 committed 2026-09-15 06:18:20 +02:00
1 parent bc57f61c91
commit 7babf88636
30 files changed
+906 -44

No files matched your search

+34 -1
View File
@@ -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,
+37 -4
View File
@@ -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;
+6
View File
@@ -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;
+9 -1
View File
@@ -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
+2
View File
@@ -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;
} }
/** /**
+26
View File
@@ -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 };
},
};
+26
View File
@@ -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)) }),
};
+2 -2
View File
@@ -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' };
}, },
}; };
+4 -5
View File
@@ -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 } : {};
}, },
}; };
+3 -4
View File
@@ -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' };
}, },
}; };
+8
View File
@@ -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,
+36 -1
View File
@@ -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);
}
+24 -13
View File
@@ -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 }));
} }
+6
View File
@@ -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;
} }
/** /**
+2 -1
View File
@@ -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),
}; };
}, },
}; };
+2
View File
@@ -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() });
+8
View File
@@ -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();
} }
+6
View File
@@ -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;
+10
View File
@@ -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 {
+3
View File
@@ -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,
+65 -1
View File
@@ -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;
+17
View File
@@ -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;
+102 -1
View File
@@ -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,
+5 -3
View File
@@ -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);
} }
}); });
+58 -5
View File
@@ -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());
}); });
+243 -1
View File
@@ -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
View File
@@ -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,
+118
View File
@@ -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');