Variables and state machines can reset with every scene (#164)
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:
1 parent
6e3e8d23c5
commit
d7c0b7cfdb
12 files changed
+487
-43
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user