Variables and state machines can reset with every scene (#164)
game-maker / gate (push) Successful in 2m43s
game-maker / suite (push) Successful in 8m40s

A shared variable or machine used to live either per object or for the
whole run. Levels need a third lifetime: shared like global, but built
fresh from the declarations whenever a scene is loaded.

- New scope `scene` for Variables assets and state machines. A missing
  field keeps the old meaning: `global` for assets, `local` for
  machines, so existing projects do not change.
- Returning to a scene that was visited before is fresh as well. There
  is no memory per scene.
- A `scene` machine announces its start state again after a scene swap,
  so `On state entered` in the new scene hears it. A global machine
  stays quiet because it never left its state.
- The rule lives in two pure functions in `sharedState.ts`
  (`carriedForward`, `restartedAssets`). BehaviorSystem only calls them.
- The inspector shows a Scope dropdown on Variables assets and a third
  step on the machine dropdown, with a note on what `scene` means.

Decisions:
- `scene` and `persistent` exclude each other (variant A). The choice
  dialog was cancelled, so this is an assumption to revisit. A
  scene-scoped asset hides the Persistent switch, and switching to
  `scene` drops the flag (undo restores it). The guard sits in
  `isPersistent` itself, so no path can save values the scope throws
  away anyway.

Findings:
- `clickText('Scene')` hits the Scene tab, not the menu entry. One e2e
  check was red because of that, and its partner was falsely green
  because it compared `undefined` with `undefined`. Both use `clickLast`
  now.

Tests:
- ./check.sh: 144 files, 2142 tests, CHECK EXIT 0 (before 143 / 2133)
- 9 new unit tests in tests/core/scene-scope.test.ts
- e2e 32 fresh: ALL PASSED, 17 checks
- e2e 1-3,17,18,26,32,35 fresh: ALL PASSED, 213 checks
- 3 new e2e checks (35: scene-scoped asset resets on swap, 32h: dropdown,
  note, hidden Persistent switch)
- Counter-checks bite: ignoring `restarted` fails 2 unit tests and the
  new check in 35, the old announcement rule fails 1, dropping the scope
  guard in `isPersistent` fails 1. Originals restored, cmp IDENTICAL.
- Check sum 1307 (1304 + 3) is computed. Last CI measurement: 1293.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 06:06:53 +02:00
1 parent 6e3e8d23c5
commit d7c0b7cfdb
12 files changed
+487 -43

No files matched your search

+13 -6
View File
@@ -2,6 +2,7 @@ import {
adoptDeclarations,
asNumber,
buildSharedState,
carriedForward,
declaredVariables,
emptySharedState,
type EntityHandle,
@@ -11,6 +12,7 @@ import {
isHandleEnabled,
type NodeContext,
type PhysicsControl,
restartedAssets,
restoreSaved,
type SaveArea,
savedSnapshot,
@@ -171,6 +173,8 @@ export class BehaviorSystem implements System {
private sharedState: SharedState = emptySharedState();
/** Handed over by the previous world of the same run; consumed by the next `start`. */
private carried: SharedState | null = null;
/** Which of the carried machines this scene starts over, so their entry is announced again. */
private restarted: ReadonlySet<string> = new Set();
/** 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`). */
@@ -224,9 +228,10 @@ export class BehaviorSystem implements System {
* the world is started.
*/
carryOver(state: Readonly<SharedState>): void {
// The bags themselves travel as they are – a running value is the point – while the map around
// them is copied, so the old system cannot still be writing into what the new one reads.
this.carried = { variables: { ...state.variables }, states: { ...state.states } };
// The bags of what is `global` travel as they are – a running value is the point – while what is
// `scene` is built again from the declarations, so a level counts from zero (#164).
this.carried = carriedForward(this.project, state);
this.restarted = restartedAssets(this.project);
}
start(world: World): void {
@@ -245,9 +250,11 @@ export class BehaviorSystem implements System {
// the move is queued here and every instance the first sync creates hears that one entry.
// Not after a scene swap: the machine is already standing where it stands, and announcing the
// entry again would be a move that never happened.
if (!carried)
for (const [machineId, start] of Object.entries(this.sharedState.states))
if (start) this.pending.push({ machineId, from: '', to: start, source: null });
// A `scene` machine is the exception after a swap: it really was entered again, just now.
for (const [machineId, start] of Object.entries(this.sharedState.states))
if (start && (!carried || this.restarted.has(machineId)))
this.pending.push({ machineId, from: '', to: start, source: null });
this.restarted = new Set();
this.syncInstances(world);
}