Files
feuerwerk/src/editor/Editor.ts
T
adminandClaude Opus 5.5 d4fc425233
game-maker / gate (push) Successful in 4m19s
game-maker / suite (push) Failing after 17m35s
Draw the editor's mouse pointer from SVG files, on screen too
The pointer drawn into an editor recording lived as paths in code, so
it could not be looked at or edited by hand. The shapes are now SVG
files, and the editor can show the same pointer on screen, so what is
recorded is what was seen.

- src/core/recording/cursors/ holds one file per shape: arrow, hand,
  open-hand, fist, move, crosshair, text and resize. Resize is turned
  for the vertical and diagonal directions.
- data-hot="x y" on the <svg> is the hotspot in viewBox units. Width
  and height in the file are ignored, and anything before <svg>, such
  as <?xml ...> from a drawing program, is cut off.
- The open hand had six fingers. It has a thumb and four now, the
  middle one longest.
- CURSOR_SIZE = 0.8 scales every pointer the same on screen and in a
  recording, so the files keep their units.
- General settings has "Custom mouse pointer", on by default. It hangs
  the files into Pixi's cursorStyles as CSS cursors with the keyword as
  fallback, so the system still draws the pointer and the canvas does
  not. Off gives Pixi's own values back at once.
- A recording draws the pointer from the same files at its own size,
  so it stays sharp in 4K. It still reads the shape from the canvas
  when that holds a url(...) cursor.

Tests:
- Unit recording and view-tween: 3 new, green. They read viewBox and
  data-hot in an edited form, the boxes of turned shapes, the default
  size and old settings without the switch. Red without data-hot and
  without cutting before <svg>.
- e2e 46i-ii: the canvas cursor is a url(...) ending in default and
  the image loads. Off gives inherit and stores false, on brings the
  file back. Red without useCursorFiles.
- e2e 67e: the recording is still black at the mouse position.
- The README names 2046 e2e checks.
- Gate on melektaus-atx: 304 files, 4702 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:33:57 +02:00

1573 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Application, Ticker } from 'pixi.js';
import type { Asset } from '../core/model/Asset';
import { findAsset, findObjectHolder, 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 { 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 { 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<void>;
}
/** A button in the tab row. */
export type StripTab = FixedTab | PluginTabId | DocumentId;
type FixedTab = (typeof FIXED_TABS)[number];
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | NodeTabKind | 'management'>;
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<CentreTab, string> = {
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<Record<FixedTab, string>> = {
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<DocumentId, ValuesTarget>();
/** The node behind each tab of an EQ (#370) or a Compressor (#378). */
private readonly nodeTargets = new Map<DocumentId, { assetId: string; nodeId: string }>();
/** 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<StripTab>;
private readonly panes: Record<CentreTab, Widget>;
private readonly assetEditors: Record<AssetKind, AssetTabEditor<Asset>>;
private documents: DocumentRow<DocumentId> = { 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<CentreTab, { doc: DocumentId; window: FloatWindow }>();
/** 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<PluginTabId, Widget>();
/** The plugins of this editor and their switches; null until `startPlugins` has run. */
plugins: PluginManager | null = null;
private readonly toolbar: Toolbar;
private readonly passwordDialog = dialogSlot<PasswordDialog>();
private readonly generalDialog = dialogSlot<GeneralSettingsDialog>();
private readonly recordingDialog = dialogSlot<EditorRecordingDialog>();
/** 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<void>; 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(),
});
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(),
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<StripTab>(
FIXED_TABS.map((tab) => ({ tab, label: TAB_LABELS[tab], icon: FIXED_TAB_ICONS[tab] })),
(tab) => this.pickTab(tab),
);
this.tabs.minHeight = Theme.controlHeight + 8;
// Written out per tab rather than derived, because a `Record<CentreTab, …>` is what makes
// adding a tab a type error everywhere it has to be handled instead of a silent omission.
this.sceneRow.add(this.viewport, 1);
this.sceneRow.add(this.tilemapSplitter);
this.sceneRow.add(this.tilemapPanel);
this.tilemapPanel.minWidth = this.tilemapWidth;
this.tilemapSplitter.onDrag = (delta) => {
this.tilemapWidth = clamp(this.tilemapWidth - delta, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
this.tilemapPanel.minWidth = this.tilemapWidth;
this.sceneRow.relayout();
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<DocumentId>(this.documents, `asset:${from}`, `asset:${to}`);
if (next === this.documents) return;
this.documents = next;
this.syncStrip();
this.showDocument(`asset:${to}`);
};
this.centre.add(this.tabs);
for (const tab of PANE_ORDER) {
this.centre.add(this.panes[tab], 1);
this.panes[tab].visible = tab === 'scene';
}
this.gameView.visible = false;
// The console sits below whichever tab is showing, so it is a sibling of the panes, not of
// the workspace: switching tabs must not move it.
this.centre.add(this.consoleSplitter);
this.centre.add(this.console);
this.console.minHeight = this.consoleHeight;
this.consoleSplitter.onDrag = (delta) => {
this.consoleHeight = clamp(this.consoleHeight - delta, MIN_CONSOLE_HEIGHT, MAX_CONSOLE_HEIGHT);
this.console.minHeight = this.consoleHeight;
this.centre.relayout();
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(),
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<StripTab>[];
hidden: StripTab[];
order: StripTab[];
labels: Record<string, string>;
} {
return {
shown: this.tabs.places(),
hidden: this.tabs.hidden(),
order: [...this.tabs.tabs],
labels: Object.fromEntries(this.documents.docs.map((doc) => [doc, this.labelOf(doc)])),
};
}
boot(): Promise<void> {
return this.projects.boot();
}
/**
* Takes the editor off the screen and lets go of everything it registered elsewhere – ticker,
* shortcuts, file drops, plugins, the autosave. Signing out does this instead of reloading the
* page (#177): the canvas, the renderer and the UI context stay, only what is drawn changes.
*
* Unsaved work is written first, while the session that owns it is still good.
*/
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
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).
const step = gameClock.advance(dt, this.state.paused || this.play.isGamePaused || this.play.isWaiting);
this.play.tick(step, timeStep(dt, gameClock.scale, this.state.paused));
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<Toolbar['runButtons']> {
return this.toolbar.runButtons();
}
/** The top bar's icon buttons by name, so a check can click what carries no caption. */
get toolbarControls(): Toolbar['controls'] {
return this.toolbar.controls;
}
/**
* Play state for the check scripts; `time` is the world clock a pause has to freeze. `sceneId`
* is the scene the run is *in* – the only way a check can see a `Load scene` happen, since the
* swap leaves the editor's own active scene alone.
*/
playInfo(): {
playing: boolean;
paused: boolean;
gamePaused: boolean;
/** 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<PlaySession['performance']>;
} {
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<PlaySession['recordingInfo']> & {
editor: ReturnType<EditorRecorder['info']>;
uploads: RecordingUpload[];
} {
return {
...this.play.recordingInfo(),
editor: this.editorRecorder.info(),
uploads: this.recordings.info(),
};
}
recordingSaved(recording: MadeRecording): void {
this.recordings.add(recording);
}
}