diff --git a/README.md b/README.md index 2c4149a2..8b157d01 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 692e5926..d0c75fc2 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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 = { + 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 diff --git a/src/core/graph/savedValues.ts b/src/core/graph/savedValues.ts index 290b5b4d..7032a9d4 100644 --- a/src/core/graph/savedValues.ts +++ b/src/core/graph/savedValues.ts @@ -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[] { diff --git a/src/core/graph/sharedState.ts b/src/core/graph/sharedState.ts index cbff5db5..8d56b674 100644 --- a/src/core/graph/sharedState.ts +++ b/src/core/graph/sharedState.ts @@ -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 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 { + 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 { + const ids = new Set(); + 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 { return machineId in ctx.shared.states ? ctx.shared.states : ctx.states; } diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 2b12070a..5db54237 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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 diff --git a/src/core/model/Scope.ts b/src/core/model/Scope.ts index 0c7cc35c..3d6a3435 100644 --- a/src/core/model/Scope.ts +++ b/src/core/model/Scope.ts @@ -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; } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 59ab795b..0f7fc2ce 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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); diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 6da24daf..05d3d8e0 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -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 = { + 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); } diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 5dbb8dc6..8685cf7d 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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 = 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): 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); } diff --git a/tests/core/scene-scope.test.ts b/tests/core/scene-scope.test.ts new file mode 100644 index 00000000..46a9e77a --- /dev/null +++ b/tests/core/scene-scope.test.ts @@ -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) => 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); + }); +}); diff --git a/tools/e2e/32-scope.mjs b/tools/e2e/32-scope.mjs index e8853924..60aea761 100644 --- a/tools/e2e/32-scope.mjs +++ b/tools/e2e/32-scope.mjs @@ -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; diff --git a/tools/e2e/35-load-scene.mjs b/tools/e2e/35-load-scene.mjs index d9e9df67..afbc82f9 100644 --- a/tools/e2e/35-load-scene.mjs +++ b/tools/e2e/35-load-scene.mjs @@ -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(