Files
feuerwerk/src/editor/Editor.ts
T
adminandClaude Opus 5.5 94c1055895
game-maker / gate (push) Successful in 3m40s
game-maker / suite (push) Failing after 11m9s
Throttle what the editor shows while a game runs (#265)
With the behaviors fixed, the scene Performance test still ran at
about 23 fps with 500 entities. The rest of the frame went to the
editor: every spawn or destroy rebuilt the hierarchy with all its
rows, redrew the scene view behind the game tab and refreshed every
panel. The game itself is cheap. What the panels show of it now
follows a preset in the project settings.

- New section While playing in the project settings with three
  presets: Live, Balanced and Minimal. Every project reads as
  Balanced, including those saved before the setting existed.
- Live keeps today's behavior. Balanced brings hierarchy and panel
  values up to date 4 times a second and stops redrawing the hidden
  scene view. Minimal updates hierarchy and values only on pause and
  throttles the graph tabs as well.
- The graph and state machine tabs stay live under Balanced. At 4
  updates a second the beads on exec edges stutter, and the tabs
  draw only while on screen. This differs from the issue proposal.
- The game itself is never throttled. Pause, step and stop bring
  every panel up to date at once, and a change of structure that
  falls between two updates is kept for the next one.
- The rule lives in editor/playThrottle.ts as panelPolicy and
  PanelClock. PlaySession asks it after every frame.
- Objects spawned at the top level go into a hierarchy group named
  after their asset, so hundreds of gold pieces fold into one row.
  Groups start folded.

Measured on a throwaway copy of Performance test, 3 s each, fps:

- About 500 entities: Live 38.6, Balanced 56, Minimal 60.6.
- About 315 entities: Live 56.9, Balanced 60.5, Minimal 60.6.
- p95 frame time at 500 entities: 60 ms, 41 ms and 23 ms.

Tests:

- ./check.sh: 193 files, 2799 tests, CHECK EXIT 0.
- GM_STEPS=1,18,59: ALL PASSED, 54 checks.
- Full e2e run (local): ALL PASSED, 1562 checks.
- tests/editor/play-throttle.test.ts: presets, update rates, kept
  structure changes, flush, spawn group and loading old projects.
- New e2e module 59: preset chosen with the mouse, measured update
  rates, scene view behind the game tab, Minimal with pause.
- Module 03 now expects spawned objects in their group row after the
  next update.
- Counter-checks: 4 in unit tests and 2 in the browser, each failing
  as expected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:53:04 +02:00

947 lines
38 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 { ScenePane } from './viewport/ScenePane';
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 { valuesTitle, VariablesEditor } from './variables/VariablesEditor';
import { valuesKey } from './variables/target';
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, ValuesTarget } from './state';
import { copySelection } from './clipboard';
import { PixelArtEditor } from './pixelart/PixelArtEditor';
import { VectorArtEditor } from './vectorart/VectorArtEditor';
import { CurveEditor } from './curve/CurveEditor';
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'
| 'curve'
| 'tile'
| 'container'
| 'variables'
| '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 what a Variables tab shows (#252) by its
* `valuesKey` – as many of those as were opened. The user management (#179) is no document, but it
* opens and closes like one.
*/
export type DocumentId = `asset:${string}` | `values:${string}` | '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 | 'variables' | 'management'>;
const FIXED_TABS = ['scene', 'game'] as const;
const PANE_ORDER = [
'scene',
'game',
'graph',
'statemachine',
'shader',
'particles',
'texture',
'vectorart',
'pixelart',
'animation',
'curve',
'tile',
'container',
'variables',
'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',
curve: 'Curve',
tile: 'Tile',
container: 'Container',
variables: 'Variables',
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 === 'management' || tab.startsWith('asset:') || tab.startsWith('values:');
}
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 curveEditor: CurveEditor;
readonly tileEditor: TileEditor;
readonly containerEditor: ContainerEditor;
readonly variablesEditor: VariablesEditor;
/** What each open Variables tab shows, by its document. */
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
/** 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 view, and the tilemap panel to its right when there is one. */
private readonly sceneRow = new HStack();
/** The scene tab: its header above `sceneRow`. */
readonly scenePane: ScenePane;
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.curveEditor = new CurveEditor(state);
this.tileEditor = new TileEditor(state);
this.containerEditor = new ContainerEditor(state);
this.variablesEditor = new VariablesEditor(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.variablesEditor.liveVariables = this.inspector.liveVariables;
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.scenePane = new ScenePane(state, this.viewport, this.sceneRow);
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,
curve: this.curveEditor,
tile: this.tileEditor,
container: this.containerEditor,
};
this.panes = {
scene: this.scenePane,
game: this.gamePane,
...this.assetEditors,
variables: this.variablesEditor,
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(),
copy: () =>
(this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) || copySelection(state),
paste: () => this.paste(),
}),
);
const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000);
app.ticker.add(onTick);
this.cleanups.push(() => app.ticker.remove(onTick));
// In the game view Tab is a game key (an inventory, a target switch) – like every other key there.
const focus = ui().focus;
focus.tabBelongsElsewhere = () => this.tab === 'game';
this.cleanups.push(() => (focus.tabBelongsElsewhere = () => false));
// 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}`);
});
// A tab per target: a list opened from inside a tab gets one beside it, not that tab (#252).
state.on('values', () => {
const target = state.openedValues;
if (!target) return;
const doc: DocumentId = `values:${valuesKey(target)}`;
if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
this.openDocument(doc);
});
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.valueTargets.clear();
this.showTab('scene');
});
state.on('mode', () => this.editorRun.invalidate());
}
/** Ctrl+V lands in whatever the visible tab edits (#240). */
private paste(): boolean {
switch (this.tab) {
case 'animation':
return this.animationEditor.canvas.pasteKeys();
case 'scene':
return this.viewport.pasteHere();
case 'container':
return this.containerEditor.canvas.pasteHere();
case 'graph':
return this.graphEditor.canvas.paste();
case 'statemachine':
return this.stateMachineEditor.canvas.paste();
case 'shader':
return this.shaderEditor.canvas.paste();
case 'particles':
return this.particlesEditor.canvas.paste();
default:
return false;
}
}
/**
* 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: a Variables tab's owner may have been renamed.
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));
this.valueTargets.delete(doc);
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 === 'variables') {
const doc = [...this.documents.docs].reverse().find((d) => d.startsWith('values:'));
if (doc) this.openDocument(doc);
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.startsWith('values:')) {
const target = this.valueTargets.get(doc);
return target && valuesTitle(this.state, target) !== null ? 'variables' : 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));
const target = this.valueTargets.get(doc);
if (kind === 'variables' && target) this.variablesEditor.show(target);
// 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 what a Variables tab shows, as its header says. */
private labelOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
const target = this.valueTargets.get(doc);
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
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 target = this.valueTargets.get(doc);
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
const type = 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();
this.viewport.catchUp();
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[];
labels: Record<string, string>;
} {
return {
shown: this.tabs.places(),
hidden: this.tabs.hidden(),
order: [...this.tabs.tabs],
labels: Object.fromEntries(this.documents.docs.map((doc) => [doc, this.labelOf(doc)])),
};
}
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);
// The preview is the editor's own, so it runs on while the game is paused.
if (this.tab === 'curve') this.curveEditor.tick(dt);
// 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) ||
(this.tab === 'curve' && this.curveEditor.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;
panelUpdates: { structure: number; values: number };
} {
return {
playing: this.play.isPlaying,
paused: this.play.isPaused,
gamePaused: this.play.isGamePaused,
time: this.play.time,
sceneId: this.play.sceneId,
panelUpdates: this.play.panelUpdates(),
};
}
}