diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index c99cb249..9b400f2d 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -344,6 +344,31 @@ export class ResetAssetPropertyCommand extends AssetPropertyCommand { } } +/** + * Names the scene a real run begins with, or takes that name back (`''`). A deleted scene is not + * cleaned up here on purpose: the id then simply reads as none, and an undo of the deletion brings + * the start scene back with it. + */ +export class SetStartSceneCommand implements Command { + readonly label = 'Set start scene'; + private previous = ''; + + constructor(private readonly sceneId: string) {} + + execute(project: Project): void { + if (this.sceneId) { + const asset = requireAsset(project, this.sceneId); + if (asset.type !== 'scene') throw new CommandError(`${asset.name} is not a scene`); + } + this.previous = project.startSceneId; + project.startSceneId = this.sceneId; + } + + undo(project: Project): void { + project.startSceneId = this.previous; + } +} + export class SetActiveSceneCommand implements Command { readonly label = 'Switch scene'; private previous = ''; diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 97eb6430..5155c710 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -125,10 +125,20 @@ export interface NodeContext { * because the asset says what a sound *is* and the node only colours it. Every call is a voice of * its own: two plays overlap, whether they loop or not, which is what makes one sound effect * fired twice in a row audible twice. + * + * The voice belongs to the behavior that started it and ends when that behavior is destroyed – + * a sound is something a thing in the world is doing. `detached` gives that up on purpose, for + * the one case where the rule is in the way: the death sound of an object that destroys itself. */ - playSound(assetId: string, volume: number, loop: boolean | undefined): void; + playSound(assetId: string, volume: number, loop: boolean | undefined, detached: boolean): void; /** Silences every voice of that sound; `''` silences everything this run started. */ stopSound(assetId: string): void; + /** + * Asks for another scene, by asset id. The swap happens once the frame is over – a graph must be + * able to finish the flow it is in, and half a frame in the old world and half in the new one is + * the kind of thing nobody can debug. The last request of a frame is the one that happens. + */ + loadScene(sceneId: string): void; /** * The position is local to `parent` (world when none); null when the asset does not exist or * cannot be placed. A spawned behavior runs its `On start` on the next tick. diff --git a/src/core/graph/nodes/action/LoadScene.ts b/src/core/graph/nodes/action/LoadScene.ts new file mode 100644 index 00000000..6c967dec --- /dev/null +++ b/src/core/graph/nodes/action/LoadScene.ts @@ -0,0 +1,35 @@ +import type { NodeDef, PortDef } from '../../NodeRegistry'; +import { asString } from '../../values'; +import { exec } from '../shared'; + +/** + * The `scene` input; unconnected it shows the scene picker, the way `soundPort` shows the sound + * one. A `Scene` literal is what feeds it through an edge. + */ +const scenePort = (): PortDef => ({ + key: 'scene', + label: 'scene', + type: 'string', + field: { key: 'scene', label: 'Scene', kind: 'scene', default: '' }, +}); + +export const LoadScene: NodeDef = { + type: 'LoadScene', + label: 'Load scene', + category: 'action', + description: + 'Replaces the running scene with another one. Everything of the old scene is gone – its ' + + 'objects, their behaviors and the sounds those behaviors were playing; what a Variables asset ' + + 'holds and where a global state machine stands carry over, because those belong to the run and ' + + 'not to the scene. Naming the scene that is already running rebuilds it, which is how a level ' + + 'is reset without losing the score. The swap happens once the frame has finished, so the rest ' + + 'of this graph still runs; asked twice in one frame, the last answer wins.', + inputs: [exec(), scenePort()], + outputs: [exec()], + params: [], + run: (ctx, inputs) => { + const scene = asString(inputs.scene); + if (scene) ctx.loadScene(scene); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/action/PlaySound.ts b/src/core/graph/nodes/action/PlaySound.ts index 3b912a48..ce046e41 100644 --- a/src/core/graph/nodes/action/PlaySound.ts +++ b/src/core/graph/nodes/action/PlaySound.ts @@ -1,5 +1,5 @@ import type { NodeDef, PortDef } from '../../NodeRegistry'; -import { asNumber, asString, type Value } from '../../values'; +import { asBoolean, asNumber, asString, type Value } from '../../values'; import { exec, num, soundPort, withDefault } from '../shared'; /** @@ -34,6 +34,19 @@ export function loopChoice(value: Value | undefined): boolean | undefined { return undefined; } +/** + * The way out of the ownership rule, off by default. Needed for one shape that is common enough to + * deserve it: an object that plays its own death sound and destroys itself in the same flow – the + * voice would be cut off in the instant it started, because the behavior holding it is gone. + */ +const detachedPort = (): PortDef => ({ + key: 'detached', + label: 'detached', + type: 'boolean', + default: false, + field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false }, +}); + export const PlaySound: NodeDef = { type: 'PlaySound', label: 'Play sound', @@ -42,13 +55,15 @@ export const PlaySound: NodeDef = { 'Plays a sound asset. "volume" multiplies the volume the asset itself carries and "loop" either ' + "leaves the asset's own setting alone or overrules it, so the asset says what the sound is and " + 'the node only colours it. Each play is a voice of its own: firing this twice in a row is heard ' + - 'twice, overlapping.', - inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort()], + 'twice, overlapping. The sound belongs to this object and stops when the object is destroyed, ' + + 'unless "detached" is on.', + inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort(), detachedPort()], outputs: [exec()], params: [], run: (ctx, inputs) => { const sound = asString(inputs.sound); - if (sound) ctx.playSound(sound, asNumber(inputs.volume), loopChoice(inputs.loop)); + if (sound) + ctx.playSound(sound, asNumber(inputs.volume), loopChoice(inputs.loop), asBoolean(inputs.detached)); return { next: 'exec' }; }, }; diff --git a/src/core/graph/nodes/data/literals.ts b/src/core/graph/nodes/data/literals.ts index be3225f0..72f2ca2b 100644 --- a/src/core/graph/nodes/data/literals.ts +++ b/src/core/graph/nodes/data/literals.ts @@ -40,6 +40,11 @@ export const SoundLiteral = literal( 'sound', 'A constant sound, for a "Play sound" or a "Stop sound" to work on.', ); +export const SceneLiteral = literal( + 'Scene', + 'scene', + 'A constant scene, for a "Load scene" to work on.', +); export const ListLiteral = literal( 'List', 'list', diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index d80034f5..4269dc5e 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -1,6 +1,7 @@ import { type NodeDef, registerNode } from '../NodeRegistry'; import { Destroy } from './action/Destroy'; import { Emit } from './action/Emit'; +import { LoadScene } from './action/LoadScene'; import { Log } from './action/Log'; import { Move } from './action/Move'; import { PlaySound } from './action/PlaySound'; @@ -34,6 +35,7 @@ import { BooleanLiteral, ListLiteral, NumberLiteral, + SceneLiteral, SoundLiteral, StateMachineLiteral, StringLiteral, @@ -150,6 +152,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ TextureLiteral, StateMachineLiteral, SoundLiteral, + SceneLiteral, Add, Subtract, Multiply, @@ -201,6 +204,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ Emit, PlaySound, StopSound, + LoadScene, Log, StopGame, RestartGame, diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index 86120477..e3ceedcb 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -17,10 +17,28 @@ export interface Project { assets: Asset[]; /** Empty string = none. */ activeSceneId: string; + /** + * Where the game begins – what a player would see first, as opposed to whatever scene happens to + * be open in the editor. Empty (or dangling) means none has been named, and a run falls back to + * the active scene; no format bump needed for that, since an older project reads as exactly that. + */ + startSceneId: string; } export function createEmptyProject(id: string, name: string): Project { - return { formatVersion: PROJECT_FORMAT_VERSION, id, name, assets: [], activeSceneId: '' }; + return { + formatVersion: PROJECT_FORMAT_VERSION, + id, + name, + assets: [], + activeSceneId: '', + startSceneId: '', + }; +} + +/** The scene a real run starts from: the named one, or the active one while none is named. */ +export function startScene(project: Project): SceneAsset | undefined { + return findScene(project, project.startSceneId) ?? findScene(project, project.activeSceneId); } export function findAsset(project: Project, assetId: string): Asset | undefined { diff --git a/src/core/properties/kinds.ts b/src/core/properties/kinds.ts index 5a3d164c..8ebf2ded 100644 --- a/src/core/properties/kinds.ts +++ b/src/core/properties/kinds.ts @@ -120,6 +120,20 @@ export const KINDS = [ field: true, port: true, }, + /** A scene asset's id – a text, so it is tagged in a list like a texture. */ + { + kind: 'scene', + label: 'Scene', + mark: '▣', + travels: 'string', + empty: '', + coerce: text, + tagged: true, + reference: true, + variable: true, + field: true, + port: true, + }, { kind: 'object', label: 'Object', mark: '◇', travels: 'object', port: true }, { kind: 'list', label: 'List', mark: '≡', travels: 'list', variable: true, port: true }, { kind: 'struct', label: 'Struct', mark: '⊞', travels: 'struct', variable: true, port: true }, diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index d9732311..45ee37db 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -178,12 +178,20 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { activeSceneId = first ? first.id : ''; } + // A dangling start scene reads as none at all – the run then falls back to the active scene, + // which is also what every project written before there was such a field does. + const startSceneId = + typeof r.startSceneId === 'string' && assets.some((a) => a.type === 'scene' && a.id === r.startSceneId) + ? r.startSceneId + : ''; + const project: Project = { formatVersion: PROJECT_FORMAT_VERSION, id: typeof r.id === 'string' && r.id ? r.id : fallbackId, name: typeof r.name === 'string' ? r.name : 'Untitled', assets, activeSceneId, + startSceneId, }; return project; } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 920ba366..c420c7d1 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -39,6 +39,7 @@ import { ParticlesEditor } from './particles/ParticlesEditor'; import { ShaderEditor } from './shader/ShaderEditor'; import { StateMachineEditor } from './statemachine/StateMachineEditor'; import { installShortcuts } from './shortcuts'; +import type { PlayFrom } from './settings'; import type { EditorState } from './state'; import { TextureEditor } from './texture/TextureEditor'; import { installFileImport } from './fileImport'; @@ -399,8 +400,31 @@ export class Editor implements PlayHost { return this.editorRun.debugInfo(); } - /** Play state for the check scripts; `time` is the world clock a pause has to freeze. */ - playInfo(): { playing: boolean; paused: boolean; time: number } { - return { playing: this.play.isPlaying, paused: this.play.isPaused, time: this.play.time }; + /** What the start-scene switch says, and the switch itself – for the check scripts. */ + playFromInfo(): { mode: PlayFrom; label: string; scene: string } { + return this.toolbar.playFromInfo(); + } + + get playFromButton(): Widget { + return this.toolbar.playFromButton; + } + + /** The scene selector, so a check can reach it – notably while a run is on (test hook). */ + get sceneButton(): Widget { + return this.toolbar.sceneButton; + } + + /** + * Play state for the check scripts; `time` is the world clock a pause has to freeze. `sceneId` + * is the scene the run is *in* – the only way a check can see a `Load scene` happen, since the + * swap leaves the editor's own active scene alone. + */ + playInfo(): { playing: boolean; paused: boolean; time: number; sceneId: string } { + return { + playing: this.play.isPlaying, + paused: this.play.isPaused, + time: this.play.time, + sceneId: this.play.sceneId, + }; } } diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index f2ff790c..75637d13 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -131,6 +131,13 @@ export class EditorRun { /** True when the run ended: a behavior failed, stopped the game, or asked to start over. */ private control(world: World): boolean { + if (world.loadSceneRequested) { + // The editor run is the scene you are editing, brought to life – swapping it for another one + // would mean editing a scene you are not looking at. Said once per ask, not once per frame. + world.log.push(`Load scene is ignored in the editor run (${world.loadSceneRequested})`); + console.warn('[editor run] Load scene only works while playing'); + world.loadSceneRequested = ''; + } if (world.restartRequested) { this.stop(); this.stale = true; diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index c08b5e13..22ff5ed5 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -93,6 +93,11 @@ export class PlaySession { return this.world?.time ?? 0; } + /** The scene the run is in – not the editor's active one, which a `Load scene` leaves alone. */ + get sceneId(): string { + return this.world?.sceneId ?? ''; + } + syncFromModel(): void { this.world?.syncFromModel(); this.behaviors?.syncDeclarations(); @@ -104,7 +109,7 @@ export class PlaySession { } play(): void { - const scene = this.state.activeScene; + const scene = this.state.sceneToPlay(); if (!scene) return; ui().focus.blur(); // A run starts at second zero, whatever the editor's previews have been counting up to. @@ -120,6 +125,26 @@ export class PlaySession { if (world.stopRequested) this.stop(); } + /** + * Swaps the running scene for another one, inside the same run: the world is torn down and built + * again, while what belongs to the run – the clock, the shared variables, the global machines – + * carries on. The sounds of the old scene end with it, because the behaviors playing them are + * destroyed; a voice that was started detached is nobody's and plays on. + */ + private loadScene(sceneId: string): void { + const old = this.world; + const runtime = this.state.runtimeProject; + if (!old || !runtime) return; + const carried = this.behaviors?.shared ?? null; + old.destroy(); + const world = World.instantiate(runtime, sceneId); + this.world = world; + this.behaviors = addGameSystems(world, runtime, this.input, undefined, soundLibrary, carried); + this.host.gameView.setWorld(world); + this.state.setNotice(this.playNotice(), 'quiet'); + if (world.stopRequested) this.stop(); + } + private playNotice(): string { if (this.state.paused) return 'Paused · Ctrl+Enter resumes · Esc stops'; return this.world?.activeCamera() @@ -168,6 +193,10 @@ export class PlaySession { this.play(); return; } + if (world.loadSceneRequested) { + this.loadScene(world.loadSceneRequested); + return; + } // Mirror what the systems did into the runtime copy so the panels show live values. if (world.syncToModel().structure) this.state.preview(); this.state.runtimeTick(); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 67fe33c3..c8f0e7f8 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -423,6 +423,11 @@ export function installDebugHook(app: Application, state: EditorState, editor: E }, world: () => editor.worldSnapshot(), playInfo: () => editor.playInfo(), + /** The start-scene switch: what it says, and where to click it (its caption is elided). */ + playFrom: () => editor.playFromInfo(), + playFromAt: () => centreOf(editor.playFromButton), + /** The scene selector: where it is, and whether it takes a click (it does, play mode or not). */ + sceneSelect: () => ({ ...centreOf(editor.sceneButton), enabled: editor.sceneButton.enabled }), /** Behaviors running without Play (`Run in editor`), and how long they have been at it. */ editorRun: () => editor.editorRunInfo(), /** Rows the hierarchy shows – collapsed subtrees are not in here. */ diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 7220a990..9a045385 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -242,6 +242,11 @@ const REFERENCES: Record = { label: assetRefLabel, choices: (project) => assetRefChoices(project, ['sound']).map((a) => ({ id: a.id, label: a.name })), }, + scene: { + none: NO_ASSET_LABEL, + label: assetRefLabel, + choices: (project) => assetRefChoices(project, ['scene']).map((a) => ({ id: a.id, label: a.name })), + }, }; /** An id in the words of its reference kind; nothing for a kind that names nothing. */ @@ -368,6 +373,7 @@ export function createField( case 'texture': case 'statemachine': case 'sound': + case 'scene': return referenceField(ctx, def, REFERENCES[def.kind], value, handlers); } } diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 098af121..092a221b 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -1,5 +1,9 @@ import { AddAnimationFramesCommand } from '../../core/commands/animationCommands'; -import { CreateAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands'; +import { + CreateAssetCommand, + SetActiveSceneCommand, + SetStartSceneCommand, +} from '../../core/commands/assetCommands'; import { oneStep } from '../../core/commands/CompoundCommand'; import { nextAssetName } from '../../core/commands/naming'; import { @@ -85,13 +89,23 @@ export class AssetPanel extends Panel { ); } + /** + * What a scene row says about itself: `●` the one the editor shows, `▶` the one a run begins + * with. Two marks rather than one, because they are two different questions and often two + * different scenes – the level you are editing versus the title screen. + */ + private sceneHint(id: string): string { + const { activeSceneId, startSceneId } = this.state.project; + const marks = `${id === startSceneId ? '▶' : ''}${id === activeSceneId ? '●' : ''}`; + return marks ? `scene ${marks}` : 'scene'; + } + private refresh(): void { - const active = this.state.project.activeSceneId; this.list.setItems( this.state.project.assets.map((asset) => ({ id: asset.id, label: asset.name, - hint: asset.type === 'scene' && asset.id === active ? 'scene ●' : asset.type, + hint: asset.type === 'scene' ? this.sceneHint(asset.id) : asset.type, swatch: hasColorSwatch(asset.type) ? Number(asset.props.color ?? 0) : undefined, overridden: assetDeviates(this.state.project, asset), })), @@ -187,6 +201,11 @@ export class AssetPanel extends Panel { onSelect: () => this.state.execute(new SetActiveSceneCommand(id)), disabled: this.state.project.activeSceneId === id, }); + const isStart = this.state.project.startSceneId === id; + items.push({ + label: isStart ? 'Clear start scene' : 'Set as start scene', + onSelect: () => this.state.execute(new SetStartSceneCommand(isStart ? '' : id)), + }); } if (asset.type === 'texture') { items.push({ diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index 5f5b73f2..702fcce7 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -1,7 +1,8 @@ import { Graphics } from 'pixi.js'; import { RenameProjectCommand } from '../../core/commands/projectCommands'; import { listScenes } from '../../core/model/Project'; -import { Button, Dropdown, HStack, Label, Spacer, TextField, Theme, type Widget } from '../../ui'; +import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme, type Widget } from '../../ui'; +import type { PlayFrom } from '../settings'; import type { EditorState, SaveStatus } from '../state'; /** What the toolbar buttons trigger; the editor supplies the implementations. */ @@ -27,9 +28,10 @@ const SAVE_LABELS: Record = { export class Toolbar extends HStack { private readonly bg = new Graphics(); private readonly projectName: TextField; - private readonly sceneButton: Dropdown; + private readonly sceneSelect: Dropdown; private readonly undoButton: Button; private readonly redoButton: Button; + private readonly playFrom: Dropdown; private readonly playButton: Button; private readonly pauseButton: Button; private readonly status: Label; @@ -58,11 +60,11 @@ export class Toolbar extends HStack { this.add(new Spacer(Theme.padding)); this.add(new Label('Scene', { style: 'muted' })); - this.sceneButton = new Dropdown('—', { + this.sceneSelect = new Dropdown('—', { width: 160, - onClick: () => actions.openSceneMenu(this.sceneButton), + onClick: () => actions.openSceneMenu(this.sceneSelect), }); - this.add(this.sceneButton); + this.add(this.sceneSelect); this.add(new Spacer(Theme.padding)); this.undoButton = new Button('Undo', { onClick: () => state.undo() }); @@ -71,6 +73,12 @@ export class Toolbar extends HStack { this.add(this.redoButton); this.add(new Spacer(), 1); + // Next to Play, because it answers a question about Play: which scene does this start? + this.playFrom = new Dropdown('—', { + width: 130, + onClick: () => this.openPlayFromMenu(), + }); + this.add(this.playFrom); this.playButton = new Button('▶ Play', { variant: 'primary', width: 110, @@ -102,6 +110,33 @@ export class Toolbar extends HStack { this.refresh(); } + /** The start-scene switch for the check scripts: what it says, and where to click it. */ + playFromInfo(): { mode: PlayFrom; label: string; scene: string } { + return { + mode: this.state.playFrom, + label: this.playFrom.text, + scene: this.state.sceneToPlay()?.name ?? '', + }; + } + + /** The switch itself, so a check can open it without hunting for its (elided) caption. */ + get playFromButton(): Dropdown { + return this.playFrom; + } + + /** The scene selector, for the check scripts – its caption is a scene name, not a fixed label. */ + get sceneButton(): Dropdown { + return this.sceneSelect; + } + + private openPlayFromMenu(): void { + const mark = (from: PlayFrom, text: string) => (this.state.playFrom === from ? `● ${text}` : text); + new Menu([ + { label: mark('start', 'Start scene'), onSelect: () => this.state.setPlayFrom('start') }, + { label: mark('active', 'Active scene'), onSelect: () => this.state.setPlayFrom('active') }, + ]).openBelow(this.playFrom); + } + /** Frame rate readout; `null` while no measurement is available yet. */ setFps(fps: number | null): void { this.fps.setText(fps === null ? '– fps' : `${fps} fps`); @@ -112,20 +147,28 @@ export class Toolbar extends HStack { this.projectName.setValue(state.project.name); const scene = state.activeScene; const scenes = listScenes(state.project); - this.sceneButton.setText(scene ? scene.name : scenes.length ? 'Select scene' : 'No scene'); + this.sceneSelect.setText(scene ? scene.name : scenes.length ? 'Select scene' : 'No scene'); const editing = state.mode === 'edit'; + const from = state.sceneToPlay(); + // The mode, worded exactly like the menu entry it came from – a caption that reads differently + // from the thing you picked makes you check twice whether you picked the right one. + this.playFrom.setText(state.playFrom === 'active' ? 'Active scene' : 'Start scene'); + this.playFrom.setEnabled(editing); // Undo/redo work in play mode too – on the runtime copy's own history. this.undoButton.setEnabled(state.commands.canUndo); this.redoButton.setEnabled(state.commands.canRedo); this.playButton.setText(editing ? '▶ Play' : '■ Stop'); this.playButton.variant = editing ? 'primary' : 'danger'; - this.playButton.setEnabled(scene !== undefined); + this.playButton.setEnabled(from !== undefined); this.playButton.invalidate(); this.pauseButton.setText(state.paused ? '▶ Resume' : '❚❚ Pause'); this.pauseButton.setEnabled(!editing); this.pauseButton.invalidate(); this.projectName.setEnabled(editing); - this.sceneButton.setEnabled(editing); + // Stays usable in play mode: it only says which scene the editor looks at, which is the same + // thing a double-click in the Assets panel does – and that one was never blocked. The run is + // not touched by it; a running scene is swapped by `Load scene`, not from here. + this.sceneSelect.setEnabled(true); this.status.setText(SAVE_LABELS[state.saveStatus]); this.status.setStyle(state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small'); } diff --git a/src/editor/settings.ts b/src/editor/settings.ts new file mode 100644 index 00000000..dce7faf9 --- /dev/null +++ b/src/editor/settings.ts @@ -0,0 +1,47 @@ +/** + * The handful of things the editor remembers about *how you work*, as opposed to what you built. + * They belong to this browser, not to the project: a second person opening the same project has + * their own answer, and none of this may end up in a diff of the project file. + * + * Storage is best effort. A browser that refuses `localStorage` (private mode, a blocked origin) + * gets the defaults and keeps working – a setting is not worth an error. + */ + +const KEY = 'game-maker.editor'; + +/** Which scene Play starts: the one named as the start scene, or whatever is open right now. */ +export type PlayFrom = 'start' | 'active'; + +export interface EditorSettings { + playFrom: PlayFrom; +} + +const DEFAULTS: EditorSettings = { playFrom: 'start' }; + +let current: EditorSettings | null = null; + +export function settings(): EditorSettings { + if (current) return current; + current = { ...DEFAULTS }; + try { + const raw = window.localStorage.getItem(KEY); + const read: unknown = raw ? JSON.parse(raw) : null; + if (read && typeof read === 'object') { + const { playFrom } = read as Partial; + if (playFrom === 'start' || playFrom === 'active') current.playFrom = playFrom; + } + } catch { + // Unreadable or refused: the defaults stand. + } + return current; +} + +export function updateSettings(change: Partial): void { + const next = { ...settings(), ...change }; + current = next; + try { + window.localStorage.setItem(KEY, JSON.stringify(next)); + } catch { + // Not stored is not a failure worth reporting; the session keeps the value either way. + } +} diff --git a/src/editor/state.ts b/src/editor/state.ts index 700a76c4..5e2b3651 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -11,10 +11,12 @@ import { findObjectHolder, findScene, type Project, + startScene, } from '../core/model/Project'; import { deserializeProject, serializeProject } from '../core/store/serialize'; import { requestRender } from '../ui'; import { consoleLog } from './console/log'; +import { type PlayFrom, settings, updateSettings } from './settings'; export type Selection = | { kind: 'asset'; assetId: string } @@ -193,6 +195,30 @@ export class EditorState { return findScene(this.project, this.project.activeSceneId); } + /** The play-mode copy while a run is on, else null – what a scene swap rebuilds its world from. */ + get runtimeProject(): Project | null { + return this.mode === 'play' ? this.project : null; + } + + /** + * Which scene Play builds its world from. Two answers rather than one, because the project has + * two: where the game begins, and what you are working on this minute. The switch says which + * question Play is asking; without a start scene the two are the same thing anyway. + */ + sceneToPlay(): SceneAsset | undefined { + return this.playFrom === 'active' ? this.activeScene : startScene(this.project); + } + + get playFrom(): PlayFrom { + return settings().playFrom; + } + + setPlayFrom(playFrom: PlayFrom): void { + if (playFrom === this.playFrom) return; + updateSettings({ playFrom }); + this.emit('mode'); + } + /** * Runs a command through the undo stack. Returns false (and sets a notice) when invalid. * In play mode the command changes the runtime copy only (see `enterPlay`). diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 5bb2d64c..afde4c53 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -39,6 +39,12 @@ export class World { stopRequested = false; /** Set by a behavior (Restart game node); the editor restarts the scene on the next tick. */ restartRequested = false; + /** + * Set by a behavior (Load scene node): the scene to swap to once this tick is over, `''` for + * none asked. Asked twice in a tick, the last one wins – a flag, not a queue, because loading + * two scenes in a row is not a thing anyone means. + */ + loadSceneRequested = ''; /** * Mouse state in world units, fed by the game view (the only place with the camera * transform). `pressed`/`released` last for one tick, like the keyboard edges. diff --git a/src/runtime/systems/Audio.ts b/src/runtime/systems/Audio.ts index 44f62ec0..f9d6d5c4 100644 --- a/src/runtime/systems/Audio.ts +++ b/src/runtime/systems/Audio.ts @@ -7,13 +7,21 @@ * holding the project. Everything below this line only knows bytes and voices. */ export interface AudioSink { - /** Starts one voice. `volume` is final (0–1) and every call overlaps whatever is already playing. */ - play(file: string, volume: number, loop: boolean): void; + /** + * Starts one voice. `volume` is final (0–1) and every call overlaps whatever is already playing. + * `owner` is the behavior the sound belongs to: when that behavior is destroyed the voice goes + * with it, because a sound is something a thing in the world is doing, not a thing of its own. + * `''` means the voice has no owner and outlives whatever started it. + */ + play(file: string, volume: number, loop: boolean, owner: string): void; /** Silences every voice of that file; `''` silences all of them. */ stop(file: string): void; + /** Silences every voice started by that owner – what destroying a behavior does. */ + stopOwner(owner: string): void; } export const SILENT_AUDIO: AudioSink = { play: () => {}, stop: () => {}, + stopOwner: () => {}, }; diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 0f8f036b..06811593 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -18,7 +18,7 @@ import { import { stepKeyRepeat } from '../../core/graph/keyRepeat'; import { graphLookup } from '../../core/model/graphLookup'; import { type PropertyBag, textOf } from '../../core/model/Properties'; -import { findAssetOf, type Project } from '../../core/model/Project'; +import { findAssetOf, findScene, type Project } from '../../core/model/Project'; import { soundFileOf } from '../../core/model/sound'; import { parentChain } from '../../core/model/walk'; import { resolveAssetProps } from '../../core/properties/resolve'; @@ -85,10 +85,13 @@ export function addGameSystems( input: Input, shouldRun?: (entity: Entity) => boolean, audio: AudioSink = SILENT_AUDIO, + /** What a scene swap hands over from the world before it (see `BehaviorSystem.carryOver`). */ + carried: Readonly | null = null, ): BehaviorSystem { const collisions = new CollisionSystem(); const animations = new AnimationSystem(); const behaviors = new BehaviorSystem(project, input, { collisions, animations, audio }, shouldRun); + if (carried) behaviors.carryOver(carried); world.addSystem(collisions); world.addSystem(animations); world.addSystem(behaviors); @@ -112,6 +115,8 @@ export class BehaviorSystem implements System { private instances: Instance[] = []; private world: World | null = null; private sharedState: SharedState = emptySharedState(); + /** Handed over by the previous world of the same run; consumed by the next `start`. */ + private carried: SharedState | null = null; /** 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`). */ @@ -152,23 +157,43 @@ export class BehaviorSystem implements System { this.declared = now; } + /** + * Takes over the shared state of the world before it – the values of the Variables assets and + * where the global machines stand – instead of building a fresh one. That is what belongs to the + * run rather than to the scene, and it is what makes a score survive the next level. Call before + * 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 } }; + } + start(world: World): void { this.world = world; this.instances = []; this.pending = []; + const carried = this.carried; + this.carried = null; // Built fresh for every run: a shared variable starts at its declared value each time Play does. - this.sharedState = buildSharedState(this.project); + this.sharedState = carried ?? buildSharedState(this.project); this.declared = declaredVariables(this.project); // A `global` machine is entered once for the whole run, before the first instance exists – so // the move is queued here and every instance the first sync creates hears that one entry. - for (const [machineId, start] of Object.entries(this.sharedState.states)) - if (start) this.pending.push({ machineId, from: '', to: start, source: null }); + // 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 }); this.syncInstances(world); } /** Tearing the world down (stop, restart) destroys every object, so each instance hears it. */ stop(): void { - for (const instance of this.instances) this.finish(instance); + for (const instance of this.instances) { + this.finish(instance); + this.audio.stopOwner(instance.entity.id); + } this.instances = []; this.pending = []; this.world = null; @@ -291,7 +316,12 @@ export class BehaviorSystem implements System { const gone = this.instances.filter((i) => !world.entities.includes(i.entity)); if (gone.length === 0) return; this.instances = this.instances.filter((i) => !gone.includes(i)); - for (const instance of gone) this.finish(instance); + for (const instance of gone) { + this.finish(instance); + // After `On destroy`, not before: that graph may still say something, and a sound it wants + // to outlive the object says so itself by being detached. + this.audio.stopOwner(instance.entity.id); + } } private finish(instance: Instance): void { @@ -454,12 +484,12 @@ export class BehaviorSystem implements System { * node's, and its loop flag unless the node overrules it either way. The asset is what a sound * *is* – a node that says nothing therefore plays it as the project describes it. */ - private playSound(assetId: string, volume: number, loop: boolean | undefined): void { + private playSound(assetId: string, volume: number, loop: boolean | undefined, owner: string): void { const asset = findAssetOf(this.project, assetId, 'sound'); if (!asset?.file) return; const props = resolveAssetProps(this.project, asset); const level = Math.max(0, Math.min(1, volumeOf(props) * volume)); - this.audio.play(asset.file, level, loop ?? loopsOf(props)); + this.audio.play(asset.file, level, loop ?? loopsOf(props), owner); } /** Runs graph code for one instance; an exception stops play and is logged, never thrown. */ @@ -521,7 +551,8 @@ export class BehaviorSystem implements System { // eslint-disable-next-line no-console console.log(`[behavior ${entity.name}]`, message); }, - playSound: (assetId, volume, loop) => this.playSound(assetId, volume, loop), + playSound: (assetId, volume, loop, detached) => + this.playSound(assetId, volume, loop, detached ? '' : entity.id), // An id nothing resolves stops nothing, rather than silencing the whole run by accident; // only the empty id means "everything", which is what the node promises. stopSound: (assetId) => { @@ -533,6 +564,12 @@ export class BehaviorSystem implements System { const found = resolve(target); if (found) world.remove(found); }, + loadScene: (sceneId) => { + // An id naming no scene is dropped here rather than half way through the swap, where the + // run would already have torn down the world it is still standing in. + if (findScene(this.project, sceneId)) world.loadSceneRequested = sceneId; + else world.log.push(`Load scene: no scene ${sceneId}`); + }, stop: () => { world.stopRequested = true; }, diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 7af11405..df1a28d8 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -2,9 +2,11 @@ import type { AudioSink } from '../runtime/systems/Audio'; const DEFAULT_BASE = '/game-maker/data/sounds'; -/** One sound in flight: its source, its own gain, and the file it came from. */ +/** One sound in flight: its source, its own gain, the file it came from and who is making it. */ interface Voice { file: string; + /** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */ + owner: string; source: AudioBufferSourceNode; gain: GainNode; } @@ -42,37 +44,46 @@ export class SoundLibrary implements AudioSink { */ private readonly stops = new Map(); private allStops = 0; + /** The same counter per owner, for the same reason – a behavior destroyed while bytes were in + * flight must not be given its voice afterwards. */ + private readonly ownerStops = new Map(); /** Where the files are served from. Set once at start-up, before anything plays. */ configure(base: string): void { this.base = base.replace(/\/+$/, ''); } - play(file: string, volume: number, loop: boolean): void { + play(file: string, volume: number, loop: boolean, owner = ''): void { if (file === '' || this.failed.has(file)) return; const buffer = this.buffers.get(file); if (buffer) { - this.start(file, buffer, volume, loop); + this.start(file, buffer, volume, loop, owner); return; } - const askedAt = this.stopsFor(file); + const askedAt = this.cancelsFor(file, owner); void this.load(file).then((loaded) => { - // A `Stop sound` that happened while the bytes were in flight has to win, or a sound the - // graph already silenced would start afterwards and play on its own. - if (loaded && this.stopsFor(file) === askedAt) this.start(file, loaded, volume, loop); + // A `Stop sound` – or the destruction of the behavior that asked – that happened while the + // bytes were in flight has to win, or a sound the run already ended would start afterwards. + if (loaded && this.cancelsFor(file, owner) === askedAt) this.start(file, loaded, volume, loop, owner); }); } stop(file: string): void { - const going = file === '' ? this.voices : this.voices.filter((voice) => voice.file === file); - for (const voice of going) voice.source.stop(); - // `onended` removes each one, but not before the next frame – the count has to be right now. - this.voices = this.voices.filter((voice) => !going.includes(voice)); - this.stopped += going.length; + this.silence(file === '' ? this.voices : this.voices.filter((voice) => voice.file === file)); if (file === '') this.allStops += 1; else this.stops.set(file, (this.stops.get(file) ?? 0) + 1); } + /** + * Every voice of one behavior. An empty owner is deliberately no one: those are the voices that + * were started to outlive their behavior, so "stop the owner" must not reach them. + */ + stopOwner(owner: string): void { + if (owner === '') return; + this.silence(this.voices.filter((voice) => voice.owner === owner)); + this.ownerStops.set(owner, (this.ownerStops.get(owner) ?? 0) + 1); + } + /** Every voice at once: what ending a run does, so nothing outlives the world that started it. */ stopAll(): void { this.stop(''); @@ -137,7 +148,14 @@ export class SoundLibrary implements AudioSink { return work; } - private start(file: string, buffer: AudioBuffer, volume: number, loop: boolean): void { + /** Ends these voices now: `onended` only fires next frame and the counts have to be right here. */ + private silence(going: Voice[]): void { + for (const voice of going) voice.source.stop(); + this.voices = this.voices.filter((voice) => !going.includes(voice)); + this.stopped += going.length; + } + + private start(file: string, buffer: AudioBuffer, volume: number, loop: boolean, owner: string): void { const context = this.audio(); // A context starts suspended where the page has not been interacted with; without this the // voice would run silently and end as if it had played. @@ -149,7 +167,7 @@ export class SoundLibrary implements AudioSink { gain.gain.value = Math.max(0, Math.min(1, volume)); this.last = { file, volume: gain.gain.value, loop }; source.connect(gain).connect(context.destination); - const voice: Voice = { file, source, gain }; + const voice: Voice = { file, owner, source, gain }; source.onended = () => { this.voices = this.voices.filter((other) => other !== voice); }; @@ -162,9 +180,9 @@ export class SoundLibrary implements AudioSink { return (this.context ??= new AudioContext()); } - /** Stops this file has seen, its share of the "everything" ones included. */ - private stopsFor(file: string): number { - return (this.stops.get(file) ?? 0) + this.allStops; + /** Everything that would have silenced this file for this owner, counted rather than flagged. */ + private cancelsFor(file: string, owner: string): number { + return (this.stops.get(file) ?? 0) + this.allStops + (this.ownerStops.get(owner) ?? 0); } } diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index 7da67588..06a0e1a3 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -142,7 +142,14 @@ describe('CommandStack', () => { }); it('keeps a command on the redo stack when its redo is rejected', () => { - const project = { formatVersion: 1, id: 'p', name: 'P', assets: [], activeSceneId: '' }; + const project = { + formatVersion: 1, + id: 'p', + name: 'P', + assets: [], + activeSceneId: '', + startSceneId: '', + }; const stack = new CommandStack(project); let failing = false; stack.execute({ diff --git a/tests/core/load-scene.test.ts b/tests/core/load-scene.test.ts new file mode 100644 index 00000000..1feea1cf --- /dev/null +++ b/tests/core/load-scene.test.ts @@ -0,0 +1,125 @@ +/** + * A game is more than one scene. `Load scene` swaps the running one for another inside the same + * run: the scene is rebuilt from scratch, while what belongs to the run – the shared variables and + * the global machines – carries on. Where a run *begins* is a second question, and the project + * answers it separately from whatever scene is open in the editor. + */ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + SetActiveSceneCommand, + SetStartSceneCommand, +} from '../../src/core/commands/assetCommands'; +import { CommandError } from '../../src/core/commands/Command'; +import { startScene } from '../../src/core/model/Project'; +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, + place, + roundTrip, + startWorld, + type SceneFixture, +} from '../helpers'; + +/** The sprite scene plus a second scene, and the id of that second one. */ +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 }; +} + +describe('the start scene', () => { + it('is what a run begins with, and falls back to the active scene while none is named', () => { + const f = twoScenes(); + expect(f.project.startSceneId).toBe(''); + expect(startScene(f.project)?.id).toBe(f.sceneId); + + f.stack.execute(new SetStartSceneCommand(f.otherId)); + f.stack.execute(new SetActiveSceneCommand(f.sceneId)); + expect(startScene(f.project)?.id).toBe(f.otherId); + }); + + it('can be taken back, and reads as none once the scene it names is gone', () => { + const f = twoScenes(); + f.stack.execute(new SetStartSceneCommand(f.otherId)); + f.stack.execute(new SetStartSceneCommand('')); + expect(f.project.startSceneId).toBe(''); + f.stack.undo(); + expect(f.project.startSceneId).toBe(f.otherId); + + f.project.startSceneId = 'no-such-scene'; + expect(startScene(f.project)?.id).toBe(f.project.activeSceneId); + }); + + it('refuses an asset that is not a scene', () => { + const f = twoScenes(); + expect(() => f.stack.execute(new SetStartSceneCommand(f.spriteId))).toThrow(CommandError); + }); + + it('survives a round trip and drops an id no scene answers to', () => { + const f = twoScenes(); + f.stack.execute(new SetStartSceneCommand(f.otherId)); + expect(roundTrip(f.project).startSceneId).toBe(f.otherId); + + f.project.startSceneId = 'gone'; + expect(roundTrip(f.project).startSceneId).toBe(''); + }); +}); + +describe('Load scene', () => { + /** A behavior asking for `sceneId` on start, placed below the box. */ + function asking(f: SceneFixture, sceneId: string) { + const behavior = makeBehavior(f, 'Logic'); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + place(f, behavior.id, { x: 0, y: 0 }, box); + const edit = graphEditor(f, behavior); + const start = edit.addNode('OnStart'); + const load = edit.addNode('LoadScene', { scene: sceneId }); + edit.connect(start, 'exec', load, 'exec'); + return { behavior, load }; + } + + it('asks the world for the scene, once the frame is over rather than mid-flow', () => { + const f = twoScenes(); + asking(f, f.otherId); + const { world, frame } = startWorld(f, { behaviors: true }); + frame(); + // Still standing in the old world: the swap is the caller's to make, once the tick is over. + expect(world.loadSceneRequested).toBe(f.otherId); + expect(world.entities.some((e) => e.name === 'Box')).toBe(true); + }); + + it('asks for nothing when the id names no scene, and says so in the log', () => { + const f = twoScenes(); + asking(f, 'no-such-scene'); + const { world, frame } = startWorld(f, { behaviors: true }); + frame(); + expect(world.loadSceneRequested).toBe(''); + expect(world.log.some((line) => line.startsWith('Load scene: no scene'))).toBe(true); + }); + + /** The point of the whole exercise: a score that survives the next level. */ + it('carries the shared variables into the world of the next scene', () => { + const f = twoScenes(); + const vars = makeVariables(f, 'Game', ['score']); + const input = new Input(); + + const first = World.instantiate(f.project, f.sceneId); + const behaviors = addGameSystems(first, f.project, input); + behaviors.liveVariables(vars.id)!.score = 7; + + const second = World.instantiate(f.project, f.otherId); + const carried = addGameSystems(second, f.project, input, undefined, undefined, behaviors.shared); + expect(carried.liveVariables(vars.id)?.score).toBe(7); + + // A fresh run, by contrast, starts at what the asset declares. + const third = World.instantiate(f.project, f.otherId); + expect(addGameSystems(third, f.project, input).liveVariables(vars.id)?.score).toBe(0); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 90f35faf..c5ad504a 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -419,6 +419,10 @@ const CASES: Record = { { name: 'outputs the id it was set to', params: { value: 'snd-1' }, outputs: { value: 'snd-1' } }, { name: 'unset is no sound', outputs: { value: '' } }, ], + Scene: [ + { name: 'outputs the id it was set to', params: { value: 'sc-1' }, outputs: { value: 'sc-1' } }, + { name: 'unset is no scene', outputs: { value: '' } }, + ], Add: [ { name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } }, { name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } }, @@ -812,7 +816,14 @@ const CASES: Record = { // What the two mean – a factor on the asset's volume, an answer that overrules its loop // flag – is the BehaviorSystem's to resolve, since only it holds the project (see // `sounds.test.ts`). - after: (ctx) => expect(ctx.played).toEqual([{ assetId: 'snd-1', volume: 0.5, loop: true }]), + after: (ctx) => + expect(ctx.played).toEqual([{ assetId: 'snd-1', volume: 0.5, loop: true, detached: false }]), + }, + { + name: 'detached says the voice is nobody’s, so destroying this object leaves it playing', + inputs: { sound: 'snd-1', volume: 1, loop: '', detached: true }, + next: 'exec', + after: (ctx) => expect(ctx.played[0]?.detached).toBe(true), }, { name: 'saying nothing about looping leaves the answer to the asset', @@ -827,6 +838,20 @@ const CASES: Record = { after: (ctx) => expect(ctx.played[0]?.loop).toBe(false), }, ], + LoadScene: [ + { + name: 'asks the context for the scene it names', + inputs: { scene: 'sc-1' }, + next: 'exec', + after: (ctx) => expect(ctx.loaded).toEqual(['sc-1']), + }, + { + name: 'naming no scene asks for nothing, and the flow carries on', + inputs: { scene: '' }, + next: 'exec', + after: (ctx) => expect(ctx.loaded).toEqual([]), + }, + ], StopSound: [ { name: 'silences the sound it names', diff --git a/tests/core/sounds.test.ts b/tests/core/sounds.test.ts index 4b499d54..5a3dfe35 100644 --- a/tests/core/sounds.test.ts +++ b/tests/core/sounds.test.ts @@ -32,14 +32,18 @@ const FILE = `${'a1'.repeat(32)}.wav`; /** Records what it was asked to play instead of making a noise – the AudioSink a test wants. */ function fakeAudio(): AudioSink & { - played: { file: string; volume: number; loop: boolean }[]; + played: { file: string; volume: number; loop: boolean; owner: string }[]; stopped: string[]; + stoppedOwners: string[]; } { const sink = { - played: [] as { file: string; volume: number; loop: boolean }[], + played: [] as { file: string; volume: number; loop: boolean; owner: string }[], stopped: [] as string[], - play: (file: string, volume: number, loop: boolean) => sink.played.push({ file, volume, loop }), + stoppedOwners: [] as string[], + play: (file: string, volume: number, loop: boolean, owner: string) => + sink.played.push({ file, volume, loop, owner }), stop: (file: string) => sink.stopped.push(file), + stopOwner: (owner: string) => sink.stoppedOwners.push(owner), }; return sink; } @@ -133,7 +137,7 @@ describe('Play sound', () => { const f = playingScene(); const audio = fakeAudio(); startWorld(f, { behaviors: true, audio }).frame(); - expect(audio.played).toEqual([{ file: FILE, volume: 1, loop: false }]); + expect(audio.played).toMatchObject([{ file: FILE, volume: 1, loop: false }]); }); it('scales the asset volume rather than replacing it, and clamps the product', () => { @@ -203,6 +207,48 @@ describe('Play sound', () => { }); }); +/** + * A sound is something a thing in the world is *doing*, so it ends when that thing does – the + * `Stop sound` you would otherwise have to remember in every `On destroy`. + */ +describe('a voice belongs to the behavior that started it', () => { + it('names that behavior as its owner, and nobody when the node is detached', () => { + const owned = playingScene(); + const ownedAudio = fakeAudio(); + const run = startWorld(owned, { behaviors: true, audio: ownedAudio }); + run.frame(); + const logic = run.world.entities.find((e) => e.name === 'Logic')!; + expect(ownedAudio.played[0]?.owner).toBe(logic.id); + + const free = playingScene({ detached: true }); + const freeAudio = fakeAudio(); + startWorld(free, { behaviors: true, audio: freeAudio }).frame(); + expect(freeAudio.played[0]?.owner).toBe(''); + }); + + it('is silenced when the behavior is destroyed, after On destroy has had its say', () => { + const f = playingScene(); + const edit = graphEditor(f, f.behavior); + const key = edit.addNode('OnKeyDown', { key: 'x' }); + const owner = edit.addNode('Owner'); + const destroy = edit.addNode('Destroy'); + edit.wire([ + [key, 'exec', destroy, 'exec'], + [owner, 'object', destroy, 'object'], + ]); + + const audio = fakeAudio(); + const { world, input, frame } = startWorld(f, { behaviors: true, audio }); + frame(); + const logic = world.entities.find((e) => e.name === 'Logic')!; + expect(audio.stoppedOwners).toEqual([]); + + input.press('x'); + frame(); + expect(audio.stoppedOwners).toEqual([logic.id]); + }); +}); + describe('Stop sound', () => { it('silences one sound by its file, and everything when it names none', () => { const f = playingScene(); diff --git a/tests/helpers.ts b/tests/helpers.ts index 08e908bf..294a6564 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -195,8 +195,10 @@ export type FakeContext = NodeContext & { stopped: boolean; changes: { machineId: string; from: string; to: string }[]; /** What the context was asked to play and to silence, in order – nothing here makes a noise. */ - played: { assetId: string; volume: number; loop: boolean | undefined }[]; + played: { assetId: string; volume: number; loop: boolean | undefined; detached: boolean }[]; silenced: string[]; + /** The scenes the context was asked to load, in order. */ + loaded: string[]; }; /** A NodeContext double: self/owner handles, `speed = 100`, dt 0.5, moves applied directly, `log` and `stop` recorded. */ @@ -227,8 +229,10 @@ export function fakeContext(overrides: Partial = {}): FakeContext { log: (message) => logs.push(message), played: [], silenced: [], - playSound: (assetId, volume, loop) => ctx.played.push({ assetId, volume, loop }), + loaded: [], + playSound: (assetId, volume, loop, detached) => ctx.played.push({ assetId, volume, loop, detached }), stopSound: (assetId) => ctx.silenced.push(assetId), + loadScene: (sceneId) => ctx.loaded.push(sceneId), spawn: () => null, destroy: () => {}, stop: () => { diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 07c8adfc..99e99ef0 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -233,6 +233,62 @@ export async function sound(t) { await stop(); await sleep(300); + // 34g-2. A voice belongs to the behavior playing it: destroy the object and its sounds go with it, + // without any `Stop sound` in the graph. `detached` is the one way out, for the death + // sound of an object that destroys itself. + await mutate(` + const p = __gm.state.project; + const graph = p.assets.find((a) => a.id === 'sn-logic').graph; + graph.nodes.push( + ${JSON.stringify(node('sn-key2', 'OnKeyDown', 260, 440, { key: 'd' }))}, + ${JSON.stringify(node('sn-owner', 'Owner', 260, 620))}, + ${JSON.stringify(node('sn-destroy', 'Destroy', 520, 440))}, + ); + graph.edges.push( + ${JSON.stringify(edge('sn-e3', 'sn-key2', 'exec', 'sn-destroy', 'exec'))}, + ${JSON.stringify(edge('sn-e4', 'sn-owner', 'object', 'sn-destroy', 'object'))}, + );`); + await sleep(300); + await play(); + await waitFor(stats, (s) => s.playing > 0); + await press('d'); + const destroyed = await waitFor(stats, (s) => s.playing === 0); + check( + 'destroying the object silences the looping sound its behavior was playing', + destroyed.playing === 0 && (await json('__gm.playInfo()')).playing === true, + JSON.stringify(destroyed), + ); + await stop(); + await sleep(300); + + const setDetached = (value) => + mutate(` + const p = __gm.state.project; + const graph = p.assets.find((a) => a.id === 'sn-logic').graph; + graph.nodes.find((n) => n.id === 'sn-play').params.detached = ${JSON.stringify(value)};`); + await setDetached(true); + await sleep(300); + await play(); + await waitFor(stats, (s) => s.playing > 0); + await press('d'); + // Long enough that the reaping frame has been and gone – a voice still here now is here on purpose. + await sleep(600); + const detached = await stats(); + check( + 'a detached voice is nobody’s and plays on after the object that started it is gone', + detached.playing >= 1, + JSON.stringify(detached), + ); + await stop(); + await sleep(300); + check( + 'ending the run silences a detached voice too', + (await stats()).playing === 0, + JSON.stringify(await stats()), + ); + await setDetached(false); + await sleep(300); + // 34h. Dropping a sound file onto the editor uploads it and makes an asset named after the file. const dropped = await evaluate(` (async () => { diff --git a/tools/e2e/35-load-scene.mjs b/tools/e2e/35-load-scene.mjs new file mode 100644 index 00000000..1c41811d --- /dev/null +++ b/tools/e2e/35-load-scene.mjs @@ -0,0 +1,245 @@ +import { edge, node, port, variable, written } from '../lib/builders.mjs'; +import { MAKE_OBJECT } from './lib/scaffold.mjs'; + +/** + * More than one scene (#39): the project names the scene a run begins with – which is not the same + * question as which scene is open in the editor – and `Load scene` swaps the running one for + * another from inside a graph. + * + * What the swap keeps is the point: the scene is built from scratch, while what belongs to the + * *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. + */ + +const SOURCE = { variablesAsset: 'ls-vars' }; + +/** `On start → Variables{set score = score + 1}`, 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-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 }), + ], + edges: [ + 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-e5', 'ls-key', 'exec', 'ls-load', 'exec'), + ], +}); + +export async function loadScene(t) { + const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot, has } = t; + const playInfo = () => json('__gm.playInfo()'); + const playFrom = () => json('__gm.playFrom()'); + /** The switch is opened by position: its caption is elided at the width it has. */ + const openPlayFrom = async () => { + const at = await json('__gm.playFromAt()'); + await click(at.x, at.y); + await sleep(250); + }; + const stats = () => json('__gm.sound.stats()'); + const waitFor = async (read, done, tries = 25) => { + let value = await read(); + for (let i = 0; i < tries && !done(value); i++) { + await sleep(150); + value = await read(); + } + return value; + }; + + await press('Escape'); + await evaluate('__gm.state.select(null)'); + await evaluate("__gm.showTab('scene')"); + await sleep(200); + + const firstId = await json('__gm.state.project.activeSceneId'); + const firstName = await json('__gm.state.activeScene.name'); + + // A sound to have something playing across the swap; the seed put the files on the server. + const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`); + const file = stored[0]?.file ?? ''; + + // 35a. A second scene with a box of its own, a Variables asset, and the behavior that counts and + // loads. Everything lives in the new scene, so nothing later in the suite sees any of it. + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + ${MAKE_OBJECT} + const second = structuredClone(scene); + second.id = 'ls-scene'; + second.name = 'Level 2'; + second.objects = [ + make('ls-box', 'crate', '', 0, 'Second box'), + make('ls-brain', 'ls-logic', 'ls-box', 0, 'Loader'), + ]; + const logic = structuredClone(p.assets.find((a) => a.id === 'mover')); + Object.assign(logic, { + id: 'ls-logic', + name: 'Loader', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: ${JSON.stringify(graphFor(firstId, 'ls-sound'))}, + }); + p.assets.push(logic, second, { + id: 'ls-vars', + type: 'variables', + name: 'Run', + props: { score: 0 }, + variables: ${JSON.stringify([variable('score', 'number', 0)])}, + }, { + id: 'ls-sound', + type: 'sound', + name: 'Hum', + props: { volume: 1, loop: true }, + file: ${JSON.stringify(file)}, + });`); + await sleep(400); + + // 35b. Without a start scene the switch says the active scene – the two questions have the same + // answer until someone answers them differently. + check( + 'with no start scene named, Play starts from the active one', + (await json('__gm.state.project.startSceneId')) === '' && + (await playFrom()).mode === 'start' && + (await playFrom()).scene === firstName && + // The caption is the mode, worded like the menu entry it came from. + (await playFrom()).label === 'Start scene', + JSON.stringify(await playFrom()), + ); + + // 35c. Naming one: right-click the scene in the assets panel. + await clickText('Level 2', 'right'); + await sleep(250); + await clickText('Set as start scene'); + await sleep(300); + check( + 'the scene’s context menu names it the start scene, and the switch follows', + (await json('__gm.state.project.startSceneId')) === 'ls-scene' && (await playFrom()).scene === 'Level 2', + JSON.stringify(await playFrom()), + ); + check( + 'the assets panel marks the start scene apart from the active one', + await has('scene ▶'), + JSON.stringify( + await json('__gm.state.project.assets.filter((a) => a.type === "scene").map((a) => a.name)'), + ), + ); + await shot('35-start-scene'); + + // 35d. Play starts there rather than in the scene the editor is showing. + await play(); + const started = await playInfo(); + check( + 'Play runs the start scene while the editor still shows the active one', + started.playing === true && + started.sceneId === 'ls-scene' && + (await json('__gm.state.project.activeSceneId')) === firstId, + JSON.stringify(started), + ); + + // 35d². The scene selector keeps working while the run is on. It only moves the editor's view, + // which a double-click on a scene in the Assets panel has always done – disabling the one + // and not the other was the inconsistency. The run itself is untouched by it. + const select = await json('__gm.sceneSelect()'); + await click(select.x, select.y); + await sleep(250); + const rows = await json('__gm.menuRows()'); + // The topmost hit, because the scene's name is on its row in the Assets panel as well. + const entry = await locate('Level 2', true); + await click(entry.x, entry.y); + await sleep(300); + const playing = await playInfo(); + check( + 'the scene selector stays usable in play mode, and the run runs on', + select.enabled === true && + rows.some((row) => (row ?? '').includes('Level 2')) && + (await json('__gm.state.project.activeSceneId')) === 'ls-scene' && + playing.playing === true && + playing.sceneId === 'ls-scene', + JSON.stringify({ select, rows, playing }), + ); + + // 35e. The swap itself. The score was counted in the old scene; a fresh run would show 0. + const before = await waitFor( + () => json('__gm.world()'), + (w) => (w?.shared?.variables?.['ls-vars']?.score ?? 0) === 1, + ); + const humming = await waitFor(stats, (s) => s.playing > 0); + await press('g'); + const after = await waitFor( + () => json('__gm.playInfo()'), + (info) => info.sceneId === firstId, + ); + const world = await json('__gm.world()'); + check( + 'Load scene swaps the running scene without ending the run', + after.playing === true && after.sceneId === firstId, + JSON.stringify({ before: before.shared?.variables, after }), + ); + check( + 'the objects of the old scene are gone and the new scene is built', + !world.entities.some((e) => e.objectId === 'ls-box') && world.entities.length > 0, + JSON.stringify(world.entities.map((e) => e.name)), + ); + check( + 'what belongs to the run carries over: the shared score survives the swap', + world.shared?.variables?.['ls-vars']?.score === 1, + JSON.stringify(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, + JSON.stringify({ humming, now: await stats() }), + ); + await stop(); + await sleep(300); + + // 35f. The switch is the way to run what you are working on instead – and it is remembered + // between runs, because it says how you work rather than what the game does. + await openPlayFrom(); + await clickText('Active scene'); + await sleep(300); + await play(); + const fromActive = await playInfo(); + check( + 'switched to "active scene", Play runs the scene the editor shows', + fromActive.sceneId === firstId && + (await playFrom()).mode === 'active' && + (await playFrom()).label === 'Active scene' && + (await json('__gm.state.project.startSceneId')) === 'ls-scene', + JSON.stringify({ fromActive, switch: await playFrom() }), + ); + await stop(); + await sleep(300); + await openPlayFrom(); + await clickText('Start scene'); + await sleep(300); + check( + 'the switch goes back to the start scene', + (await playFrom()).mode === 'start' && (await playFrom()).scene === 'Level 2', + JSON.stringify(await playFrom()), + ); + + // 35g. Everything this step made goes again, the start scene included. + await mutate(` + const p = __gm.state.project; + p.startSceneId = ''; + const gone = new Set(['ls-logic', 'ls-scene', 'ls-vars', 'ls-sound']); + p.assets = p.assets.filter((a) => !gone.has(a.id)); + __gm.state.select(null);`); + await sleep(300); + check( + 'the step leaves nothing of its own behind', + (await json('__gm.state.project.assets.filter((a) => a.id.startsWith("ls-")).length')) === 0 && + (await json('__gm.state.project.startSceneId')) === '' && + (await stats()).playing === 0, + JSON.stringify(await json('__gm.state.project.assets.map((a) => a.id)')), + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 69ad322f..bce9a61a 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -37,6 +37,7 @@ import { particles } from './31-particles.mjs'; import { scope } from './32-scope.mjs'; import { renderOnChange } from './33-render-on-change.mjs'; import { sound } from './34-sound.mjs'; +import { loadScene } from './35-load-scene.mjs'; export const STEPS = [ hierarchy, @@ -73,4 +74,5 @@ export const STEPS = [ scope, renderOnChange, sound, + loadScene, ];