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; } /** 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; 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 = { 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> = { 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(); /** 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; private readonly panes: Record; private readonly assetEditors: Record>; private documents: DocumentRow = { docs: [], active: null }; private readonly pluginPanes = new Map(); /** 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(); /** 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; 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( 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` 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(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[]; hidden: StripTab[]; order: StripTab[]; labels: Record; } { 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 { 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 { 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 { 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(), }; } }