A game that is more than one scene needs a way from one to the next, so the graph can load one (#39)
game-maker / suite (push) Failing after 11m6s
game-maker / suite (push) Failing after 11m6s
Load scene sets a flag on the World like Stop and Restart do; the swap happens after the tick, and the last wish of a tick wins. Exactly one thing crosses over: the SharedState, so global variables and global machines keep what they know while everything local is built anew. Loading the scene that is already running rebuilds it, which is what "reset the level" means when the score must survive. A run starts either from the start scene (a new project field, set from the asset panel and marked with an arrow in the list) or from whatever scene is open. The toggle next to Play says which question is asked and remembers the answer in localStorage: it is a way of working, not part of the game. Sounds no longer end because a scene ends. A voice belongs to the behavior that started it and stops right after On destroy, so the scene swap is no longer a special case at all. For the enemy that plays its explosion and destroys itself in the same breath there is now a detached input on Play sound. The scene selector stays usable while a run plays, because a double click in the asset panel already did the same thing unguarded. It moves what the editor looks at, not what the run plays.
This commit is contained in:
1 parent
30090fc48e
commit
1e9d21cd5d
30 files changed
+976
-57
No files matched your search
@@ -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 = '';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' };
|
||||
},
|
||||
};
|
||||
@@ -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' };
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+27
-3
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -242,6 +242,11 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<SaveStatus, string> = {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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<EditorSettings>;
|
||||
if (playFrom === 'start' || playFrom === 'active') current.playFrom = playFrom;
|
||||
}
|
||||
} catch {
|
||||
// Unreadable or refused: the defaults stand.
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function updateSettings(change: Partial<EditorSettings>): 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.
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: () => {},
|
||||
};
|
||||
@@ -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<SharedState> | 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<SharedState>): void {
|
||||
// The bags themselves travel as they are – a running value is the point – while the map around
|
||||
// them is copied, so the old system cannot still be writing into what the new one reads.
|
||||
this.carried = { variables: { ...state.variables }, states: { ...state.states } };
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -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<string, number>();
|
||||
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<string, number>();
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -419,6 +419,10 @@ const CASES: Record<string, Case[]> = {
|
||||
{ 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<string, Case[]> = {
|
||||
// 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<string, Case[]> = {
|
||||
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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
+6
-2
@@ -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<NodeContext> = {}): 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: () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)')),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
Reference in new issue
Block a user