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

+46 -11
View File
@@ -4,6 +4,7 @@ import {
SetAssetPropertyCommand,
SetMachineScopeCommand,
SetVariablesPersistentCommand,
SetVariablesScopeCommand,
} from '../../core/commands/assetCommands';
import { isPersistent } from '../../core/graph/savedValues';
import { nextFieldName, nextVariableName } from '../../core/commands/naming';
@@ -19,7 +20,7 @@ import type { Command } from '../../core/commands/Command';
import type { Asset, BehaviorAsset, StateMachineAsset, VariablesAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
import { isGlobal, readScope, scopeOf } from '../../core/model/Scope';
import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope';
import { type StructFieldKind, VARIABLE_KINDS } from '../../core/properties/kinds';
import {
isRowShown,
@@ -52,8 +53,9 @@ export function assetSignature(project: Project, assetId: string): string {
if (!asset) return 'missing';
// A machine's own rows are none, so its scope has to be named here or switching it rebuilds nothing.
const machine = asset.type === 'statemachine' ? `|${scopeOf(asset)}` : '';
// The same for the switch that keeps shared values between runs: its notes and button depend on it.
const kept = asset.type === 'variables' ? `|${isPersistent(asset)}` : '';
// The same for the switch that keeps shared values between runs and for the scope beside it: the
// notes, the button and whether the switch is shown at all depend on the two.
const kept = asset.type === 'variables' ? `|${isPersistent(asset)}|${sharedScopeOf(asset)}` : '';
// Vector art shows its shapes under its rows (#129), so a shape added or renamed is a new structure.
const shapes = asset.type === 'vectorart' ? `|${shapeListSignature(asset)}` : '';
const values = resolveAssetProps(project, asset);
@@ -81,7 +83,10 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void {
buildProps(host, asset, markedAssetSchema(host.state.project, asset));
if (asset.type === 'behavior' || asset.type === 'variables') buildVariables(host, asset);
if (asset.type === 'variables') buildPersistence(host, asset);
if (asset.type === 'variables') {
buildVariablesScope(host, asset);
buildPersistence(host, asset);
}
if (asset.type === 'statemachine') buildMachineScope(host, asset);
// The drawing itself: its shapes are picked here as a texture's frames are picked in its tab (#129).
if (asset.type === 'vectorart') buildShapeListSection(host, asset);
@@ -214,6 +219,12 @@ const PERSISTENT: PropertyDef = { key: 'persistent', label: 'Persistent', kind:
function buildPersistence(host: InspectorHost, asset: VariablesAsset): void {
const set = (value: PropertyValue): void =>
void host.state.execute(new SetVariablesPersistentCommand(asset.id, value === true));
// Nothing to keep between runs when every scene throws the values away – the switch is not shown.
if (sharedScopeOf(asset) === 'scene') {
addNote(host, 'One value each, built again for', 'small');
addNote(host, 'every scene that is loaded.', 'small');
return;
}
const row = createFieldRow(host, PERSISTENT, isPersistent(asset), { onInput: set, onChange: set });
host.body.add(row.row);
// Two lines rather than one: the panel is narrow enough to cut a longer note off mid-sentence.
@@ -247,20 +258,44 @@ const MACHINE_SCOPE: PropertyDef = {
options: [
{ value: 'local', label: 'Local' },
{ value: 'global', label: 'Global' },
{ value: 'scene', label: 'Scene' },
],
};
const MACHINE_SCOPE_NOTES: Record<Scope, string[]> = {
local: ['Each behavior instance keeps its own current state.'],
global: ['Every behavior using this machine shares its', 'current state, for the whole run.'],
scene: ['Shared like global, but it starts over', 'whenever a scene is loaded.'],
};
/** A state machine has no properties of its own, so the inspector shows just its name and this. */
function buildMachineScope(host: InspectorHost, asset: StateMachineAsset): void {
const set = (value: PropertyValue): void =>
void host.state.execute(new SetMachineScopeCommand(asset.id, readScope(value)));
const row = createFieldRow(host, MACHINE_SCOPE, scopeOf(asset), { onInput: set, onChange: set });
host.body.add(row.row);
addNote(
host,
isGlobal(asset)
? 'Every behavior using this machine shares its current state.'
: 'Each behavior instance keeps its own current state.',
'small',
);
// Short lines rather than one: the panel is narrow enough to cut a longer note off mid-sentence.
for (const line of MACHINE_SCOPE_NOTES[scopeOf(asset)]) addNote(host, line, 'small');
}
/** The same switch for a Variables asset, which has no `local`: the asset is the shared thing. */
const VARIABLES_SCOPE: PropertyDef = {
key: 'scope',
label: 'Scope',
kind: 'enum',
default: 'global',
options: [
{ value: 'global', label: 'Global' },
{ value: 'scene', label: 'Scene' },
],
};
function buildVariablesScope(host: InspectorHost, asset: VariablesAsset): void {
const set = (value: PropertyValue): void =>
void host.state.execute(new SetVariablesScopeCommand(asset.id, readSharedScope(value)));
const row = createFieldRow(host, VARIABLES_SCOPE, sharedScopeOf(asset), {
onInput: set,
onChange: set,
});
host.body.add(row.row);
}