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

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1304 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1307 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+57 -3
View File
@@ -15,7 +15,14 @@ import { isSoundFile } from '../model/sound';
import { removeSubtrees } from '../model/hierarchy';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { findAssetOf, objectHolders, type Project } from '../model/Project';
import { type Scope, scopeOf, setScope } from '../model/Scope';
import {
type Scope,
scopeOf,
setScope,
setSharedScope,
type SharedScope,
sharedScopeOf,
} from '../model/Scope';
import type { SceneObject } from '../model/SceneObject';
import { isTextureFile } from '../model/texture';
import { coerceValue, defaultsFor } from '../properties/PropertySchema';
@@ -254,7 +261,16 @@ export class RenameAssetCommand implements Command {
}
}
/** A `global` machine keeps one current state for the whole run instead of one per behavior instance. */
const SCOPE_LABELS: Record<Scope, string> = {
local: 'local',
global: 'global',
scene: 'per scene',
};
/**
* A `global` machine keeps one current state for the whole run instead of one per behavior
* instance; a `scene` machine shares one too, but starts over with every scene (#164).
*/
export class SetMachineScopeCommand implements Command {
readonly label: string;
private previous: Scope = 'local';
@@ -263,7 +279,7 @@ export class SetMachineScopeCommand implements Command {
private readonly assetId: string,
private readonly scope: Scope,
) {
this.label = `Make state machine ${scope}`;
this.label = `Make state machine ${SCOPE_LABELS[scope]}`;
}
execute(project: Project): void {
@@ -311,6 +327,44 @@ function setPersistent(asset: VariablesAsset, on: boolean): void {
else delete asset.persistent;
}
/**
* How long one set of shared values lives: `global` for the whole run, `scene` built again from the
* declarations with every scene that is loaded (#164). Going to `scene` takes the persistent switch
* off, because the two say opposite things about the same values and the scope is what was just asked for.
*/
export class SetVariablesScopeCommand implements Command {
readonly label: string;
private previous: SharedScope = 'global';
private wasPersistent = false;
constructor(
private readonly assetId: string,
private readonly scope: SharedScope,
) {
this.label = scope === 'scene' ? 'Make variables per scene' : 'Make variables global';
}
execute(project: Project): void {
const asset = this.target(project);
this.previous = sharedScopeOf(asset);
this.wasPersistent = asset.persistent === true;
setSharedScope(asset, this.scope);
if (this.scope === 'scene') setPersistent(asset, false);
}
undo(project: Project): void {
const asset = this.target(project);
setSharedScope(asset, this.previous);
setPersistent(asset, this.wasPersistent);
}
private target(project: Project): VariablesAsset {
const asset = findAssetOf(project, this.assetId, 'variables');
if (!asset) throw new CommandError(`Variables ${this.assetId} not found`);
return asset;
}
}
/**
* 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
+7 -1
View File
@@ -1,6 +1,7 @@
import type { VariablesAsset } from '../model/Asset';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project';
import { sharedScopeOf } from '../model/Scope';
import { coerceValue } from '../properties/PropertySchema';
/** What a save area holds: the values of each persistent Variables asset, keyed by asset id. */
@@ -15,8 +16,13 @@ export interface SaveArea {
write(saved: SavedValues): void;
}
/**
* A `scene` asset is never persistent, whatever the flag says (#164): its values are thrown away
* with every scene, so there is nothing about them worth keeping between runs. Answered here rather
* than left to the commands, so no path can produce a saved value the scope would drop anyway.
*/
export function isPersistent(asset: VariablesAsset): boolean {
return asset.persistent === true;
return asset.persistent === true && sharedScopeOf(asset) !== 'scene';
}
function persistentAssets(project: Project): VariablesAsset[] {
+32 -3
View File
@@ -1,6 +1,6 @@
import type { PropertyBag } from '../model/Properties';
import type { Project } from '../model/Project';
import { isGlobal } from '../model/Scope';
import { isPerScene, isShared, sharedScopeOf } from '../model/Scope';
import type { NodeContext, SharedState } from './NodeContext';
import { startStateOf } from './stateMachine';
@@ -27,11 +27,40 @@ export function declaredVariables(project: Project): Record<string, PropertyBag>
export function buildSharedState(project: Project): SharedState {
const shared: SharedState = { variables: declaredVariables(project), states: {} };
for (const asset of project.assets)
if (asset.type === 'statemachine' && isGlobal(asset))
if (asset.type === 'statemachine' && isShared(asset))
shared.states[asset.id] = startStateOf(asset.graph, asset.start);
return shared;
}
/**
* The state the next scene of the same run starts from: what is `global` travels as it stands,
* what is `scene` is built again from the declarations. Fresh bags rather than the carried ones,
* so the world before it cannot still be writing into what this one reads.
*/
export function carriedForward(project: Project, carried: Readonly<SharedState>): SharedState {
const fresh = buildSharedState(project);
const restarted = restartedAssets(project);
const next: SharedState = { variables: {}, states: {} };
for (const [id, bag] of Object.entries(fresh.variables))
next.variables[id] = restarted.has(id) ? bag : (carried.variables[id] ?? bag);
for (const [id, state] of Object.entries(fresh.states))
next.states[id] = restarted.has(id) ? state : (carried.states[id] ?? state);
return next;
}
/**
* The assets a scene swap starts over rather than hands on, by id: the `scene` Variables assets and
* the `scene` machines. Also what tells the new world which machines to announce an entry for.
*/
export function restartedAssets(project: Project): Set<string> {
const ids = new Set<string>();
for (const asset of project.assets) {
if (asset.type === 'variables' && sharedScopeOf(asset) === 'scene') ids.add(asset.id);
if (asset.type === 'statemachine' && isPerScene(asset)) ids.add(asset.id);
}
return ids;
}
/**
* 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.
@@ -80,7 +109,7 @@ export function variableBag(ctx: NodeContext, source: string): PropertyBag {
return ctx.shared.variables[source] ?? {};
}
/** Where a machine's current state is kept: shared when the machine is `global`, else per instance. */
/** Where a machine's current state is kept: shared unless the machine is `local`, else per instance. */
export function stateBag(ctx: NodeContext, machineId: string): Record<string, string> {
return machineId in ctx.shared.states ? ctx.shared.states : ctx.states;
}
+7 -1
View File
@@ -43,11 +43,16 @@ export interface BehaviorAsset extends AssetBase {
*
* A `persistent` asset keeps its values past the end of the run (#166): the next run starts from
* what the last one left, not from `props`. Absent is not persistent.
*
* `scope` says how long one set of values lives: absent is `global`, the whole run, and `scene`
* builds them again from the declarations with every scene that is loaded (#164). A `scene` asset
* is never persistent – what a scene swap throws away has nothing to keep between runs.
*/
export interface VariablesAsset extends AssetBase {
type: 'variables';
variables: PropertyDef[];
persistent?: boolean;
scope?: Scope;
}
/** Placed as a child of the object it belongs to; its position is the offset from that body. */
@@ -159,7 +164,8 @@ export interface AnimationAsset extends AssetBase {
/**
* 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`.
* 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;
* a `scene` machine shares one too, but starts over with every scene that is loaded (#164).
*
* `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
+42 -11
View File
@@ -1,29 +1,60 @@
/**
* Whether a state machine keeps its current state per behavior instance or shares one for the whole
* run. Everything was `local` before scopes existed, so a missing field means `local` and old
* projects load unchanged. Variables are shared by living on a Variables asset instead (#91).
* How long a shared value lives. `local` is one per behavior instance, `global` one for the whole
* run, `scene` one per scene: rebuilt from the declarations whenever a scene is loaded, so what a
* level counts is gone with the level while a score is not (#164).
*
* Everything was `local` before scopes existed, so a missing field means `local` and old projects
* load unchanged. Variables are shared by living on a Variables asset instead (#91) – that asset is
* shared by its nature, so a missing field means `global` there (see `sharedScopeOf`).
*/
export type Scope = 'local' | 'global';
export type Scope = 'local' | 'global' | 'scene';
/** The scopes of something that is shared either way: a Variables asset has no `local`. */
export type SharedScope = 'global' | 'scene';
export interface Scoped {
scope?: Scope;
}
export function scopeOf(owner: Scoped): Scope {
return owner.scope === 'global' ? 'global' : 'local';
if (owner.scope === 'global') return 'global';
return owner.scope === 'scene' ? 'scene' : 'local';
}
export function isGlobal(owner: Scoped): boolean {
return owner.scope === 'global';
/** Whether the value is shared at all: both `global` and `scene` are one value for many instances. */
export function isShared(owner: Scoped): boolean {
return owner.scope === 'global' || owner.scope === 'scene';
}
/** Reads a scope out of loaded data, where anything but the one known word means `local`. */
/** Whether a scene swap rebuilds the value instead of handing it over. */
export function isPerScene(owner: Scoped): boolean {
return owner.scope === 'scene';
}
/** Reads a scope out of loaded data, where anything but the two known words means `local`. */
export function readScope(raw: unknown): Scope {
return raw === 'global' ? 'global' : 'local';
if (raw === 'global') return 'global';
return raw === 'scene' ? 'scene' : 'local';
}
/** Keeps `local` out of the document: the field is present only when it says `global`. */
/** Keeps `local` out of the document: the field is present only when it says more than that. */
export function setScope(owner: Scoped, scope: Scope): void {
if (scope === 'global') owner.scope = 'global';
if (scope === 'local') delete owner.scope;
else owner.scope = scope;
}
/** The scope of something shared by nature, where a missing field is `global` rather than `local`. */
export function sharedScopeOf(owner: Scoped): SharedScope {
return owner.scope === 'scene' ? 'scene' : 'global';
}
/** Reads the scope of something shared by nature out of loaded data. */
export function readSharedScope(raw: unknown): SharedScope {
return raw === 'scene' ? 'scene' : 'global';
}
/** Keeps the default out of the document the other way round: only `scene` is written. */
export function setSharedScope(owner: Scoped, scope: SharedScope): void {
if (scope === 'scene') owner.scope = 'scene';
else delete owner.scope;
}
+6 -3
View File
@@ -53,7 +53,7 @@ import {
type Vec2,
} from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import { readScope } from '../model/Scope';
import { readScope, readSharedScope } from '../model/Scope';
import type { SceneObject } from '../model/SceneObject';
import {
DEFAULT_FILL,
@@ -171,7 +171,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
});
} else if (type === 'variables') {
const shared: VariablesAsset = { id, type, name, props, variables: variables.get(id) ?? [] };
if (a.persistent === true) shared.persistent = true;
// Scope first: a `scene` asset is never persistent, so the switch that cannot apply is dropped.
if (readSharedScope(a.scope) === 'scene') shared.scope = 'scene';
else if (a.persistent === true) shared.persistent = true;
assets.push(shared);
} else if (type === 'statemachine') {
const machine: StateMachineAsset = {
@@ -181,7 +183,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
props,
graph: normalizeGraph(a.graph, (def) => def.type === STATE),
};
if (readScope(a.scope) === 'global') machine.scope = 'global';
const scope = readScope(a.scope);
if (scope !== 'local') machine.scope = scope;
// 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);
+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);
}
+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);
}
+208
View File
@@ -0,0 +1,208 @@
/**
* The third scope (#164). `global` belongs to the run and survives `Load scene`; `scene` belongs to
* the scene and is built again from the declarations with every swap. What a level counts is gone
* with the level, while the score beside it is not.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
SetAssetPropertyCommand,
SetMachineScopeCommand,
SetVariablesPersistentCommand,
SetVariablesScopeCommand,
} from '../../src/core/commands/assetCommands';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
import { isPersistent, STATE, STATE_NAME_PARAM } from '../../src/core/graph';
import type { StateMachineAsset, VariablesAsset } from '../../src/core/model/Asset';
import { addGameSystems } from '../../src/runtime/systems/BehaviorSystem';
import { Input } from '../../src/runtime/systems/Input';
import { World } from '../../src/runtime/World';
import {
graphEditor,
makeBehavior,
makeSpriteScene,
makeVariables,
roundTrip,
type SceneFixture,
} from '../helpers';
/** The sprite scene plus a second scene to swap to. */
function twoScenes(): SceneFixture & { otherId: string } {
const f = makeSpriteScene('Box');
const second = new CreateAssetCommand('scene', 'Level 2');
f.stack.execute(second);
return { ...f, otherId: second.asset.id };
}
/** A machine with one state, `start` set to it, in the given scope. */
function makeMachine(f: SceneFixture, name: string, scope: 'local' | 'global' | 'scene') {
const created = new CreateAssetCommand('statemachine', name);
f.stack.execute(created);
const state = new AddNodeCommand(created.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: 'Idle' });
f.stack.execute(state);
(created.asset as StateMachineAsset).start = state.node.id;
if (scope !== 'local') f.stack.execute(new SetMachineScopeCommand(created.asset.id, scope));
return { id: created.asset.id, stateId: state.node.id };
}
/** Runs the first scene, moves the shared values on, then swaps to the second scene of the same run. */
function swap(
f: SceneFixture & { otherId: string },
move: (system: ReturnType<typeof addGameSystems>) => void,
) {
const input = new Input();
const first = World.instantiate(f.project, f.sceneId);
const before = addGameSystems(first, f.project, input);
move(before);
const second = World.instantiate(f.project, f.otherId);
return addGameSystems(second, f.project, input, { carried: before.shared });
}
/**
* A behavior placed in the second scene that writes `run`/`level` when the machine of that name
* enters its state. What it wrote is the only evidence an entry was announced at all.
*/
function watching(
f: SceneFixture & { otherId: string },
forRun: { id: string; stateId: string },
forLevel: { id: string; stateId: string },
) {
const behavior = makeBehavior(f, 'Watcher', ['run', 'level']);
const editor = graphEditor(f, behavior);
for (const [machine, key] of [
[forRun, 'run'],
[forLevel, 'level'],
] as const) {
const entered = editor.addNode('OnStateEntered', { machine: machine.id });
const one = editor.addNode('Add', { a: 1, b: 0 });
const write = editor.variables([key], { write: true });
editor.wire([
[entered, machine.stateId, write, 'exec'],
[one, 'result', write, key],
]);
}
f.stack.execute(new PlaceAssetCommand(f.otherId, behavior.id, { x: 0, y: 0 }));
return behavior;
}
describe('a Variables asset scoped to the scene', () => {
it('starts over on a scene swap while a global one carries on', () => {
const f = twoScenes();
const run = makeVariables(f, 'Run', ['score']);
const level = makeVariables(f, 'Level', ['enemies']);
f.stack.execute(new SetVariablesScopeCommand(level.id, 'scene'));
f.stack.execute(new SetAssetPropertyCommand(level.id, 'enemies', 5));
const after = swap(f, (before) => {
before.liveVariables(run.id)!.score = 7;
before.liveVariables(level.id)!.enemies = 1;
});
expect(after.liveVariables(run.id)?.score).toBe(7);
expect(after.liveVariables(level.id)?.enemies).toBe(5);
});
it('is built from the declarations at the start of a run, like every other one', () => {
const f = twoScenes();
const level = makeVariables(f, 'Level', ['enemies']);
f.stack.execute(new SetVariablesScopeCommand(level.id, 'scene'));
f.stack.execute(new SetAssetPropertyCommand(level.id, 'enemies', 3));
const world = World.instantiate(f.project, f.sceneId);
const system = addGameSystems(world, f.project, new Input());
expect(system.liveVariables(level.id)?.enemies).toBe(3);
});
it('is never persistent, and switching to it takes the switch off', () => {
const f = twoScenes();
const level = makeVariables(f, 'Level', ['enemies']);
f.stack.execute(new SetVariablesPersistentCommand(level.id, true));
expect(isPersistent(level)).toBe(true);
f.stack.execute(new SetVariablesScopeCommand(level.id, 'scene'));
expect(level.persistent).toBeUndefined();
expect(isPersistent(level)).toBe(false);
f.stack.undo();
expect(level.scope).toBeUndefined();
expect(isPersistent(level)).toBe(true);
});
/** A flag left over from an older file must not outvote the scope it contradicts. */
it('reads as not persistent even when the document says both', () => {
const f = twoScenes();
const level = makeVariables(f, 'Level', ['enemies']);
level.persistent = true;
level.scope = 'scene';
expect(isPersistent(level)).toBe(false);
expect(
(roundTrip(f.project).assets.find((a) => a.id === level.id) as VariablesAsset).persistent,
).toBeUndefined();
});
it('survives a round trip, and an unknown word reads as global', () => {
const f = twoScenes();
const level = makeVariables(f, 'Level', ['enemies']);
f.stack.execute(new SetVariablesScopeCommand(level.id, 'scene'));
const loaded = () => roundTrip(f.project).assets.find((a) => a.id === level.id) as VariablesAsset;
expect(loaded().scope).toBe('scene');
level.scope = 'nonsense' as never;
expect(loaded().scope).toBeUndefined();
});
});
describe('a state machine scoped to the scene', () => {
it('starts over on a scene swap while a global one keeps its state', () => {
const f = twoScenes();
const forRun = makeMachine(f, 'Run', 'global');
const forLevel = makeMachine(f, 'Level', 'scene');
const after = swap(f, (before) => {
before.shared.states[forRun.id] = 'moved';
before.shared.states[forLevel.id] = 'moved';
});
expect(after.shared.states[forRun.id]).toBe('moved');
expect(after.shared.states[forLevel.id]).toBe(forLevel.stateId);
});
it('is shared between instances, the way a global one is', () => {
const f = twoScenes();
const machine = makeMachine(f, 'Level', 'scene');
const world = World.instantiate(f.project, f.sceneId);
const system = addGameSystems(world, f.project, new Input());
expect(system.shared.states[machine.id]).toBe(machine.stateId);
});
it('survives a round trip', () => {
const f = twoScenes();
const machine = makeMachine(f, 'Level', 'scene');
const loaded = roundTrip(f.project).assets.find((a) => a.id === machine.id) as StateMachineAsset;
expect(loaded.scope).toBe('scene');
});
/**
* The entry of a shared machine is announced once, before any instance exists. After a swap a
* `global` machine stands where it stood, so announcing it again would be a move that never
* happened – a `scene` machine really was entered again, and has to say so.
*/
it('announces its entry again in the next scene, where a global one stays quiet', () => {
const f = twoScenes();
const forRun = makeMachine(f, 'Run', 'global');
const forLevel = makeMachine(f, 'Level', 'scene');
const watcher = watching(f, forRun, forLevel);
const input = new Input();
const first = World.instantiate(f.project, f.sceneId);
const before = addGameSystems(first, f.project, input);
first.tick(1 / 60);
const second = World.instantiate(f.project, f.otherId);
addGameSystems(second, f.project, input, { carried: before.shared });
second.tick(1 / 60);
const heard = second.entities.find((e) => e.assetId === watcher.id)!;
expect(heard.props.level).toBe(1);
expect(heard.props.run).toBe(0);
});
});
+35
View File
@@ -244,6 +244,41 @@ export async function scope(t) {
JSON.stringify({ play: menuCleared }),
);
// 32h. The third scope (#164): the switch beside Persistent, what it offers, and that the two
// cannot both be on – `scene` throws the values away with every scene, so there is nothing
// left to keep between runs and the Persistent switch is not even shown.
await selectAsset('sc-vars');
await sleep(200);
const scopeRow = await json(`__gm.inspectorField('Scope')`);
await click(scopeRow.x, scopeRow.y);
const scopes = await json('__gm.menuRows()');
await clickLast('Scene');
await sleep(200);
const scoped = await asset('sc-vars');
const sceneNote = await has('every scene that is loaded.');
check(
'a Variables asset offers Global and Scene, and taking Scene says what it means',
JSON.stringify(scopes) === JSON.stringify(['Global', 'Scene']) && scoped.scope === 'scene' && sceneNote,
JSON.stringify({ scopes, scope: scoped.scope, note: sceneNote }),
);
check(
'a scene-scoped asset is not persistent any more, and cannot be switched to it',
scoped.persistent === undefined && (await json(`__gm.inspectorField('Persistent')`)) === null,
JSON.stringify({ persistent: scoped.persistent, row: await json(`__gm.inspectorField('Persistent')`) }),
);
await shot('05-scene-scope');
const backRow = await json(`__gm.inspectorField('Scope')`);
await click(backRow.x, backRow.y);
await clickLast('Global');
await sleep(200);
const back = await asset('sc-vars');
check(
'switching back to Global takes the scope out of the document and brings the switch back',
back.scope === undefined && Boolean(await json(`__gm.inspectorField('Persistent')`)),
JSON.stringify({ scope: back.scope, persistent: back.persistent }),
);
// 32g. Everything this step added goes again.
await mutate(`
const p = __gm.state.project;
+33 -3
View File
@@ -10,17 +10,27 @@ import { MAKE_OBJECT } from './lib/scaffold.mjs';
* *run* carries on. A Variables asset is the witness, because a fresh run would put it back to
* what it declares. The sounds of the old scene end with it – not by a rule of their own, but
* because the behaviors playing them are destroyed like everything else in that scene.
*
* A second Variables asset scoped to the scene is the other half of that witness (#164): the two
* count the same way in the same graph, and only one of them is still counting after the swap.
*/
const SOURCE = { variablesAsset: 'ls-vars' };
const PER_SCENE = { variablesAsset: 'ls-level' };
/** `On start → Variables{set score = score + 1}`, plus `On key down g → Load scene`. */
/**
* `On start → Variables{set score = score + 1} → the same for the per-scene count`, plus
* `On key down g → Load scene`.
*/
const graphFor = (sceneId, soundId) => ({
nodes: [
node('ls-start', 'OnStart', 0, 0),
node('ls-read', 'Variables', 0, 200, SOURCE, [port('score', 'number')]),
node('ls-add', 'Add', 260, 200, { b: 1 }),
node('ls-write', 'Variables', 560, 0, SOURCE, [written('score', 'number')]),
node('ls-read2', 'Variables', 0, 640, PER_SCENE, [port('count', 'number')]),
node('ls-add2', 'Add', 260, 640, { b: 1 }),
node('ls-write2', 'Variables', 560, 640, PER_SCENE, [written('count', 'number')]),
node('ls-play', 'PlaySound', 820, 0, { sound: soundId, volume: 1, loop: 'true' }),
node('ls-key', 'OnKeyDown', 0, 420, { key: 'g' }),
node('ls-load', 'LoadScene', 260, 420, { scene: sceneId }),
@@ -29,7 +39,10 @@ const graphFor = (sceneId, soundId) => ({
edge('ls-e1', 'ls-start', 'exec', 'ls-write', 'exec'),
edge('ls-e2', 'ls-read', 'score', 'ls-add', 'a'),
edge('ls-e3', 'ls-add', 'result', 'ls-write', 'score'),
edge('ls-e4', 'ls-write', 'exec', 'ls-play', 'exec'),
edge('ls-e4', 'ls-write', 'exec', 'ls-write2', 'exec'),
edge('ls-e6', 'ls-read2', 'count', 'ls-add2', 'a'),
edge('ls-e7', 'ls-add2', 'result', 'ls-write2', 'count'),
edge('ls-e8', 'ls-write2', 'exec', 'ls-play', 'exec'),
edge('ls-e5', 'ls-key', 'exec', 'ls-load', 'exec'),
],
});
@@ -116,6 +129,13 @@ export async function loadScene(t) {
name: 'Run',
props: { score: 0 },
variables: ${JSON.stringify([variable('score', 'number', 0)])},
}, {
id: 'ls-level',
type: 'variables',
name: 'Level',
scope: 'scene',
props: { count: 0 },
variables: ${JSON.stringify([variable('count', 'number', 0)])},
}, {
id: 'ls-sound',
type: 'sound',
@@ -212,6 +232,16 @@ export async function loadScene(t) {
world.shared?.variables?.['ls-vars']?.score === 1,
JSON.stringify(world.shared?.variables),
);
// 35e². The other scope, counted by the same graph in the same frame (#164). The loader stayed
// behind with its scene, so nobody counts in the new one – and the value is the declared 0
// rather than the 1 the score right beside it still carries.
check(
'what belongs to the scene starts over: the per-scene count does not carry',
(before.shared?.variables?.['ls-level']?.count ?? 0) === 1 &&
world.shared?.variables?.['ls-level']?.count === 0 &&
world.shared?.variables?.['ls-vars']?.score === 1,
JSON.stringify({ before: before.shared?.variables, after: world.shared?.variables }),
);
check(
'the sounds of the old scene end with the behaviors that were playing them',
humming.playing >= 1 && (await stats()).playing === 0,
@@ -245,7 +275,7 @@ export async function loadScene(t) {
await mutate(`
const p = __gm.state.project;
p.startSceneId = '';
const gone = new Set(['ls-logic', 'ls-scene', 'ls-vars', 'ls-sound']);
const gone = new Set(['ls-logic', 'ls-scene', 'ls-vars', 'ls-level', 'ls-sound']);
p.assets = p.assets.filter((a) => !gone.has(a.id));
__gm.state.select(null);`);
check(