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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user