Files
feuerwerk/src/editor/Editor.ts
T
adminandClaude Opus 5.5 94749b4f3e
game-maker / gate (push) Successful in 3m6s
game-maker / suite (push) Successful in 9m38s
Let a project keep named snapshots and restore them (#231)
Autosave only keeps the latest revision. Before a risky change there
was no way to put a known good state aside and go back to it later.
The main menu now has "Snapshots..." to create, restore and delete
named copies of the open project.

- Server: SnapshotStore.php keeps each snapshot as a copy of the
  project JSON under data/snapshots/<project>/ (git-ignored). Four
  routes under projects/{id}/snapshots use the same owner check as the
  project, so an admin also sees foreign snapshots.
- Create writes the autosave first. With unsaved edits (play with open
  edits, failed save, conflict) no snapshot is made and a message says
  why. The rule is the pure function in src/editor/snapshots.ts.
- An empty name falls back to the local date and time.
- Restore asks first, then keeps the current state as "Before
  restoring <name>". It writes the snapshot back as a new revision and
  reloads the project, so the undo list is empty. A restore can itself
  be undone by restoring that snapshot.
- Deleting a project deletes its snapshots too. Images, sounds, fonts
  and pixel art are content-addressed and never deleted, so a snapshot
  can always reach them.
- Client: SnapshotStore and HttpSnapshotStore in src/core/store, the
  SnapshotsDialog, and the flows in ProjectSession. A stale revision
  (409) becomes StaleRevisionError.
- CI runs the new PHP test in the gate job next to accounts.

Not covered by e2e: the lock during play and the conflict case. Both
are unit tests only.

Tests:
- check.sh: 164 files, 2401 tests, CHECK EXIT 0.
- tests/php/snapshots.test.php: 14 checks, all passed.
- tests/editor/snapshots.test.ts: 5 tests for the time format, the
  blocker rule, the routes and 409.
- New e2e module 50 on a throwaway project: create, rename, restore,
  undo the restore, delete, then delete the project and get 404 on
  its snapshots. 46b expects the new menu entry. e2e 46,50: 20 checks
  passed. The check sum goes from 1385 to 1393 (computed).
- Counter-checks, originals restored and compared with cmp: blocker
  always allowed fails 1 unit test. No sorting fails 2 PHP checks.
  Restore without keeping the old state fails 50c and aborts 50d.
- The first sort test with 2 snapshots passed by chance half the time
  because of random ids. It now uses 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:21:52 +02:00

873 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Application, Ticker } from 'pixi.js';
import type { Asset } from '../core/model/Asset';
import { findAsset, findObjectHolder } from '../core/model/Project';
import type { Role } from '../core/store/AuthStore';
import type { ProjectStore } from '../core/store/ProjectStore';
import { pluginsOf } from '../core/store/projectPlugins';
import type { FontStore } from '../core/store/FontStore';
import type { SnapshotStore } from '../core/store/SnapshotStore';
import type { SoundStore } from '../core/store/SoundStore';
import type { TextureStore } from '../core/store/TextureStore';
import type { UsersStore } from '../core/store/UsersStore';
import { gameClock, timeStep } from '../core/time';
import {
clamp,
HStack,
isRendering,
Menu,
type MenuEntry,
Panel,
renderCount,
requestRender,
Splitter,
Theme,
ui,
VStack,
type Widget,
} from '../ui';
import { TabStrip, type TabPlace } from './TabStrip';
import type { AssetTabEditor } from './AssetTabEditor';
import { closeDocument, type DocumentRow, openDocument, replaceDocument } from './documents';
import { ConsolePanel } from './console/ConsolePanel';
import { GamePane } from './viewport/GamePane';
import { GameViewport } from './viewport/GameViewport';
import { SceneViewport } from './viewport/SceneViewport';
import { TAB_OF_ASSET } from './assetTabs';
import { attachAutosave } from './autosave';
import { PasswordDialog } from './auth/PasswordDialog';
import type { OpenProjectDialog } from './OpenProjectDialog';
import type { SnapshotsDialog } from './SnapshotsDialog';
import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
import { dialogSlot } from './FormDialog';
import { ContainerEditor } from './container/ContainerEditor';
import { EditorRun } from './EditorRun';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
import { ListEditor } from './list/ListEditor';
import { resolveList } from './list/slot';
import { AnimationEditor } from './animation/AnimationEditor';
import { TileEditor } from './tile/TileEditor';
import { AssetPanel } from './panels/AssetPanel';
import {
ASSET_ICON_SVG,
GAME_ICON_SVG,
LIST_ICON_SVG,
PLUGINS_ICON_SVG,
USERS_ICON_SVG,
} from './panels/assetIcons';
import { HierarchyPanel } from './panels/HierarchyPanel';
import { TilemapPanel } from './panels/TilemapPanel';
import { Toolbar } from './panels/Toolbar';
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
import { ProjectSession } from './ProjectSession';
import { ParticlesEditor } from './particles/ParticlesEditor';
import { ShaderEditor } from './shader/ShaderEditor';
import { StateMachineEditor } from './statemachine/StateMachineEditor';
import { installShortcuts } from './shortcuts';
import type { PlayFrom } from '../core/model/playSettings';
import type { EditorState } from './state';
import { PixelArtEditor } from './pixelart/PixelArtEditor';
import { VectorArtEditor } from './vectorart/VectorArtEditor';
import { TextureEditor } from './texture/TextureEditor';
import { installFileImport } from './fileImport';
import type { PluginManager } from './plugins/loader';
import { PluginsPane } from './plugins/PluginsPane';
import { ManagementPane } from './users/ManagementPane';
import { UsersPane } from './users/UsersPane';
export type CentreTab =
| 'scene'
| 'graph'
| 'statemachine'
| 'shader'
| 'particles'
| 'game'
| 'texture'
| 'vectorart'
| 'pixelart'
| 'animation'
| 'tile'
| 'container'
| 'list'
| 'management';
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
export type PluginTabId = `plugin:${string}`;
/** What is showing: a fixed tab, a plugin's, or the kind of the open document on screen. */
export type TabId = CentreTab | PluginTabId;
/**
* An open document (#171): an asset by its id, or the list the List tab edits – there is one. The
* user management (#179) is no document, but it opens and closes like one.
*/
export type DocumentId = `asset:${string}` | 'list' | 'management';
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
export interface EditorAccount {
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
user: string;
/** What the editor offers: an administrator gets the user management (#180, #179). */
role: Role;
users: UsersStore;
logOut: () => void;
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
changePassword: (current: string, next: string) => Promise<void>;
}
/** A button in the tab row. */
export type StripTab = FixedTab | PluginTabId | DocumentId;
type FixedTab = (typeof FIXED_TABS)[number];
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
type AssetKind = Exclude<CentreTab, FixedTab | 'list' | 'management'>;
const FIXED_TABS = ['scene', 'game'] as const;
const PANE_ORDER = [
'scene',
'game',
'graph',
'statemachine',
'shader',
'particles',
'texture',
'vectorart',
'pixelart',
'animation',
'tile',
'container',
'list',
'management',
] as const;
/** The fixed tabs' names, and a document's kind as a word (its tab shows the icon). */
const TAB_LABELS: Record<CentreTab, string> = {
scene: 'Scene',
game: 'Game',
graph: 'Behavior',
statemachine: 'State machine',
shader: 'Shader',
particles: 'Particles',
texture: 'Texture',
vectorart: 'Vector art',
pixelart: 'Pixel art',
animation: 'Animation',
tile: 'Tile',
container: 'Container',
list: 'List',
management: 'User management',
};
/** Icons of the fixed tabs (#176); a plugin's own tab has none, it brings no picture of its own. */
const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
scene: ASSET_ICON_SVG.scene,
game: GAME_ICON_SVG,
};
function isDocument(tab: StripTab): tab is DocumentId {
return tab === 'list' || tab === 'management' || tab.startsWith('asset:');
}
const MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500;
const MIN_RIGHT_WIDTH = 200;
const MAX_RIGHT_WIDTH = 600;
// The floor is the console's own header: dragged all the way shut it stays visible to drag open.
const MIN_CONSOLE_HEIGHT = Panel.HEADER_HEIGHT;
const MAX_CONSOLE_HEIGHT = 600;
// Either panel of the left column keeps at least its header, for the same reason as the console.
const MIN_PANEL_HEIGHT = Panel.HEADER_HEIGHT;
/**
* Composes toolbar, panels, viewports and graph editor into the one application that edits and
* plays. What it owns itself is the layout, the tab strip and the frame ticker; running the
* project is `PlaySession`, loading it is `ProjectSession`.
*/
export class Editor implements PlayHost {
/** Scene tab: the editable model view (live positions while playing). */
readonly viewport: SceneViewport;
/** Game tab: the running world through its camera. */
readonly gameView: GameViewport;
readonly gamePane: GamePane;
readonly graphEditor: GraphEditor;
readonly stateMachineEditor: StateMachineEditor;
readonly shaderEditor: ShaderEditor;
readonly particlesEditor: ParticlesEditor;
readonly textureEditor: TextureEditor;
readonly vectorArtEditor: VectorArtEditor;
readonly pixelArtEditor: PixelArtEditor;
readonly animationEditor: AnimationEditor;
readonly tileEditor: TileEditor;
readonly containerEditor: ContainerEditor;
readonly listEditor: ListEditor;
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
readonly pluginsPane = new PluginsPane();
/** The accounts, above the plugins; null unless an administrator is signed in. */
readonly usersPane: UsersPane | null;
private readonly managementPane: ManagementPane;
readonly hierarchy: HierarchyPanel;
readonly assets: AssetPanel;
readonly inspector: Inspector;
/** Beside the scene view, while exactly one placed tilemap is selected there (#142). */
readonly tilemapPanel: TilemapPanel;
readonly tilemapSplitter = new Splitter('x');
/** The scene tab: its view, and the tilemap panel to its right when there is one. */
private readonly sceneRow = new HStack();
readonly console = new ConsolePanel();
tab: TabId = 'scene';
/** Frame rate, refreshed twice a second; null until the first measurement. */
fps: number | null = null;
private readonly play: PlaySession;
private readonly editorRun: EditorRun;
private readonly projects: ProjectSession;
private readonly leftColumn: VStack;
readonly leftSplitter = new Splitter('x');
readonly rightSplitter = new Splitter('x');
readonly consoleSplitter = new Splitter('y');
/** Between the asset panel and the scene objects below it. */
readonly columnSplitter = new Splitter('y');
private readonly workspace: HStack;
private readonly centre: VStack;
private readonly tabs: TabStrip<StripTab>;
private readonly panes: Record<CentreTab, Widget>;
private readonly assetEditors: Record<AssetKind, AssetTabEditor<Asset>>;
private documents: DocumentRow<DocumentId> = { docs: [], active: null };
private readonly pluginPanes = new Map<PluginTabId, Widget>();
/** The plugins of this editor and their switches; null until `startPlugins` has run. */
plugins: PluginManager | null = null;
private readonly toolbar: Toolbar;
private readonly passwordDialog = dialogSlot<PasswordDialog>();
/** Drawn-frame count at the last measurement, not a counter of its own. */
private fpsFrames = 0;
private fpsElapsed = 0;
/** What this editor registered outside itself, as the calls that take it away again (#177). */
private readonly cleanups: (() => void)[] = [];
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
private disposed = false;
private leftWidth = 250;
private rightWidth = 350;
/** Starts out as wide as the inspector, then goes its own way. */
private tilemapWidth = this.rightWidth;
private consoleHeight = 300;
/** The asset panel's part of the left column's height; the scene objects get the rest. */
private assetsShare = 0.5;
constructor(
app: Application,
private readonly root: VStack,
private readonly state: EditorState,
store: ProjectStore,
textures: TextureStore,
fonts: FontStore,
sounds: SoundStore,
snapshots: SnapshotStore,
account: EditorAccount,
) {
this.viewport = new SceneViewport(state);
this.gameView = new GameViewport(state);
this.gamePane = new GamePane(this.gameView);
this.graphEditor = new GraphEditor(state);
this.stateMachineEditor = new StateMachineEditor(state);
this.shaderEditor = new ShaderEditor(state);
this.particlesEditor = new ParticlesEditor(state);
this.textureEditor = new TextureEditor(state);
this.vectorArtEditor = new VectorArtEditor(state);
this.pixelArtEditor = new PixelArtEditor(state);
this.animationEditor = new AnimationEditor(state);
this.tileEditor = new TileEditor(state);
this.containerEditor = new ContainerEditor(state);
this.listEditor = new ListEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.tilemapPanel = new TilemapPanel(state);
this.play = new PlaySession(state, this);
this.editorRun = new EditorRun(state, {
// The game view is the one place where no key means anything to the editor, so that is where
// a behavior may have them – unless a widget in it is focused and wants them itself.
focused: () => this.tab === 'game' && ui().focus.focused === null,
onWorld: (world) => {
this.gameView.setEditorWorld(world);
this.viewport.setEditorWorld(world);
},
});
this.projects = new ProjectSession(
state,
store,
() => {
if (this.play.isPlaying) this.play.stop();
},
{ store: snapshots, flush: () => this.autosave.flush() },
// Every project brings its own plugins (#119); until `startPlugins` has run there is no
// manager, and a project with plugins simply has none of them.
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
);
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
this.graphEditor.canvas.timeProvider = () => this.play.time;
// Only one of the two ever runs a given machine, for the reason the live variables are one call.
this.stateMachineEditor.canvas.stateProvider = (assetId) => {
const playing = this.play.machineStates(assetId);
return playing.length > 0 ? playing : this.editorRun.machineStates(assetId);
};
// Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`).
this.inspector.liveVariables = (assetId) =>
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
this.usersPane =
account.role === 'admin'
? new UsersPane(account.users, {
self: account.user,
openProject: (id) => this.projects.openProject(id),
notice: (message, level) => state.setNotice(message, level),
})
: null;
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
this.toolbar = new Toolbar(state, {
openMainMenu: (anchor) => this.projects.openMainMenu(anchor, this.managementEntries()),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(),
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
});
root.add(this.toolbar);
this.workspace = new HStack();
this.leftColumn = new VStack();
this.assets = new AssetPanel(state, textures, fonts, sounds);
this.leftColumn.add(this.assets, this.assetsShare);
this.cleanups.push(installFileImport(app.canvas, { state, textures, fonts, sounds }));
this.leftColumn.add(this.columnSplitter);
this.leftColumn.add(this.hierarchy, 1 - this.assetsShare);
// A share rather than a height, so a taller or shorter window keeps the split the user chose.
this.columnSplitter.onDrag = (delta) => {
const room = this.leftColumn.h - Splitter.THICKNESS;
if (room <= 0) return;
const floor = Math.min(0.5, MIN_PANEL_HEIGHT / room);
this.assetsShare = clamp(this.assetsShare + delta / room, floor, 1 - floor);
this.assets.flex = this.assetsShare;
this.hierarchy.flex = 1 - this.assetsShare;
this.leftColumn.relayout();
};
this.centre = new VStack();
this.tabs = new TabStrip<StripTab>(
FIXED_TABS.map((tab) => ({ tab, label: TAB_LABELS[tab], icon: FIXED_TAB_ICONS[tab] })),
(tab) => this.pickTab(tab),
);
this.tabs.minHeight = Theme.controlHeight + 8;
// Written out per tab rather than derived, because a `Record<CentreTab, …>` is what makes
// adding a tab a type error everywhere it has to be handled instead of a silent omission.
this.sceneRow.add(this.viewport, 1);
this.sceneRow.add(this.tilemapSplitter);
this.sceneRow.add(this.tilemapPanel);
this.tilemapPanel.minWidth = this.tilemapWidth;
this.tilemapSplitter.onDrag = (delta) => {
this.tilemapWidth = clamp(this.tilemapWidth - delta, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
this.tilemapPanel.minWidth = this.tilemapWidth;
this.sceneRow.relayout();
};
this.tilemapPanel.onTargetChange = () => this.syncTilemapPanel();
this.syncTilemapPanel();
this.assetEditors = {
graph: this.graphEditor,
statemachine: this.stateMachineEditor,
shader: this.shaderEditor,
particles: this.particlesEditor,
texture: this.textureEditor,
vectorart: this.vectorArtEditor,
pixelart: this.pixelArtEditor,
animation: this.animationEditor,
tile: this.tileEditor,
container: this.containerEditor,
};
this.panes = {
scene: this.sceneRow,
game: this.gamePane,
...this.assetEditors,
list: this.listEditor,
management: this.managementPane,
};
// Following a call goes on in the same tab: the tab changes its document, not its place.
this.graphEditor.onRetarget = (from, to) => {
const next = replaceDocument<DocumentId>(this.documents, `asset:${from}`, `asset:${to}`);
if (next === this.documents) return;
this.documents = next;
this.syncStrip();
this.showDocument(`asset:${to}`);
};
this.centre.add(this.tabs);
for (const tab of PANE_ORDER) {
this.centre.add(this.panes[tab], 1);
this.panes[tab].visible = tab === 'scene';
}
this.gameView.visible = false;
// The console sits below whichever tab is showing, so it is a sibling of the panes, not of
// the workspace: switching tabs must not move it.
this.centre.add(this.consoleSplitter);
this.centre.add(this.console);
this.console.minHeight = this.consoleHeight;
this.consoleSplitter.onDrag = (delta) => {
this.consoleHeight = clamp(this.consoleHeight - delta, MIN_CONSOLE_HEIGHT, MAX_CONSOLE_HEIGHT);
this.console.minHeight = this.consoleHeight;
this.centre.relayout();
};
this.leftColumn.minWidth = this.leftWidth;
this.inspector.minWidth = this.rightWidth;
this.leftSplitter.onDrag = (delta) => {
this.leftWidth = clamp(this.leftWidth + delta, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH);
this.leftColumn.minWidth = this.leftWidth;
this.workspace.relayout();
};
this.rightSplitter.onDrag = (delta) => {
this.rightWidth = clamp(this.rightWidth - delta, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
this.inspector.minWidth = this.rightWidth;
this.workspace.relayout();
};
this.workspace.add(this.leftColumn);
this.workspace.add(this.leftSplitter);
this.workspace.add(this.centre, 1);
this.workspace.add(this.rightSplitter);
this.workspace.add(this.inspector);
root.add(this.workspace, 1);
root.resize(app.screen.width, app.screen.height);
this.autosave = attachAutosave(state, store);
this.cleanups.push(
installShortcuts(state, {
play: () => this.play.play(),
stop: () => this.play.stop(),
togglePause: () => this.play.togglePause(),
copyKeys: () => this.tab === 'animation' && this.animationEditor.canvas.copyKeys(),
pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(),
}),
);
const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000);
app.ticker.add(onTick);
this.cleanups.push(() => app.ticker.remove(onTick));
// A node, a frame and a template object exist only in their own document, so selecting one
// brings it up. Selecting an asset never does: that is what opening it (double-click, Enter,
// creating it) is for – see the `open` event below.
state.on('selection', () => {
const s = state.selection;
if (s?.kind === 'node' || s?.kind === 'frame') this.openDocument(`asset:${s.assetId}`);
if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container')
this.openDocument(`asset:${s.holderId}`);
});
state.on('open', () => {
const s = state.selection;
if (s?.kind !== 'asset') return;
if (findAsset(state.project, s.assetId)?.type === 'scene') this.showTab('scene');
else this.openDocument(`asset:${s.assetId}`);
});
// Opening a list is a request to look at it, so this switches even when the slot is unchanged.
state.on('list', () => {
if (state.listSlot) this.openDocument('list');
else this.closeDocument('list');
});
state.on('project', () => {
this.play.syncFromModel();
this.editorRun.invalidate();
this.syncDocuments();
});
state.on('load', () => {
this.editorRun.invalidate();
for (const doc of this.documents.docs) this.tabs.removeTab(doc);
this.documents = { docs: [], active: null };
this.showTab('scene');
});
state.on('mode', () => this.editorRun.invalidate());
}
/**
* The main menu's last group (#179): the user management for an administrator, and the plugins
* for everybody – both open the one tab, which holds the plugins below the accounts.
*/
private managementEntries(): MenuEntry[] {
const open = () => this.openDocument('management');
return [
{ separator: true },
...(this.usersPane ? [{ label: 'User management', onSelect: open }] : []),
{ label: 'Plugins', onSelect: open },
];
}
/**
* The account menu under its button (#182). The name stands in it as an entry that cannot be
* picked: it says who is signed in without costing the crowded toolbar another label.
*/
private openAccountMenu(anchor: Widget, account: EditorAccount): void {
new Menu([
{ label: `Signed in as ${account.user}`, disabled: true, onSelect: () => {} },
{ separator: true },
{
label: 'Change password…',
onSelect: () => {
const dialog = this.passwordDialog.open(
new PasswordDialog(account.changePassword, () =>
this.state.setNotice('Password changed – your other sessions were signed out'),
),
);
dialog.focusFirstField();
},
},
{ label: 'Logout', onSelect: () => account.logOut() },
]).openBelow(anchor);
}
/** The open "Change password" dialog, for the check scripts. */
accountDialog(): PasswordDialog | null {
return this.passwordDialog.current();
}
/** The open "Project settings" dialog (#183), for the check scripts. */
projectSettingsDialog(): ProjectSettingsDialog | null {
return this.projects.projectSettings();
}
/** The open "Open project" dialog (#183), for the check scripts. */
projectPickerDialog(): OpenProjectDialog | null {
return this.projects.projectPicker();
}
/** The open "Snapshots" dialog (#231), for the check scripts. */
snapshotsDialog(): SnapshotsDialog | null {
return this.projects.snapshotList();
}
/** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */
hasTab(id: string): boolean {
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
}
/** Puts a plugin's tab after the fixed ones, ahead of the documents; `hasTab` has said the id is free. */
addPluginTab(id: string, label: string, content: Widget): void {
const tab: PluginTabId = `plugin:${id}`;
content.visible = false;
this.centre.addChildAt(content, this.centre.getChildIndex(this.consoleSplitter));
content.flex = 1;
this.tabs.addTab(tab, label, FIXED_TABS.length + this.pluginPanes.size);
this.pluginPanes.set(tab, content);
this.centre.relayout();
}
/**
* Takes a plugin's tab away again – the plugin was switched off, or its `activate` failed after
* adding it. Whoever was looking at it is put back on the scene.
*/
removePluginTab(id: string): void {
const tab: PluginTabId = `plugin:${id}`;
const pane = this.pluginPanes.get(tab);
if (!pane) return;
this.pluginPanes.delete(tab);
pane.destroy({ children: true });
this.tabs.removeTab(tab);
if (this.tab === tab) this.showTab('scene');
else this.centre.relayout();
}
/** What a plugin put into its tab, for the check scripts. */
pluginPane(id: string): Widget | undefined {
return this.pluginPanes.get(`plugin:${id}`);
}
/** The documents open in the tab row, in order, and the one showing – for the check scripts. */
openDocuments(): { docs: DocumentId[]; active: DocumentId | null } {
return { docs: [...this.documents.docs], active: this.documents.active };
}
/** Brings a document up, opening a tab for it if it has none. Unknown assets are ignored. */
openDocument(doc: DocumentId): void {
if (!this.kindOf(doc)) return;
if (this.documents.active === doc) {
// Still worth a look at the name: the List tab may have been pointed at another list.
this.syncStrip();
return;
}
this.documents = openDocument(this.documents, doc);
this.syncStrip();
this.showDocument(doc);
}
/** Closes a document's tab; nothing is lost, every edit is in the model already. */
closeDocument(doc: DocumentId): void {
if (!this.documents.docs.includes(doc)) return;
const wasShowing = this.documents.active === doc;
this.documents = closeDocument(this.documents, doc);
this.tabs.removeTab(doc);
const kind = this.kindOf(doc);
if (kind && kind in this.assetEditors)
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
// The List tab edits the one slot there is; a closed tab lets go of it.
if (doc === 'list' && this.state.listSlot) this.state.openList(null);
if (!wasShowing) return;
if (this.documents.active) this.showDocument(this.documents.active);
else this.showTab('scene');
}
/**
* Shows a tab by what it is. A fixed or plugin tab is shown as such; a kind of document shows the
* one the selection is about, else the one its editor last had – the way the check scripts reach
* a document without knowing its tab.
*/
showTab(tab: TabId): void {
if (tab === 'list') {
if (this.state.listSlot) this.openDocument('list');
return;
}
if (tab === 'management') {
this.openDocument('management');
return;
}
if (tab in this.assetEditors) {
const editor = this.assetEditors[tab as AssetKind];
const id =
editor.selectedId() ||
editor.currentAssetId ||
this.state.project.assets.find((a) => editor.isMine(a))?.id;
if (id && findAsset(this.state.project, id)) this.openDocument(`asset:${id}`);
else this.display(tab, null);
return;
}
this.display(tab, tab as FixedTab | PluginTabId);
}
private pickTab(tab: StripTab): void {
if (isDocument(tab)) this.openDocument(tab);
else this.showTab(tab);
}
private kindOf(doc: DocumentId): CentreTab | null {
if (doc === 'list') return this.state.listSlot ? 'list' : null;
if (doc === 'management') return 'management';
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const kind = type ? TAB_OF_ASSET[type] : undefined;
return kind && kind !== 'scene' ? kind : null;
}
private showDocument(doc: DocumentId): void {
const kind = this.kindOf(doc);
if (!kind) return;
if (kind in this.assetEditors) this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length));
// Read on every showing: an account may have been changed from another tab in the meantime.
if (kind === 'management') void this.usersPane?.reload();
this.display(kind, doc);
}
/** What a document's tab says: the asset's name, or the list's owner and name, as its tab heads it. */
private labelOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
if (doc === 'list') {
const slot = this.state.listSlot;
return (slot && resolveList(this.state, slot)?.title) ?? '';
}
return findAsset(this.state.project, doc.slice('asset:'.length))?.name ?? '';
}
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
private iconOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
const type = doc === 'list' ? undefined : findAsset(this.state.project, doc.slice('asset:'.length))?.type;
return type ? ASSET_ICON_SVG[type] : LIST_ICON_SVG;
}
/** Brings the row in line with `documents`: new tabs in their place, gone ones out, names current. */
private syncStrip(): void {
for (const tab of [...this.tabs.tabs])
if (isDocument(tab) && !this.documents.docs.includes(tab)) this.tabs.removeTab(tab);
const base = FIXED_TABS.length + this.pluginPanes.size;
this.documents.docs.forEach((doc, index) => {
const kind = this.kindOf(doc);
if (!this.tabs.has(doc) && kind)
this.tabs.addTab(doc, this.labelOf(doc), base + index, {
kind: doc === 'management' ? this.labelOf(doc) : TAB_LABELS[kind],
icon: this.iconOf(doc),
onClose: () => this.closeDocument(doc),
});
else this.tabs.setLabel(doc, this.labelOf(doc));
});
}
/** After an edit: a deleted asset takes its tab along, a renamed one renames it. */
private syncDocuments(): void {
for (const doc of this.documents.docs) if (!this.kindOf(doc)) this.closeDocument(doc);
this.syncStrip();
}
/** Shows the pane for `tab`, with `strip` lit in the row – none for an empty kind reached by `showTab`. */
private display(tab: TabId, strip: StripTab | null): void {
this.tab = tab;
this.documents = { docs: this.documents.docs, active: strip && isDocument(strip) ? strip : null };
// The hierarchy lists what the visible tab edits; every other tab is about the scene.
this.hierarchy.showObjectsOf(
tab === 'container'
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
: () => this.state.activeScene,
);
for (const key of PANE_ORDER) this.panes[key].visible = key === tab;
for (const [key, pane] of this.pluginPanes) pane.visible = key === tab;
// The view checks its own flag before it redraws, and the row around it is what switches now.
this.viewport.visible = tab === 'scene';
this.gameView.visible = tab === 'game';
this.syncTilemapPanel();
this.tabs.setActive(strip);
this.centre.relayout();
// Hidden, it skipped every change; its pane may come back at the same size and not relayout.
this.gameView.redraw();
ui().focus.validate();
}
/** The tilemap panel belongs to the scene tab and needs a map to paint; otherwise the view gets its room. */
private syncTilemapPanel(): void {
const shown = this.tab === 'scene' && this.tilemapPanel.hasTarget;
if (this.tilemapPanel.visible === shown) return;
this.tilemapPanel.visible = shown;
this.tilemapSplitter.visible = shown;
this.sceneRow.relayout();
}
/**
* Global x-extent of the tab buttons in the row, from the widget box – not `getBounds()`, which
* on an untoggled ghost tab sees nothing but the label (the background is not drawn at all).
*/
tabRow(): { shown: TabPlace<StripTab>[]; hidden: StripTab[]; order: StripTab[] } {
return { shown: this.tabs.places(), hidden: this.tabs.hidden(), order: [...this.tabs.tabs] };
}
boot(): Promise<void> {
return this.projects.boot();
}
/**
* Takes the editor off the screen and lets go of everything it registered elsewhere – ticker,
* shortcuts, file drops, plugins, the autosave. Signing out does this instead of reloading the
* page (#177): the canvas, the renderer and the UI context stay, only what is drawn changes.
*
* Unsaved work is written first, while the session that owns it is still good.
*/
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
await this.autosave.flush();
this.autosave.stop();
if (this.play.isPlaying) this.play.stop();
this.editorRun.shutDown();
await this.plugins?.stopAll();
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
// Closed rather than just thrown away with the overlay below, so the slot lets go of it too.
this.passwordDialog.current()?.close();
// A menu or a picker left open belongs to widgets that are about to be destroyed.
for (const popup of ui().overlay.removeChildren()) popup.destroy({ children: true });
ui().focus.blur();
this.root.removeChild(this.toolbar, this.workspace);
this.toolbar.destroy({ children: true });
this.workspace.destroy({ children: true });
// The sign-in screen sets `root` to the size of the window as well, and `resize` lays out only
// when the size changed – the same reason `App.ts` forgets the size after signing in.
this.root.resize(0, 0);
}
showGame(): void {
this.showTab('game');
}
leaveGame(): void {
if (this.tab === 'game') this.showTab('scene');
}
private tick(dt: number): void {
this.measureFps(dt);
// One step for everything below: a world gets exactly the seconds the particle, shader and
// animation clocks moved, so the global time scale cannot pull them apart.
// The game's own pause holds the clock as well, so particles and shaders freeze with the world.
const step = gameClock.advance(dt, this.state.paused || this.play.isGamePaused);
this.play.tick(step, timeStep(dt, gameClock.scale, this.state.paused));
this.editorRun.tick(step);
this.viewport.animate();
this.gameView.animate();
this.console.refresh();
if (this.tab === 'animation') this.animationEditor.tick(step);
if (this.tab === 'tile') this.tileEditor.tick(step);
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
// to do it, and its states move just the same.
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
if (this.tab === 'particles') this.particlesEditor.tick();
if (this.tab === 'shader') this.shaderEditor.tick();
if (this.tab === 'pixelart') this.pixelArtEditor.tick();
if (this.movesByItself()) requestRender();
}
/**
* What changes without anyone touching it, and therefore cannot be woken by an event: the
* running world, a Time shader on screen, the previews that are moving pictures.
*/
private movesByItself(): boolean {
return (
this.play.isPlaying ||
this.editorRun.isRunning ||
(this.viewport.visible && this.viewport.animates) ||
(this.gameView.visible && this.gameView.animates) ||
this.tab === 'particles' ||
this.tab === 'pixelart' ||
(this.tab === 'shader' && this.shaderEditor.animates) ||
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
(this.tab === 'tile' && this.tileEditor.isPlaying)
);
}
/**
* Counts drawn frames over the time the renderer was awake. Idle seconds are not slow seconds:
* since the editor stopped drawing what nobody changed, counting them would drag the readout to
* zero while everything is fine. It answers "how smooth is it while it moves", and holds its
* last answer in between.
*/
private measureFps(dt: number): void {
if (!isRendering()) return;
this.fpsElapsed += dt;
if (this.fpsElapsed < 0.5) return;
const drawn = renderCount();
this.fps = Math.round((drawn - this.fpsFrames) / this.fpsElapsed);
this.fpsFrames = drawn;
this.fpsElapsed = 0;
this.toolbar.setFps(this.fps);
}
/** Read-only view of the running world for the check scripts. */
worldSnapshot(): WorldSnapshot | null {
return this.play.snapshot();
}
/** What is running without Play, for the check scripts. */
editorRunInfo(): { running: boolean; time: number; behaviors: string[]; keyboard: boolean } {
return this.editorRun.debugInfo();
}
/** Which scene Play would start, and under which of the two rules – for the check scripts. */
playFromInfo(): { mode: PlayFrom; scene: string } {
return { mode: this.state.playFrom, scene: this.state.sceneToPlay()?.name ?? '' };
}
/** The two run buttons of the top bar (#183): what they show and whether they take a click. */
runButtons(): ReturnType<Toolbar['runButtons']> {
return this.toolbar.runButtons();
}
/** The top bar's icon buttons by name, so a check can click what carries no caption. */
get toolbarControls(): Toolbar['controls'] {
return this.toolbar.controls;
}
/**
* 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; gamePaused: boolean; time: number; sceneId: string } {
return {
playing: this.play.isPlaying,
paused: this.play.isPaused,
gamePaused: this.play.isGamePaused,
time: this.play.time,
sceneId: this.play.sceneId,
};
}
}