import type { Application, Ticker } from 'pixi.js'; import type { Asset } from '../core/model/Asset'; import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project'; import { assetTitle } from '../core/music/embedded'; import { mixOf, videoBusesOf } from '../core/music/mixer'; 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 { VideoStore } from '../core/store/VideoStore'; import { StreamKeeper } from './sound/StreamKeeper'; import type { TextureStore } from '../core/store/TextureStore'; import type { UsersStore } from '../core/store/UsersStore'; import { FIXED_STEP, gameClock, timeStep } from '../core/time'; import { clamp, HStack, isRendering, lastRenderTime, Menu, type MenuEntry, Panel, renderCount, requestRender, Splitter, Theme, tooltips, ui, VStack, type Widget, } from '../ui'; import { TabStrip, type TabPlace } from './TabStrip'; import type { AssetTabEditor } from './AssetTabEditor'; import { closeDocument, type DocumentRow, neighbourOf, openDocument, replaceDocument } from './documents'; import { openingBox, type WindowBox } from './floating'; import { FloatWindow } from './FloatWindow'; 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 { leaveFullscreen, onFullscreenChange } from '../viewport/fullscreen'; import { soundLibrary } from '../viewport/SoundLibrary'; 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 { ReleaseStore } from '../core/store/ReleaseStore'; import type { ReleaseDialog } from './ReleaseDialog'; import type { ProjectSettingsDialog } from './ProjectSettingsDialog'; import { dialogSlot } from './FormDialog'; import { generalSettings, keepGeneralSettings } from './generalSettings'; import { GeneralSettingsDialog } from './GeneralSettingsDialog'; import { useCursorFiles } from './editorCursors'; import { ContainerEditor } from './container/ContainerEditor'; import { CompositionEditor } from './composition/CompositionEditor'; 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 { pickView } from './objectPick'; import { keepPanelSize, panelSize } from './panelSizes'; 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 { type RecordingUpload, RecordingUploads } from './recordingUploads'; import { EditorRecorder } from './EditorRecorder'; import { EditorRecordingDialog } from './EditorRecordingDialog'; import { editorRecordingSettings, keepEditorRecordingSettings } from './editorRecordingSettings'; import type { MadeRecording } from '../viewport/RecordingTake'; import type { RecordingUploadOptions } from '../core/recording/recording'; import { ProjectSession } from './ProjectSession'; import { ParticlesEditor } from './particles/ParticlesEditor'; import { ShaderEditor } from './shader/ShaderEditor'; import { SoundFxEditor } from './soundfx/SoundFxEditor'; import { InstrumentEditor } from './instrument/InstrumentEditor'; import { PatternEditor } from './pattern/PatternEditor'; import { SongEditor } from './song/SongEditor'; 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 { SoundEditor } from './sound/SoundEditor'; import { VideoEditor } from './video/VideoEditor'; 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'; import { RoutingEditor } from './routing/RoutingEditor'; import { CompressorEditor } from './soundfx/CompressorEditor'; import { EqEditor } from './soundfx/EqEditor'; import { NODE_TAB_KINDS, nodeOfTab, nodeTabLabel, type NodeTabKind, nodeTabOf } from './soundfx/nodeTabs'; export type CentreTab = | 'scene' | 'graph' | 'statemachine' | 'shader' | 'particles' | 'sound' | 'soundfx' | 'instrument' | 'pattern' | 'song' | 'game' | 'video' | 'texture' | 'vectorart' | 'pixelart' | 'animation' | 'curve' | 'tile' | 'container' | 'composition' | 'variables' | 'routing' | 'eq' | 'compressor' | 'filter' | '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) and the master routing * (#351) are no documents, but they open and close like one. A Parametric EQ has a tab per node (#370), * and so have a Compressor (#378) and a Filter (#385). */ export type DocumentId = `asset:${string}` | `values:${string}` | `${NodeTabKind}:${string}` | 'routing' | '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', 'sound', 'soundfx', 'instrument', 'pattern', 'song', 'video', 'texture', 'vectorart', 'pixelart', 'animation', 'curve', 'tile', 'container', 'composition', 'variables', 'routing', 'eq', 'compressor', 'filter', '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', sound: 'Sound', soundfx: 'Sound FX', instrument: 'Instrument', pattern: 'Pattern', song: 'Song', video: 'Video', texture: 'Texture', vectorart: 'Vector art', pixelart: 'Pixel art', animation: 'Animation', curve: 'Curve', tile: 'Tile', container: 'Container', composition: 'Composition', variables: 'Variables', routing: 'Master routing', eq: 'Parametric EQ', compressor: 'Compressor', filter: 'Filter', 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 === 'routing' || tab.startsWith('asset:') || tab.startsWith('values:') || nodeTabOfDocument(tab) !== null ); } /** The kind of a node's tab (#370, #378) a document is, or null for any other. */ function nodeTabOfDocument(doc: string): NodeTabKind | null { return NODE_TAB_KINDS.find((kind) => doc.startsWith(`${kind}:`)) ?? null; } 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; // What a share read back from the browser is held to before the column has a height to measure by. const MIN_KEPT_SHARE = 0.03; /** * 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 soundEditor: SoundEditor; readonly videoEditor: VideoEditor; /** Makes the copies streamed sounds play from (#349). */ readonly streamKeeper: StreamKeeper; readonly soundFxEditor: SoundFxEditor; readonly instrumentEditor: InstrumentEditor; readonly patternEditor: PatternEditor; readonly songEditor: SongEditor; readonly textureEditor: TextureEditor; readonly vectorArtEditor: VectorArtEditor; readonly pixelArtEditor: PixelArtEditor; readonly animationEditor: AnimationEditor; readonly curveEditor: CurveEditor; readonly tileEditor: TileEditor; readonly containerEditor: ContainerEditor; readonly compositionEditor: CompositionEditor; readonly variablesEditor: VariablesEditor; readonly routingEditor: RoutingEditor; readonly eqEditor: EqEditor; readonly compressorEditor: CompressorEditor; readonly filterEditor: EqEditor; /** What each open Variables tab shows, by its document. */ private readonly valueTargets = new Map(); /** The node behind each tab of an EQ (#370) or a Compressor (#378). */ private readonly nodeTargets = 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 recordings: RecordingUploads; /** Records the whole editor from the main menu. */ private readonly editorRecorder: EditorRecorder; 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 }; /** * The kinds whose pane is in a window of its own (#371), and the document each window shows. A * kind has one editor, so it has one window: another document of it opens there, not in a tab. */ private readonly floats = new Map(); /** The window the last press went into; null when it went into the editor. Keys follow it. */ private pressedWindow: CentreTab | null = 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(); private readonly generalDialog = dialogSlot(); private readonly recordingDialog = dialogSlot(); /** Drawn-frame count at the last measurement, not a counter of its own. */ private fpsFrames = 0; private fpsElapsed = 0; /** Whether a node's face (#380) moved in the last frame: a meter falling back still needs drawing. */ private facesMoving = false; /** 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; // Each starts where its divider was left in this browser (`panelSizes.ts`), else at the number here. private leftWidth = panelSize('left', 250, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH); private rightWidth = panelSize('right', 350, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH); /** Starts out as wide as the inspector, then goes its own way. */ private tilemapWidth = panelSize('tilemap', this.rightWidth, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH); private consoleHeight = panelSize('console', 300, MIN_CONSOLE_HEIGHT, MAX_CONSOLE_HEIGHT); /** The asset panel's part of the left column's height; the scene objects get the rest. */ private assetsShare = panelSize('assets-share', 0.5, MIN_KEPT_SHARE, 1 - MIN_KEPT_SHARE); constructor( app: Application, private readonly root: VStack, private readonly state: EditorState, store: ProjectStore, textures: TextureStore, fonts: FontStore, sounds: SoundStore, videos: VideoStore, snapshots: SnapshotStore, releases: ReleaseStore, 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.streamKeeper = new StreamKeeper(state, sounds); this.soundEditor = new SoundEditor(state, this.streamKeeper); this.videoEditor = new VideoEditor(state, videos); this.soundFxEditor = new SoundFxEditor(state); this.instrumentEditor = new InstrumentEditor(state); this.patternEditor = new PatternEditor(state, sounds); this.songEditor = new SongEditor(state, sounds); 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.compositionEditor = new CompositionEditor(state); this.variablesEditor = new VariablesEditor(state); this.routingEditor = new RoutingEditor(state); this.eqEditor = new EqEditor(state); this.compressorEditor = new CompressorEditor(state); this.filterEditor = new EqEditor(state, 'filter'); this.hierarchy = new HierarchyPanel(state); this.compositionEditor.onLiveItems = () => { if (this.tab === 'composition') this.hierarchy.refresh(); }; this.inspector = new Inspector(state); this.tilemapPanel = new TilemapPanel(state); this.recordings = new RecordingUploads({ state, videos }); this.editorRecorder = new EditorRecorder({ log: (line) => { state.setNotice(line); console.warn('[recording]', line); }, saved: (recording, options) => this.recordings.add(recording, options), showBadge: (on) => this.toolbar.showRecording(on), badgeRoom: () => this.toolbar.recordingRoom(), }); // Undone on dispose: the next session's editor shares the renderer and must find Pixi's own styles. this.cleanups.push(useCursorFiles(app.renderer.events, app.canvas)); 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(); }, { snapshots, releases, 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.modeChanged`). 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(), [ { label: 'Master routing', onSelect: () => this.openDocument('routing') }, ]), togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()), togglePause: () => this.play.togglePause(), step: () => this.play.step(), 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, videos); this.leftColumn.add(this.assets, this.assetsShare); this.cleanups.push(installFileImport(app.canvas, { state, textures, fonts, sounds, videos })); 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(); keepPanelSize('assets-share', this.assetsShare); }; 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(); keepPanelSize('tilemap', this.tilemapWidth); }; 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, sound: this.soundEditor, video: this.videoEditor, soundfx: this.soundFxEditor, instrument: this.instrumentEditor, pattern: this.patternEditor, song: this.songEditor, texture: this.textureEditor, vectorart: this.vectorArtEditor, pixelart: this.pixelArtEditor, animation: this.animationEditor, curve: this.curveEditor, tile: this.tileEditor, container: this.containerEditor, composition: this.compositionEditor, }; this.panes = { scene: this.scenePane, game: this.gamePane, ...this.assetEditors, variables: this.variablesEditor, routing: this.routingEditor, eq: this.eqEditor, compressor: this.compressorEditor, filter: this.filterEditor, 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 float = this.floats.get('graph'); if (float?.doc === `asset:${from}`) { this.showInWindow('graph', `asset:${to}`); return; } 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(); keepPanelSize('console', this.consoleHeight); }; 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(); keepPanelSize('left', this.leftWidth); }; this.rightSplitter.onDrag = (delta) => { this.rightWidth = clamp(this.rightWidth - delta, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH); this.inspector.minWidth = this.rightWidth; this.workspace.relayout(); keepPanelSize('right', this.rightWidth); }; 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); // A press in the editor takes the keys back from a window (#371). this.workspace.on('pointerdowncapture', () => (this.pressedWindow = null)); this.autosave = attachAutosave(state, store); this.cleanups.push( installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop(), togglePause: () => this.play.togglePause(), step: () => this.play.step(), copy: () => (this.keyTab() === 'animation' && this.animationEditor.canvas.copyKeys()) || (this.keyTab() === 'pattern' && this.patternEditor.canvas.copy()) || (this.keyTab() === 'song' && this.songEditor.canvas.copy()) || copySelection(state), paste: () => this.paste(), removePoint: () => this.removePoint(), }), ); const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000); app.ticker.add(onTick); this.cleanups.push(() => app.ticker.remove(onTick)); // Only the game tab's button asks for fullscreen, so every change is the game view's (#267). this.cleanups.push(onFullscreenChange((on) => this.showGameFullscreen(on))); // 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, a clip 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.assetId === ROUTING_ID) this.openDocument('routing'); else if ( s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song' || s?.kind === 'composition' ) this.openDocument(`asset:${s.assetId}`); const holderType = s?.kind === 'object' ? findAsset(state.project, s.holderId)?.type : undefined; if (s?.kind === 'object' && (holderType === 'container' || holderType === 'composition')) 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); }); // A tab per Parametric EQ (#370), Compressor (#378) or Filter (#385) node, opened by a double-click on it. state.on('nodeTab', () => { const target = state.openedNode; const kind = target && nodeTabOf(target.type); if (!target || !kind) return; const doc: DocumentId = `${kind}:${target.assetId}:${target.nodeId}`; if (!this.nodeTargets.has(doc)) this.nodeTargets.set(doc, target); this.openDocument(doc); }); // The tabs play through the buses and the master as the game would (#325). const syncMix = () => void soundLibrary.setMix(mixOf(state.project), videoBusesOf(state.project)); syncMix(); state.on('project', () => { syncMix(); this.play.syncFromModel(); this.editorRun.invalidate(); this.syncDocuments(); }); state.on('load', () => { syncMix(); this.editorRun.invalidate(); for (const kind of [...this.floats.keys()]) this.closeWindow(kind); for (const doc of this.documents.docs) this.tabs.removeTab(doc); this.documents = { docs: [], active: null }; this.valueTargets.clear(); this.nodeTargets.clear(); this.eqEditor.close(); this.compressorEditor.close(); this.filterEditor.close(); this.showTab('scene'); }); state.on('mode', () => this.editorRun.modeChanged()); state.on('pick', () => this.followPick()); } /** The tab the eyedropper was started from, when it had to leave it for the view it picks in. */ private pickReturn: { tab: TabId; doc: DocumentId | null } | null = null; /** * Brings up the view the eyedropper picks in (#276) – a field in a Variables tab has none beside * it – and goes back there once it is taken or cancelled. Dropped, it stays: the user went elsewhere. */ private followPick(): void { const pick = this.state.objectPick; if (!pick) { const back = this.pickReturn; this.pickReturn = null; if (!back || this.state.pickEnd === 'drop') return; if (back.doc) this.openDocument(back.doc); else this.showTab(back.tab); return; } if (this.showsPick()) return; this.pickReturn ??= { tab: this.tab, doc: this.documents.active }; if (pickView(this.state, pick.holderId) === 'container') this.openDocument(`asset:${pick.holderId}`); else this.showTab('scene'); } /** Whether the tab showing is the view the running eyedropper picks in. */ private showsPick(): boolean { const pick = this.state.objectPick; if (!pick) return false; if (this.shows('container') && this.containerEditor.currentAssetId === pick.holderId) return true; return this.tab === 'scene' && pickView(this.state, pick.holderId) === 'scene'; } /** * Where Delete, Copy and Paste go: the window pressed last (#371), else the tab showing. */ private keyTab(): TabId { return this.pressedWindow && this.floats.has(this.pressedWindow) ? this.pressedWindow : this.tab; } /** Whether a kind's pane is on screen, in the tab showing or in a window of its own. */ private shows(kind: CentreTab): boolean { return this.tab === kind || this.floats.has(kind); } /** Delete takes the point the visible tab has picked (#288), wherever the keyboard focus is. */ private removePoint(): boolean { switch (this.keyTab()) { case 'scene': return this.viewport.removePickedCorner(); case 'container': return this.containerEditor.canvas.removePickedCorner(); case 'composition': return this.compositionEditor.preview.removePickedCorner(); case 'texture': return this.textureEditor.canvas.removePickedPoint(); case 'vectorart': return this.vectorArtEditor.canvas.removePickedPoint(); case 'curve': return this.curveEditor.canvas.removePickedPoint(); case 'pattern': return this.patternEditor.canvas.deleteSelection(); case 'song': return this.songEditor.canvas.deleteSelection(); case 'eq': // The selection is the EQ's node, still selected from the double-click: it is never what goes. this.eqEditor.removePicked(); return true; case 'filter': // A Filter has its one band and nothing else to take; its node stays as well. return true; default: return false; } } /** Ctrl+V lands in whatever the visible tab edits (#240). */ private paste(): boolean { switch (this.keyTab()) { case 'animation': return this.animationEditor.canvas.pasteKeys(); case 'scene': return this.viewport.pasteHere(); case 'container': return this.containerEditor.canvas.pasteHere(); case 'composition': return this.compositionEditor.canvas.paste(); 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(); case 'soundfx': return this.soundFxEditor.canvas.paste(); case 'instrument': return this.instrumentEditor.canvas.paste(); case 'pattern': return this.patternEditor.canvas.paste(); case 'song': return this.songEditor.canvas.paste(); default: return false; } } /** * The main menu's last groups: recording the editor, then the user management for an * administrator and the plugins for everybody (#179) – both open the one tab, which holds the * plugins below the accounts. */ private managementEntries(): MenuEntry[] { const open = () => this.openDocument('management'); const recorder = this.editorRecorder; return [ { separator: true }, recorder.isRecording ? { label: 'Stop recording', onSelect: () => recorder.stop() } : { label: 'Start recording…', onSelect: () => this.openEditorRecording() }, { separator: true }, { label: 'General settings…', onSelect: () => this.openGeneralSettings() }, ...(this.usersPane ? [{ label: 'User management', onSelect: open }] : []), { label: 'Plugins', onSelect: open }, ]; } /** What the editor does for whoever sits at it, kept in this browser (#309). */ openGeneralSettings(): GeneralSettingsDialog { return this.generalDialog.open(new GeneralSettingsDialog(generalSettings(), keepGeneralSettings)); } /** The open "General settings" dialog, for the check scripts. */ generalSettingsDialog(): GeneralSettingsDialog | null { return this.generalDialog.current(); } /** "Start recording…": how the editor is recorded, starting on the last choice made in this browser. */ openEditorRecording(): EditorRecordingDialog { return this.recordingDialog.open( new EditorRecordingDialog(editorRecordingSettings(), (settings) => { keepEditorRecordingSettings(settings); this.editorRecorder.start(settings); }), ); } /** The open "Start recording" dialog, for the check scripts. */ editorRecordingDialog(): EditorRecordingDialog | null { return this.recordingDialog.current(); } /** * 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(); } /** The open "Release" dialog (#266), for the check scripts. */ releaseDialog(): ReleaseDialog | null { return this.projects.releaseView(); } /** 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 { const kind = this.kindOf(doc); if (!kind) return; if (this.floats.has(kind)) { this.showInWindow(kind, 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 { const floating = [...this.floats].find(([, float]) => float.doc === doc)?.[0]; if (floating) { this.closeWindow(floating); return; } if (!this.documents.docs.includes(doc)) return; const wasShowing = this.documents.active === doc; const at = this.documents.docs.indexOf(doc); this.leaveRow(doc); this.forget(doc); if (wasShowing) this.showBeside(at); } /** Takes a document out of the tab row, without anything else that closing it does. */ private leaveRow(doc: DocumentId): void { this.documents = closeDocument(this.documents, doc); this.tabs.removeTab(doc); } /** Lets go of what a document kept besides its tab: its editor's view, what its tab was for. */ private forget(doc: DocumentId): void { 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 (this.nodeTargets.delete(doc)) { if (kind === 'eq') this.eqEditor.close(); if (kind === 'compressor') this.compressorEditor.close(); if (kind === 'filter') this.filterEditor.close(); } if (doc === 'routing') this.routingEditor.close(); } /** * Shows what comes in place of the document that stood at `at` in the row: its left neighbour, * else its right one, else the scene – passing over documents whose kind is in a window. */ private showBeside(at: number): void { const next = neighbourOf(this.documents.docs, at, (doc) => { const kind = this.kindOf(doc); return kind !== null && !this.floats.has(kind); }); if (next) this.showDocument(next); else this.showTab('scene'); } /** * Moves a document out of the tab row into a window of its own (#371), its kind's pane with it. * With a window for the kind open already, the document goes there instead. */ floatDocument(doc: DocumentId): void { const kind = this.kindOf(doc); if (!kind) return; if (this.floats.has(kind)) { this.showInWindow(kind, doc); return; } const at = this.documents.docs.indexOf(doc); const showing = this.documents.active; const centreHadIt = this.tab === kind; if (at >= 0) this.leaveRow(doc); const window = new FloatWindow({ onDock: () => this.dockWindow(kind), onClose: () => this.closeWindow(kind), onPress: () => (this.pressedWindow = kind), }); const pane = this.panes[kind]; this.centre.removeChild(pane); window.setContent(pane); const screen = ui().app.screen; window.open(openingBox(screen.width, screen.height, this.floats.size)); this.floats.set(kind, { doc, window }); this.showInWindow(kind, doc); // The tab row showed this kind – the document itself, or another one of it: the pane is gone. if (!centreHadIt) return; const from = showing && showing !== doc ? this.documents.docs.indexOf(showing) : at; this.showBeside(from >= 0 ? from : this.documents.docs.length); } /** Puts a window's document back into the tab row, showing, and its pane back below the row. */ dockWindow(kind: CentreTab): void { const float = this.floats.get(kind); if (!float) return; this.takeBack(kind); this.documents = openDocument(this.documents, float.doc); this.syncStrip(); this.showDocument(float.doc); // Back at the size it had below the row, the pane would not lay itself out for the move. this.panes[kind].layout(); this.panes[kind].redraw(); } /** Closes a window and the document in it, as the × of its tab would. */ closeWindow(kind: CentreTab): void { const float = this.floats.get(kind); if (!float) return; this.takeBack(kind); this.forget(float.doc); } /** The pane comes home below the tab row, hidden until its kind shows there; the window goes. */ private takeBack(kind: CentreTab): void { const float = this.floats.get(kind)!; this.floats.delete(kind); if (this.pressedWindow === kind) this.pressedWindow = null; const pane = this.panes[kind]; pane.visible = false; this.centre.addChildAt(pane, this.centre.getChildIndex(this.consoleSplitter)); pane.flex = 1; float.window.close(); float.window.destroy({ children: true }); this.centre.relayout(); } /** Points a window at `doc`: another document of its kind takes the place of the one it had. */ private showInWindow(kind: CentreTab, doc: DocumentId): void { const float = this.floats.get(kind)!; if (float.doc !== doc) { const old = float.doc; float.doc = doc; // A document has one place: its tab goes when it comes into the window. if (this.documents.docs.includes(doc)) this.leaveRow(doc); this.forget(old); } this.pointPane(kind, doc); float.window.setTitle(this.labelOf(doc), this.iconOf(doc)); float.window.toFront(); this.pressedWindow = kind; } /** The windows open, for the check scripts: what each shows, where, and where its controls are. */ floatWindows(): { kind: CentreTab; doc: DocumentId; title: string; box: WindowBox; bar: { x: number; y: number }; grip: { x: number; y: number }; dock: { x: number; y: number }; close: { x: number; y: number }; front: boolean; }[] { const centre = (w: Widget) => { const at = w.getGlobalPosition(); return { x: at.x + w.w / 2, y: at.y + w.h / 2 }; }; return [...this.floats].map(([kind, { doc, window }]) => ({ kind, doc, title: window.titleText, box: window.box, bar: window.barPoint(), grip: window.gripPoint(), dock: centre(window.dockButton), close: centre(window.closeButton), front: window.parent?.children.at(-1) === window, })); } /** Where Delete, Copy and Paste go now, for the check scripts (#371). */ keyTarget(): TabId { return this.keyTab(); } /** A right-click on a document's tab (#371). */ private openTabMenu(doc: DocumentId, x: number, y: number): void { new Menu([ { label: 'Float', onSelect: () => this.floatDocument(doc) }, { label: 'Close', onSelect: () => this.closeDocument(doc) }, ]).open(x, y); } /** * 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' || tab === 'routing') { this.openDocument(tab); 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); } /** * In fullscreen the game view is all the page shows: it moves from its tab to the root, and the * toolbar and the workspace step aside. Leaving puts it back where it was, below the tab header. */ private showGameFullscreen(on: boolean): void { if (on === this.gameView.isFullscreen) return; this.toolbar.visible = !on; this.workspace.visible = !on; for (const { window } of this.floats.values()) window.visible = !on; if (on) this.root.add(this.gameView, 1); else this.gamePane.add(this.gameView, 1); this.gameView.setFullscreen(on); this.root.relayout(); // The pane may come back at the size it had, and a resize to the same size lays nothing out. this.gamePane.relayout(); } /** Back to the editor now, not when the browser reports it: the caller goes on with the layout. */ private endGameFullscreen(): void { leaveFullscreen(); this.showGameFullscreen(false); } 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' || doc === 'routing') return doc; const nodeTab = nodeTabOfDocument(doc); if (nodeTab) { const target = this.nodeTargets.get(doc); return target && nodeOfTab(this.state.project, nodeTab, target.assetId, target.nodeId) ? nodeTab : null; } 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 (this.floats.has(kind)) { this.showInWindow(kind, doc); return; } this.pointPane(kind, doc); this.display(kind, doc); } /** Points the editor of a kind at a document, wherever its pane is. */ private pointPane(kind: CentreTab, doc: DocumentId): void { 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(); if (kind === 'routing') this.routingEditor.open(); const node = this.nodeTargets.get(doc); if (kind === 'eq' && node) this.eqEditor.show(node.assetId, node.nodeId); if (kind === 'compressor' && node) this.compressorEditor.show(node.assetId, node.nodeId); if (kind === 'filter' && node) this.filterEditor.show(node.assetId, node.nodeId); } /** 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'; if (doc === 'routing') return TAB_LABELS.routing; const node = this.nodeTargets.get(doc); const nodeTab = nodeTabOfDocument(doc); if (node && nodeTab) return nodeTabLabel(this.state.project, nodeTab, node.assetId, node.nodeId); const target = this.valueTargets.get(doc); if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? ''; const asset = findAsset(this.state.project, doc.slice('asset:'.length)); return asset ? assetTitle(this.state.project, asset) : ''; } /** 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; if (doc === 'routing') return ASSET_ICON_SVG.bus; if (nodeTabOfDocument(doc)) return ASSET_ICON_SVG.soundfx; 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' || doc === 'routing' ? this.labelOf(doc) : TAB_LABELS[kind], icon: this.iconOf(doc), onClose: () => this.closeDocument(doc), onMenu: (x, y) => this.openTabMenu(doc, x, y), }); 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); for (const [kind, { doc, window }] of [...this.floats]) if (!this.kindOf(doc)) this.closeWindow(kind); else window.setTitle(this.labelOf(doc), this.iconOf(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 { // Fullscreen is the game view's; a tab that takes its place (Stop goes back to the scene) ends it. if (tab !== 'game') this.endGameFullscreen(); 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) : tab === 'composition' ? () => this.compositionEditor.shownHolder() : () => this.state.activeScene, ); // A pane in a window shows there whatever the row shows. for (const key of PANE_ORDER) if (!this.floats.has(key)) 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, the view was left out when its row was laid out – the eyedropper's bar may have // come up over it meanwhile – and its pane may come back at the same size and not relayout. if (tab === 'scene') this.sceneRow.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(); // Picking goes on in its own view only; another tab brought up ends it without writing. if (this.state.objectPick && !this.showsPick()) this.state.endObjectPick('drop'); } /** 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; this.endGameFullscreen(); await this.autosave.flush(); this.autosave.stop(); if (this.play.isPlaying) this.play.stop(); this.editorRecorder.stop(); this.editorRun.shutDown(); await this.plugins?.stopAll(); for (const cleanup of this.cleanups.splice(0).reverse()) cleanup(); // Before the overlay is emptied: a window listens to the renderer, and its pane goes home with the rest. for (const kind of [...this.floats.keys()]) this.closeWindow(kind); // Closed rather than just thrown away with the overlay below, so the slot lets go of it too. this.passwordDialog.current()?.close(); this.generalDialog.current()?.close(); this.recordingDialog.current()?.close(); // The tooltip is not the editor's to destroy: it leaves the overlay before that is emptied. tooltips.dismiss(); // 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'); this.showPerformance(); } leaveGame(): void { if (this.tab === 'game') this.showTab('scene'); } private tick(dt: number): void { const started = performance.now(); const drawnBefore = this.gameView.drawTime; const sceneDraws = this.viewport.redrawCount; const gameDraws = this.gameView.redrawCount; 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, // and so does a run that waits for its videos at the start (#404). // A single step out of the pause moves by one fixed step, however long the frame took. const stepping = this.play.beginFrame(); const frame = stepping ? FIXED_STEP : dt; const held = this.state.paused && !stepping; const step = gameClock.advance(frame, held || this.play.isGamePaused || this.play.isWaiting); this.play.tick(step, timeStep(frame, gameClock.scale, held)); this.editorRun.tick(step); this.viewport.animate(sceneDraws); this.gameView.animate(gameDraws); this.play.recordFrame(); this.console.refresh(); if (this.shows('animation')) this.animationEditor.tick(step); if (this.shows('composition')) this.compositionEditor.tick(dt); if (this.shows('tile')) this.tileEditor.tick(step); // The preview is the editor's own, so it runs on while the game is paused. if (this.shows('curve')) this.curveEditor.tick(dt); if (this.shows('pattern')) this.patternEditor.tick(); if (this.shows('song')) this.songEditor.tick(); else this.songEditor.hideMeters(); if (this.shows('sound')) this.soundEditor.tick(); if (this.shows('video')) this.videoEditor.tick(); // The tabs of an EQ or a Compressor and their nodes (#380, #381) listen to the same taps: what // is not seen lets go of them first, so it cannot take them from what is. const faces = [ ['soundfx', this.soundFxEditor.canvas], ['instrument', this.instrumentEditor.canvas], ] as const; if (!this.shows('eq')) this.eqEditor.hideSpectrum(); if (!this.shows('filter')) this.filterEditor.hideSpectrum(); if (!this.shows('compressor')) this.compressorEditor.hideLevels(); for (const [kind, canvas] of faces) if (!this.shows(kind)) canvas.hideFaces(); if (this.shows('eq')) this.eqEditor.tick(); if (this.shows('filter')) this.filterEditor.tick(); if (this.shows('compressor')) this.compressorEditor.tick(); this.facesMoving = false; for (const [kind, canvas] of faces) if (this.shows(kind)) this.facesMoving = canvas.tickFaces() || this.facesMoving; // 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.shows('statemachine') && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw(); if (this.shows('particles')) this.particlesEditor.tick(); if (this.shows('shader')) this.shaderEditor.tick(); if (this.shows('pixelart')) this.pixelArtEditor.tick(); if (this.movesByItself()) requestRender(); const drawn = this.gameView.drawTime - drawnBefore; if (this.play.measureFrame(dt, performance.now() - started, drawn, lastRenderTime())) this.showPerformance(); } private showPerformance(): void { const reading = this.play.performance(); if (reading) this.gameView.performanceOverlay.show({ fps: this.fps, ...reading }); } /** * 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.shows('particles') || this.shows('pixelart') || (this.shows('shader') && this.shaderEditor.animates) || (this.shows('animation') && this.animationEditor.isPlaying) || (this.shows('composition') && this.compositionEditor.isPlaying) || (this.shows('tile') && this.tileEditor.isPlaying) || (this.shows('curve') && this.curveEditor.isPlaying) || (this.shows('pattern') && this.patternEditor.isPlaying) || (this.shows('song') && this.songEditor.isPlaying) || (this.shows('sound') && this.soundEditor.isPlaying) || (this.shows('video') && this.videoEditor.isPlaying) || (this.shows('eq') && this.eqEditor.hasSpectrum) || (this.shows('filter') && this.filterEditor.hasSpectrum) || (this.shows('compressor') && this.compressorEditor.moving) || this.facesMoving ); } /** * 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; /** Held at the start until the Game tab's driven videos have a picture (#404). */ waiting: boolean; time: number; sceneId: string; panelUpdates: { structure: number; values: number }; performance: ReturnType; } { return { playing: this.play.isPlaying, paused: this.play.isPaused, gamePaused: this.play.isGamePaused, waiting: this.play.isWaiting, time: this.play.time, sceneId: this.play.sceneId, panelUpdates: this.play.panelUpdates(), performance: this.play.performance(), }; } /** * The recordings of the game view (#405), those of the editor, and what became of them on the way * into the project. */ recordingInfo(): ReturnType & { editor: ReturnType; uploads: RecordingUpload[]; } { return { ...this.play.recordingInfo(), editor: this.editorRecorder.info(), uploads: this.recordings.info(), }; } recordingSaved(recording: MadeRecording, options: RecordingUploadOptions): void { this.recordings.add(recording, options); } }