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
+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`).
|
||||
|
||||
Reference in new issue
Block a user