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

+102 -1
View File
@@ -11,6 +11,7 @@ import {
isHandleEnabled,
type NodeContext,
type SharedState,
startStates,
type TraceListener,
type Value,
} from '../../core/graph';
@@ -41,6 +42,21 @@ interface Produced {
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 {
entity: Entity;
interpreter: GraphInterpreter;
@@ -89,6 +105,8 @@ export class BehaviorSystem implements System {
private instances: Instance[] = [];
private world: World | null = null;
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`). */
private declared: Record<string, PropertyBag> = {};
private readonly collisions: CollisionSystem | null;
@@ -128,9 +146,14 @@ export class BehaviorSystem implements System {
start(world: World): void {
this.world = world;
this.instances = [];
this.pending = [];
// Built fresh for every run: a shared variable starts at its declared value each time Play does.
this.sharedState = buildSharedState(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);
}
@@ -138,6 +161,7 @@ export class BehaviorSystem implements System {
stop(): void {
for (const instance of this.instances) this.finish(instance);
this.instances = [];
this.pending = [];
this.world = null;
}
@@ -147,6 +171,8 @@ export class BehaviorSystem implements System {
*/
private syncInstances(world: World): void {
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) {
if (
entity.type !== 'behavior' ||
@@ -171,6 +197,7 @@ export class BehaviorSystem implements System {
} satisfies TraceListener;
const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true };
this.instances.push(instance);
this.enterStartStates(instance, (starts ??= startStates(this.project)));
this.run(
instance,
(i, ctx) => {
@@ -180,6 +207,74 @@ export class BehaviorSystem implements System {
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. */
@@ -250,8 +345,12 @@ export class BehaviorSystem implements System {
},
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.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.
const resolve = (handle: EntityHandle | null): Entity | 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,
states,
shared: this.sharedState,
stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }),
time: world.time,
dt,
findByName: (name) => world.findByName(name) ?? null,