The top bar, the magnet, the eye and many marks of the inspector are
pictures without a caption. Whoever did not know one could only try
it. The toolkit had no hint on hover, which also forced the point
menu of #195 to be words. The answers in the issue settle the open
points: a cut tab name shows in full, shortcuts are part of the
text, the point menu stays as it is.
- Button takes a tooltip, a text or a function asked each time. It
shows on a small sign once the pointer has rested for half a
second.
- There is one sign for the page, a TooltipLayer on the overlay
above the menus. A control reports each pointer move and the
pointer leaving, the layer does the waiting.
- A press, a key, the wheel or a blur of the window takes the sign
away. initUI listens for them on the window.
- placeTooltip in ui/tooltip.ts is the rule for the place as a pure
function: under the control and flush left, flush right at the
right border, above the control at the bottom.
- toolbarTip says what the five buttons of the top bar do right now.
Undo and redo name their step from CommandStack.undoLabel and
redoLabel, as in "Undo Paint tiles (Ctrl+Z)".
- A SortableList reports its own marks with a key per mark: the eye,
the duplicate mark, the cross and the plus.
- The magnet, both fullscreen buttons, the eye of the scene tab, the
overflow of the tab strip, the plus of the assets panel, the marks
of the inspector rows and the nine anchors of the resize dialog
carry a text.
Decided here:
- Every button whose text is cut short shows the whole text, not
only a tab.
- A row action of the inspector names the one thing a click does. A
button that opens a menu says only Reset, Apply or Size from the
picture.
- A button just clicked stays quiet until the pointer has left it.
- A greyed out button shows no sign.
- The switches of the eye popup, the cross of a document tab and the
tools of pixel art have none. The tools carry words, other than
the issue assumes.
- The marks of a list say Show or Hide, Duplicate, Remove and Add,
whatever the list holds.
Found on the way:
- Signing out empties the overlay and would have destroyed a sign on
screen with it. The editor takes the tooltip away first. This has
no test of its own.
- The text hooks locate and has skip the sign, or it would answer
for the button under it.
Tests:
- Gate on melektaus-atx: 210 files, 3209 tests (3176 + 33), exit 0
- e2e 46 on melektaus-atx: 20 checks, all pass
- e2e 1-14 on melektaus-atx: 508 checks, all pass
- e2e 1-9,14,39,40,44-48: 685 pass, 2 fail, both in module 14. It
measures tabs that modules 10 to 13 open, and the chain 1-14 is
green. The cut chain was not run on the state before.
- No full run and no smokes. The sum of 1667 (1664 + 3) is reckoned,
1664 is what CI counted for 3452899.
- 33 unit tests in tests/ui/tooltip.test.ts
- Three e2e checks in block 46h of module 46: the redo button after
the wait, taking the sign away, turning at the right border
- 50 unit counter-probes and four browser counter-probes all fail
their tests. Three of the unit ones did not at first and got
sharper tests.
- Not checked in the browser: the sign of a cut tab, the marks of
the lists, turning upwards at the bottom, each other button alone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1059 lines
43 KiB
TypeScript
1059 lines
43 KiB
TypeScript
import type { Application, Ticker } from 'pixi.js';
|
||
import type { Asset } from '../core/model/Asset';
|
||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||
import type { Role } from '../core/store/AuthStore';
|
||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||
import { pluginsOf } from '../core/store/projectPlugins';
|
||
import type { FontStore } from '../core/store/FontStore';
|
||
import type { SnapshotStore } from '../core/store/SnapshotStore';
|
||
import type { SoundStore } from '../core/store/SoundStore';
|
||
import type { TextureStore } from '../core/store/TextureStore';
|
||
import type { UsersStore } from '../core/store/UsersStore';
|
||
import { gameClock, timeStep } from '../core/time';
|
||
import {
|
||
clamp,
|
||
HStack,
|
||
isRendering,
|
||
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, openDocument, replaceDocument } from './documents';
|
||
import { ConsolePanel } from './console/ConsolePanel';
|
||
import { GamePane } from './viewport/GamePane';
|
||
import { ScenePane } from './viewport/ScenePane';
|
||
import { GameViewport } from './viewport/GameViewport';
|
||
import { SceneViewport } from './viewport/SceneViewport';
|
||
import { leaveFullscreen, onFullscreenChange } from '../viewport/fullscreen';
|
||
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 { ContainerEditor } from './container/ContainerEditor';
|
||
import { EditorRun } from './EditorRun';
|
||
import { GraphEditor } from './graph/GraphEditor';
|
||
import { Inspector } from './inspector/Inspector';
|
||
import { valuesTitle, VariablesEditor } from './variables/VariablesEditor';
|
||
import { valuesKey } from './variables/target';
|
||
import { pickView } from './objectPick';
|
||
import { AnimationEditor } from './animation/AnimationEditor';
|
||
import { TileEditor } from './tile/TileEditor';
|
||
import { AssetPanel } from './panels/AssetPanel';
|
||
import {
|
||
ASSET_ICON_SVG,
|
||
GAME_ICON_SVG,
|
||
LIST_ICON_SVG,
|
||
PLUGINS_ICON_SVG,
|
||
USERS_ICON_SVG,
|
||
} from './panels/assetIcons';
|
||
import { HierarchyPanel } from './panels/HierarchyPanel';
|
||
import { TilemapPanel } from './panels/TilemapPanel';
|
||
import { Toolbar } from './panels/Toolbar';
|
||
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
|
||
import { ProjectSession } from './ProjectSession';
|
||
import { ParticlesEditor } from './particles/ParticlesEditor';
|
||
import { ShaderEditor } from './shader/ShaderEditor';
|
||
import { StateMachineEditor } from './statemachine/StateMachineEditor';
|
||
import { installShortcuts } from './shortcuts';
|
||
import type { PlayFrom } from '../core/model/playSettings';
|
||
import type { EditorState, ValuesTarget } from './state';
|
||
import { copySelection } from './clipboard';
|
||
import { PixelArtEditor } from './pixelart/PixelArtEditor';
|
||
import { VectorArtEditor } from './vectorart/VectorArtEditor';
|
||
import { CurveEditor } from './curve/CurveEditor';
|
||
import { TextureEditor } from './texture/TextureEditor';
|
||
import { installFileImport } from './fileImport';
|
||
import type { PluginManager } from './plugins/loader';
|
||
import { PluginsPane } from './plugins/PluginsPane';
|
||
import { ManagementPane } from './users/ManagementPane';
|
||
import { UsersPane } from './users/UsersPane';
|
||
|
||
export type CentreTab =
|
||
| 'scene'
|
||
| 'graph'
|
||
| 'statemachine'
|
||
| 'shader'
|
||
| 'particles'
|
||
| 'game'
|
||
| 'texture'
|
||
| 'vectorart'
|
||
| 'pixelart'
|
||
| 'animation'
|
||
| 'curve'
|
||
| 'tile'
|
||
| 'container'
|
||
| 'variables'
|
||
| 'management';
|
||
|
||
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
|
||
export type PluginTabId = `plugin:${string}`;
|
||
/** What is showing: a fixed tab, a plugin's, or the kind of the open document on screen. */
|
||
export type TabId = CentreTab | PluginTabId;
|
||
/**
|
||
* An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its
|
||
* `valuesKey` – as many of those as were opened. The user management (#179) is no document, but it
|
||
* opens and closes like one.
|
||
*/
|
||
export type DocumentId = `asset:${string}` | `values:${string}` | 'management';
|
||
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
|
||
export interface EditorAccount {
|
||
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
|
||
user: string;
|
||
/** What the editor offers: an administrator gets the user management (#180, #179). */
|
||
role: Role;
|
||
users: UsersStore;
|
||
logOut: () => void;
|
||
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
|
||
changePassword: (current: string, next: string) => Promise<void>;
|
||
}
|
||
|
||
/** A button in the tab row. */
|
||
export type StripTab = FixedTab | PluginTabId | DocumentId;
|
||
|
||
type FixedTab = (typeof FIXED_TABS)[number];
|
||
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
|
||
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'management'>;
|
||
|
||
const FIXED_TABS = ['scene', 'game'] as const;
|
||
|
||
const PANE_ORDER = [
|
||
'scene',
|
||
'game',
|
||
'graph',
|
||
'statemachine',
|
||
'shader',
|
||
'particles',
|
||
'texture',
|
||
'vectorart',
|
||
'pixelart',
|
||
'animation',
|
||
'curve',
|
||
'tile',
|
||
'container',
|
||
'variables',
|
||
'management',
|
||
] as const;
|
||
|
||
/** The fixed tabs' names, and a document's kind as a word (its tab shows the icon). */
|
||
const TAB_LABELS: Record<CentreTab, string> = {
|
||
scene: 'Scene',
|
||
game: 'Game',
|
||
graph: 'Behavior',
|
||
statemachine: 'State machine',
|
||
shader: 'Shader',
|
||
particles: 'Particles',
|
||
texture: 'Texture',
|
||
vectorart: 'Vector art',
|
||
pixelart: 'Pixel art',
|
||
animation: 'Animation',
|
||
curve: 'Curve',
|
||
tile: 'Tile',
|
||
container: 'Container',
|
||
variables: 'Variables',
|
||
management: 'User management',
|
||
};
|
||
|
||
/** Icons of the fixed tabs (#176); a plugin's own tab has none, it brings no picture of its own. */
|
||
const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
|
||
scene: ASSET_ICON_SVG.scene,
|
||
game: GAME_ICON_SVG,
|
||
};
|
||
|
||
function isDocument(tab: StripTab): tab is DocumentId {
|
||
return tab === 'management' || tab.startsWith('asset:') || tab.startsWith('values:');
|
||
}
|
||
|
||
const MIN_LEFT_WIDTH = 160;
|
||
const MAX_LEFT_WIDTH = 500;
|
||
const MIN_RIGHT_WIDTH = 200;
|
||
const MAX_RIGHT_WIDTH = 600;
|
||
// The floor is the console's own header: dragged all the way shut it stays visible to drag open.
|
||
const MIN_CONSOLE_HEIGHT = Panel.HEADER_HEIGHT;
|
||
const MAX_CONSOLE_HEIGHT = 600;
|
||
// Either panel of the left column keeps at least its header, for the same reason as the console.
|
||
const MIN_PANEL_HEIGHT = Panel.HEADER_HEIGHT;
|
||
|
||
/**
|
||
* Composes toolbar, panels, viewports and graph editor into the one application that edits and
|
||
* plays. What it owns itself is the layout, the tab strip and the frame ticker; running the
|
||
* project is `PlaySession`, loading it is `ProjectSession`.
|
||
*/
|
||
export class Editor implements PlayHost {
|
||
/** Scene tab: the editable model view (live positions while playing). */
|
||
readonly viewport: SceneViewport;
|
||
/** Game tab: the running world through its camera. */
|
||
readonly gameView: GameViewport;
|
||
readonly gamePane: GamePane;
|
||
readonly graphEditor: GraphEditor;
|
||
readonly stateMachineEditor: StateMachineEditor;
|
||
readonly shaderEditor: ShaderEditor;
|
||
readonly particlesEditor: ParticlesEditor;
|
||
readonly textureEditor: TextureEditor;
|
||
readonly vectorArtEditor: VectorArtEditor;
|
||
readonly pixelArtEditor: PixelArtEditor;
|
||
readonly animationEditor: AnimationEditor;
|
||
readonly curveEditor: CurveEditor;
|
||
readonly tileEditor: TileEditor;
|
||
readonly containerEditor: ContainerEditor;
|
||
readonly variablesEditor: VariablesEditor;
|
||
/** What each open Variables tab shows, by its document. */
|
||
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
|
||
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
|
||
readonly pluginsPane = new PluginsPane();
|
||
/** The accounts, above the plugins; null unless an administrator is signed in. */
|
||
readonly usersPane: UsersPane | null;
|
||
private readonly managementPane: ManagementPane;
|
||
readonly hierarchy: HierarchyPanel;
|
||
readonly assets: AssetPanel;
|
||
readonly inspector: Inspector;
|
||
/** Beside the scene view, while exactly one placed tilemap is selected there (#142). */
|
||
readonly tilemapPanel: TilemapPanel;
|
||
readonly tilemapSplitter = new Splitter('x');
|
||
/** The scene view, and the tilemap panel to its right when there is one. */
|
||
private readonly sceneRow = new HStack();
|
||
/** The scene tab: its header above `sceneRow`. */
|
||
readonly scenePane: ScenePane;
|
||
readonly console = new ConsolePanel();
|
||
tab: TabId = 'scene';
|
||
/** Frame rate, refreshed twice a second; null until the first measurement. */
|
||
fps: number | null = null;
|
||
private readonly play: PlaySession;
|
||
private readonly editorRun: EditorRun;
|
||
private readonly projects: ProjectSession;
|
||
private readonly leftColumn: VStack;
|
||
readonly leftSplitter = new Splitter('x');
|
||
readonly rightSplitter = new Splitter('x');
|
||
readonly consoleSplitter = new Splitter('y');
|
||
/** Between the asset panel and the scene objects below it. */
|
||
readonly columnSplitter = new Splitter('y');
|
||
private readonly workspace: HStack;
|
||
private readonly centre: VStack;
|
||
private readonly tabs: TabStrip<StripTab>;
|
||
private readonly panes: Record<CentreTab, Widget>;
|
||
private readonly assetEditors: Record<AssetKind, AssetTabEditor<Asset>>;
|
||
private documents: DocumentRow<DocumentId> = { docs: [], active: null };
|
||
private readonly pluginPanes = new Map<PluginTabId, Widget>();
|
||
/** The plugins of this editor and their switches; null until `startPlugins` has run. */
|
||
plugins: PluginManager | null = null;
|
||
private readonly toolbar: Toolbar;
|
||
private readonly passwordDialog = dialogSlot<PasswordDialog>();
|
||
/** Drawn-frame count at the last measurement, not a counter of its own. */
|
||
private fpsFrames = 0;
|
||
private fpsElapsed = 0;
|
||
/** What this editor registered outside itself, as the calls that take it away again (#177). */
|
||
private readonly cleanups: (() => void)[] = [];
|
||
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
|
||
private disposed = false;
|
||
private leftWidth = 250;
|
||
private rightWidth = 350;
|
||
/** Starts out as wide as the inspector, then goes its own way. */
|
||
private tilemapWidth = this.rightWidth;
|
||
private consoleHeight = 300;
|
||
/** The asset panel's part of the left column's height; the scene objects get the rest. */
|
||
private assetsShare = 0.5;
|
||
|
||
constructor(
|
||
app: Application,
|
||
private readonly root: VStack,
|
||
private readonly state: EditorState,
|
||
store: ProjectStore,
|
||
textures: TextureStore,
|
||
fonts: FontStore,
|
||
sounds: SoundStore,
|
||
snapshots: SnapshotStore,
|
||
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.textureEditor = new TextureEditor(state);
|
||
this.vectorArtEditor = new VectorArtEditor(state);
|
||
this.pixelArtEditor = new PixelArtEditor(state);
|
||
this.animationEditor = new AnimationEditor(state);
|
||
this.curveEditor = new CurveEditor(state);
|
||
this.tileEditor = new TileEditor(state);
|
||
this.containerEditor = new ContainerEditor(state);
|
||
this.variablesEditor = new VariablesEditor(state);
|
||
this.hierarchy = new HierarchyPanel(state);
|
||
this.inspector = new Inspector(state);
|
||
this.tilemapPanel = new TilemapPanel(state);
|
||
this.play = new PlaySession(state, this);
|
||
this.editorRun = new EditorRun(state, {
|
||
// The game view is the one place where no key means anything to the editor, so that is where
|
||
// a behavior may have them – unless a widget in it is focused and wants them itself.
|
||
focused: () => this.tab === 'game' && ui().focus.focused === null,
|
||
onWorld: (world) => {
|
||
this.gameView.setEditorWorld(world);
|
||
this.viewport.setEditorWorld(world);
|
||
},
|
||
});
|
||
this.projects = new ProjectSession(
|
||
state,
|
||
store,
|
||
() => {
|
||
if (this.play.isPlaying) this.play.stop();
|
||
},
|
||
{ 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.tick`).
|
||
this.inspector.liveVariables = (assetId) =>
|
||
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
|
||
this.variablesEditor.liveVariables = this.inspector.liveVariables;
|
||
|
||
this.usersPane =
|
||
account.role === 'admin'
|
||
? new UsersPane(account.users, {
|
||
self: account.user,
|
||
openProject: (id) => this.projects.openProject(id),
|
||
notice: (message, level) => state.setNotice(message, level),
|
||
})
|
||
: null;
|
||
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
|
||
|
||
this.toolbar = new Toolbar(state, {
|
||
openMainMenu: (anchor) => this.projects.openMainMenu(anchor, this.managementEntries()),
|
||
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||
togglePause: () => this.play.togglePause(),
|
||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||
});
|
||
root.add(this.toolbar);
|
||
|
||
this.workspace = new HStack();
|
||
this.leftColumn = new VStack();
|
||
this.assets = new AssetPanel(state, textures, fonts, sounds);
|
||
this.leftColumn.add(this.assets, this.assetsShare);
|
||
this.cleanups.push(installFileImport(app.canvas, { state, textures, fonts, sounds }));
|
||
this.leftColumn.add(this.columnSplitter);
|
||
this.leftColumn.add(this.hierarchy, 1 - this.assetsShare);
|
||
// A share rather than a height, so a taller or shorter window keeps the split the user chose.
|
||
this.columnSplitter.onDrag = (delta) => {
|
||
const room = this.leftColumn.h - Splitter.THICKNESS;
|
||
if (room <= 0) return;
|
||
const floor = Math.min(0.5, MIN_PANEL_HEIGHT / room);
|
||
this.assetsShare = clamp(this.assetsShare + delta / room, floor, 1 - floor);
|
||
this.assets.flex = this.assetsShare;
|
||
this.hierarchy.flex = 1 - this.assetsShare;
|
||
this.leftColumn.relayout();
|
||
};
|
||
|
||
this.centre = new VStack();
|
||
this.tabs = new TabStrip<StripTab>(
|
||
FIXED_TABS.map((tab) => ({ tab, label: TAB_LABELS[tab], icon: FIXED_TAB_ICONS[tab] })),
|
||
(tab) => this.pickTab(tab),
|
||
);
|
||
this.tabs.minHeight = Theme.controlHeight + 8;
|
||
// Written out per tab rather than derived, because a `Record<CentreTab, …>` is what makes
|
||
// adding a tab a type error everywhere it has to be handled instead of a silent omission.
|
||
this.sceneRow.add(this.viewport, 1);
|
||
this.sceneRow.add(this.tilemapSplitter);
|
||
this.sceneRow.add(this.tilemapPanel);
|
||
this.tilemapPanel.minWidth = this.tilemapWidth;
|
||
this.tilemapSplitter.onDrag = (delta) => {
|
||
this.tilemapWidth = clamp(this.tilemapWidth - delta, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
|
||
this.tilemapPanel.minWidth = this.tilemapWidth;
|
||
this.sceneRow.relayout();
|
||
};
|
||
this.tilemapPanel.onTargetChange = () => this.syncTilemapPanel();
|
||
this.syncTilemapPanel();
|
||
this.scenePane = new ScenePane(state, this.viewport, this.sceneRow);
|
||
this.assetEditors = {
|
||
graph: this.graphEditor,
|
||
statemachine: this.stateMachineEditor,
|
||
shader: this.shaderEditor,
|
||
particles: this.particlesEditor,
|
||
texture: this.textureEditor,
|
||
vectorart: this.vectorArtEditor,
|
||
pixelart: this.pixelArtEditor,
|
||
animation: this.animationEditor,
|
||
curve: this.curveEditor,
|
||
tile: this.tileEditor,
|
||
container: this.containerEditor,
|
||
};
|
||
this.panes = {
|
||
scene: this.scenePane,
|
||
game: this.gamePane,
|
||
...this.assetEditors,
|
||
variables: this.variablesEditor,
|
||
management: this.managementPane,
|
||
};
|
||
// Following a call goes on in the same tab: the tab changes its document, not its place.
|
||
this.graphEditor.onRetarget = (from, to) => {
|
||
const next = replaceDocument<DocumentId>(this.documents, `asset:${from}`, `asset:${to}`);
|
||
if (next === this.documents) return;
|
||
this.documents = next;
|
||
this.syncStrip();
|
||
this.showDocument(`asset:${to}`);
|
||
};
|
||
this.centre.add(this.tabs);
|
||
for (const tab of PANE_ORDER) {
|
||
this.centre.add(this.panes[tab], 1);
|
||
this.panes[tab].visible = tab === 'scene';
|
||
}
|
||
this.gameView.visible = false;
|
||
// The console sits below whichever tab is showing, so it is a sibling of the panes, not of
|
||
// the workspace: switching tabs must not move it.
|
||
this.centre.add(this.consoleSplitter);
|
||
this.centre.add(this.console);
|
||
this.console.minHeight = this.consoleHeight;
|
||
this.consoleSplitter.onDrag = (delta) => {
|
||
this.consoleHeight = clamp(this.consoleHeight - delta, MIN_CONSOLE_HEIGHT, MAX_CONSOLE_HEIGHT);
|
||
this.console.minHeight = this.consoleHeight;
|
||
this.centre.relayout();
|
||
};
|
||
|
||
this.leftColumn.minWidth = this.leftWidth;
|
||
this.inspector.minWidth = this.rightWidth;
|
||
this.leftSplitter.onDrag = (delta) => {
|
||
this.leftWidth = clamp(this.leftWidth + delta, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH);
|
||
this.leftColumn.minWidth = this.leftWidth;
|
||
this.workspace.relayout();
|
||
};
|
||
this.rightSplitter.onDrag = (delta) => {
|
||
this.rightWidth = clamp(this.rightWidth - delta, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
|
||
this.inspector.minWidth = this.rightWidth;
|
||
this.workspace.relayout();
|
||
};
|
||
this.workspace.add(this.leftColumn);
|
||
this.workspace.add(this.leftSplitter);
|
||
this.workspace.add(this.centre, 1);
|
||
this.workspace.add(this.rightSplitter);
|
||
this.workspace.add(this.inspector);
|
||
root.add(this.workspace, 1);
|
||
root.resize(app.screen.width, app.screen.height);
|
||
|
||
this.autosave = attachAutosave(state, store);
|
||
this.cleanups.push(
|
||
installShortcuts(state, {
|
||
play: () => this.play.play(),
|
||
stop: () => this.play.stop(),
|
||
togglePause: () => this.play.togglePause(),
|
||
copy: () =>
|
||
(this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) || copySelection(state),
|
||
paste: () => this.paste(),
|
||
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 and a template object exist only in their own document, so selecting one
|
||
// brings it up. Selecting an asset never does: that is what opening it (double-click, Enter,
|
||
// creating it) is for – see the `open` event below.
|
||
state.on('selection', () => {
|
||
const s = state.selection;
|
||
if (s?.kind === 'node' || s?.kind === 'frame') this.openDocument(`asset:${s.assetId}`);
|
||
if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container')
|
||
this.openDocument(`asset:${s.holderId}`);
|
||
});
|
||
state.on('open', () => {
|
||
const s = state.selection;
|
||
if (s?.kind !== 'asset') return;
|
||
if (findAsset(state.project, s.assetId)?.type === 'scene') this.showTab('scene');
|
||
else this.openDocument(`asset:${s.assetId}`);
|
||
});
|
||
// A tab per target: a list opened from inside a tab gets one beside it, not that tab (#252).
|
||
state.on('values', () => {
|
||
const target = state.openedValues;
|
||
if (!target) return;
|
||
const doc: DocumentId = `values:${valuesKey(target)}`;
|
||
if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
|
||
this.openDocument(doc);
|
||
});
|
||
state.on('project', () => {
|
||
this.play.syncFromModel();
|
||
this.editorRun.invalidate();
|
||
this.syncDocuments();
|
||
});
|
||
state.on('load', () => {
|
||
this.editorRun.invalidate();
|
||
for (const doc of this.documents.docs) this.tabs.removeTab(doc);
|
||
this.documents = { docs: [], active: null };
|
||
this.valueTargets.clear();
|
||
this.showTab('scene');
|
||
});
|
||
state.on('mode', () => this.editorRun.invalidate());
|
||
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.tab === 'container') return this.containerEditor.currentAssetId === pick.holderId;
|
||
return this.tab === 'scene' && pickView(this.state, pick.holderId) === 'scene';
|
||
}
|
||
|
||
/** Delete takes the point the visible tab has picked (#288), wherever the keyboard focus is. */
|
||
private removePoint(): boolean {
|
||
switch (this.tab) {
|
||
case 'scene':
|
||
return this.viewport.removePickedCorner();
|
||
case 'container':
|
||
return this.containerEditor.canvas.removePickedCorner();
|
||
case 'texture':
|
||
return this.textureEditor.canvas.removePickedPoint();
|
||
case 'vectorart':
|
||
return this.vectorArtEditor.canvas.removePickedPoint();
|
||
case 'curve':
|
||
return this.curveEditor.canvas.removePickedPoint();
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Ctrl+V lands in whatever the visible tab edits (#240). */
|
||
private paste(): boolean {
|
||
switch (this.tab) {
|
||
case 'animation':
|
||
return this.animationEditor.canvas.pasteKeys();
|
||
case 'scene':
|
||
return this.viewport.pasteHere();
|
||
case 'container':
|
||
return this.containerEditor.canvas.pasteHere();
|
||
case 'graph':
|
||
return this.graphEditor.canvas.paste();
|
||
case 'statemachine':
|
||
return this.stateMachineEditor.canvas.paste();
|
||
case 'shader':
|
||
return this.shaderEditor.canvas.paste();
|
||
case 'particles':
|
||
return this.particlesEditor.canvas.paste();
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The main menu's last group (#179): the user management for an administrator, and the plugins
|
||
* for everybody – both open the one tab, which holds the plugins below the accounts.
|
||
*/
|
||
private managementEntries(): MenuEntry[] {
|
||
const open = () => this.openDocument('management');
|
||
return [
|
||
{ separator: true },
|
||
...(this.usersPane ? [{ label: 'User management', onSelect: open }] : []),
|
||
{ label: 'Plugins', onSelect: open },
|
||
];
|
||
}
|
||
|
||
/**
|
||
* The account menu under its button (#182). The name stands in it as an entry that cannot be
|
||
* picked: it says who is signed in without costing the crowded toolbar another label.
|
||
*/
|
||
private openAccountMenu(anchor: Widget, account: EditorAccount): void {
|
||
new Menu([
|
||
{ label: `Signed in as ${account.user}`, disabled: true, onSelect: () => {} },
|
||
{ separator: true },
|
||
{
|
||
label: 'Change password…',
|
||
onSelect: () => {
|
||
const dialog = this.passwordDialog.open(
|
||
new PasswordDialog(account.changePassword, () =>
|
||
this.state.setNotice('Password changed – your other sessions were signed out'),
|
||
),
|
||
);
|
||
dialog.focusFirstField();
|
||
},
|
||
},
|
||
{ label: 'Logout', onSelect: () => account.logOut() },
|
||
]).openBelow(anchor);
|
||
}
|
||
|
||
/** The open "Change password" dialog, for the check scripts. */
|
||
accountDialog(): PasswordDialog | null {
|
||
return this.passwordDialog.current();
|
||
}
|
||
|
||
/** The open "Project settings" dialog (#183), for the check scripts. */
|
||
projectSettingsDialog(): ProjectSettingsDialog | null {
|
||
return this.projects.projectSettings();
|
||
}
|
||
|
||
/** The open "Open project" dialog (#183), for the check scripts. */
|
||
projectPickerDialog(): OpenProjectDialog | null {
|
||
return this.projects.projectPicker();
|
||
}
|
||
|
||
/** The open "Snapshots" dialog (#231), for the check scripts. */
|
||
snapshotsDialog(): SnapshotsDialog | null {
|
||
return this.projects.snapshotList();
|
||
}
|
||
|
||
/** 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 {
|
||
if (!this.kindOf(doc)) return;
|
||
if (this.documents.active === doc) {
|
||
// Still worth a look at the name: a Variables tab's owner may have been renamed.
|
||
this.syncStrip();
|
||
return;
|
||
}
|
||
this.documents = openDocument(this.documents, doc);
|
||
this.syncStrip();
|
||
this.showDocument(doc);
|
||
}
|
||
|
||
/** Closes a document's tab; nothing is lost, every edit is in the model already. */
|
||
closeDocument(doc: DocumentId): void {
|
||
if (!this.documents.docs.includes(doc)) return;
|
||
const wasShowing = this.documents.active === doc;
|
||
this.documents = closeDocument(this.documents, doc);
|
||
this.tabs.removeTab(doc);
|
||
const kind = this.kindOf(doc);
|
||
if (kind && kind in this.assetEditors)
|
||
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
|
||
this.valueTargets.delete(doc);
|
||
if (!wasShowing) return;
|
||
if (this.documents.active) this.showDocument(this.documents.active);
|
||
else this.showTab('scene');
|
||
}
|
||
|
||
/**
|
||
* Shows a tab by what it is. A fixed or plugin tab is shown as such; a kind of document shows the
|
||
* one the selection is about, else the one its editor last had – the way the check scripts reach
|
||
* a document without knowing its tab.
|
||
*/
|
||
showTab(tab: TabId): void {
|
||
if (tab === 'variables') {
|
||
const doc = [...this.documents.docs].reverse().find((d) => d.startsWith('values:'));
|
||
if (doc) this.openDocument(doc);
|
||
return;
|
||
}
|
||
if (tab === 'management') {
|
||
this.openDocument('management');
|
||
return;
|
||
}
|
||
if (tab in this.assetEditors) {
|
||
const editor = this.assetEditors[tab as AssetKind];
|
||
const id =
|
||
editor.selectedId() ||
|
||
editor.currentAssetId ||
|
||
this.state.project.assets.find((a) => editor.isMine(a))?.id;
|
||
if (id && findAsset(this.state.project, id)) this.openDocument(`asset:${id}`);
|
||
else this.display(tab, null);
|
||
return;
|
||
}
|
||
this.display(tab, tab as FixedTab | PluginTabId);
|
||
}
|
||
|
||
private pickTab(tab: StripTab): void {
|
||
if (isDocument(tab)) this.openDocument(tab);
|
||
else this.showTab(tab);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
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') return 'management';
|
||
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||
const kind = type ? TAB_OF_ASSET[type] : undefined;
|
||
return kind && kind !== 'scene' ? kind : null;
|
||
}
|
||
|
||
private showDocument(doc: DocumentId): void {
|
||
const kind = this.kindOf(doc);
|
||
if (!kind) return;
|
||
if (kind in this.assetEditors) this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length));
|
||
const target = this.valueTargets.get(doc);
|
||
if (kind === 'variables' && target) this.variablesEditor.show(target);
|
||
// Read on every showing: an account may have been changed from another tab in the meantime.
|
||
if (kind === 'management') void this.usersPane?.reload();
|
||
this.display(kind, doc);
|
||
}
|
||
|
||
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
|
||
private labelOf(doc: DocumentId): string {
|
||
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
|
||
const target = this.valueTargets.get(doc);
|
||
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
|
||
return findAsset(this.state.project, doc.slice('asset:'.length))?.name ?? '';
|
||
}
|
||
|
||
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
|
||
private iconOf(doc: DocumentId): string {
|
||
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
|
||
const target = this.valueTargets.get(doc);
|
||
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
|
||
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||
return type ? ASSET_ICON_SVG[type] : LIST_ICON_SVG;
|
||
}
|
||
|
||
/** Brings the row in line with `documents`: new tabs in their place, gone ones out, names current. */
|
||
private syncStrip(): void {
|
||
for (const tab of [...this.tabs.tabs])
|
||
if (isDocument(tab) && !this.documents.docs.includes(tab)) this.tabs.removeTab(tab);
|
||
const base = FIXED_TABS.length + this.pluginPanes.size;
|
||
this.documents.docs.forEach((doc, index) => {
|
||
const kind = this.kindOf(doc);
|
||
if (!this.tabs.has(doc) && kind)
|
||
this.tabs.addTab(doc, this.labelOf(doc), base + index, {
|
||
kind: doc === 'management' ? this.labelOf(doc) : TAB_LABELS[kind],
|
||
icon: this.iconOf(doc),
|
||
onClose: () => this.closeDocument(doc),
|
||
});
|
||
else this.tabs.setLabel(doc, this.labelOf(doc));
|
||
});
|
||
}
|
||
|
||
/** After an edit: a deleted asset takes its tab along, a renamed one renames it. */
|
||
private syncDocuments(): void {
|
||
for (const doc of this.documents.docs) if (!this.kindOf(doc)) this.closeDocument(doc);
|
||
this.syncStrip();
|
||
}
|
||
|
||
/** Shows the pane for `tab`, with `strip` lit in the row – none for an empty kind reached by `showTab`. */
|
||
private display(tab: TabId, strip: StripTab | null): void {
|
||
// 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)
|
||
: () => this.state.activeScene,
|
||
);
|
||
for (const key of PANE_ORDER) this.panes[key].visible = key === tab;
|
||
for (const [key, pane] of this.pluginPanes) pane.visible = key === tab;
|
||
// The view checks its own flag before it redraws, and the row around it is what switches now.
|
||
this.viewport.visible = tab === 'scene';
|
||
this.gameView.visible = tab === 'game';
|
||
this.syncTilemapPanel();
|
||
this.tabs.setActive(strip);
|
||
this.centre.relayout();
|
||
// Hidden, 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.editorRun.shutDown();
|
||
await this.plugins?.stopAll();
|
||
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
|
||
// Closed rather than just thrown away with the overlay below, so the slot lets go of it too.
|
||
this.passwordDialog.current()?.close();
|
||
// 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.
|
||
const step = gameClock.advance(dt, this.state.paused || this.play.isGamePaused);
|
||
this.play.tick(step, timeStep(dt, gameClock.scale, this.state.paused));
|
||
this.editorRun.tick(step);
|
||
this.viewport.animate(sceneDraws);
|
||
this.gameView.animate(gameDraws);
|
||
this.console.refresh();
|
||
if (this.tab === 'animation') this.animationEditor.tick(step);
|
||
if (this.tab === 'tile') this.tileEditor.tick(step);
|
||
// The preview is the editor's own, so it runs on while the game is paused.
|
||
if (this.tab === 'curve') this.curveEditor.tick(dt);
|
||
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
||
// to do it, and its states move just the same.
|
||
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
||
if (this.tab === 'particles') this.particlesEditor.tick();
|
||
if (this.tab === 'shader') this.shaderEditor.tick();
|
||
if (this.tab === 'pixelart') this.pixelArtEditor.tick();
|
||
if (this.movesByItself()) requestRender();
|
||
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.tab === 'particles' ||
|
||
this.tab === 'pixelart' ||
|
||
(this.tab === 'shader' && this.shaderEditor.animates) ||
|
||
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
|
||
(this.tab === 'tile' && this.tileEditor.isPlaying) ||
|
||
(this.tab === 'curve' && this.curveEditor.isPlaying)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Counts drawn frames over the time the renderer was awake. Idle seconds are not slow seconds:
|
||
* since the editor stopped drawing what nobody changed, counting them would drag the readout to
|
||
* zero while everything is fine. It answers "how smooth is it while it moves", and holds its
|
||
* last answer in between.
|
||
*/
|
||
private measureFps(dt: number): void {
|
||
if (!isRendering()) return;
|
||
this.fpsElapsed += dt;
|
||
if (this.fpsElapsed < 0.5) return;
|
||
const drawn = renderCount();
|
||
this.fps = Math.round((drawn - this.fpsFrames) / this.fpsElapsed);
|
||
this.fpsFrames = drawn;
|
||
this.fpsElapsed = 0;
|
||
this.toolbar.setFps(this.fps);
|
||
}
|
||
|
||
/** Read-only view of the running world for the check scripts. */
|
||
worldSnapshot(): WorldSnapshot | null {
|
||
return this.play.snapshot();
|
||
}
|
||
|
||
/** What is running without Play, for the check scripts. */
|
||
editorRunInfo(): { running: boolean; time: number; behaviors: string[]; keyboard: boolean } {
|
||
return this.editorRun.debugInfo();
|
||
}
|
||
|
||
/** Which scene Play would start, and under which of the two rules – for the check scripts. */
|
||
playFromInfo(): { mode: PlayFrom; scene: string } {
|
||
return { mode: this.state.playFrom, scene: this.state.sceneToPlay()?.name ?? '' };
|
||
}
|
||
|
||
/** The two run buttons of the top bar (#183): what they show and whether they take a click. */
|
||
runButtons(): ReturnType<Toolbar['runButtons']> {
|
||
return this.toolbar.runButtons();
|
||
}
|
||
|
||
/** The top bar's icon buttons by name, so a check can click what carries no caption. */
|
||
get toolbarControls(): Toolbar['controls'] {
|
||
return this.toolbar.controls;
|
||
}
|
||
|
||
/**
|
||
* Play state for the check scripts; `time` is the world clock a pause has to freeze. `sceneId`
|
||
* is the scene the run is *in* – the only way a check can see a `Load scene` happen, since the
|
||
* swap leaves the editor's own active scene alone.
|
||
*/
|
||
playInfo(): {
|
||
playing: boolean;
|
||
paused: boolean;
|
||
gamePaused: boolean;
|
||
time: number;
|
||
sceneId: string;
|
||
panelUpdates: { structure: number; values: number };
|
||
performance: ReturnType<PlaySession['performance']>;
|
||
} {
|
||
return {
|
||
playing: this.play.isPlaying,
|
||
paused: this.play.isPaused,
|
||
gamePaused: this.play.isGamePaused,
|
||
time: this.play.time,
|
||
sceneId: this.play.sceneId,
|
||
panelUpdates: this.play.panelUpdates(),
|
||
performance: this.play.performance(),
|
||
};
|
||
}
|
||
}
|