Autosave only keeps the latest revision. Before a risky change there
was no way to put a known good state aside and go back to it later.
The main menu now has "Snapshots..." to create, restore and delete
named copies of the open project.
- Server: SnapshotStore.php keeps each snapshot as a copy of the
project JSON under data/snapshots/<project>/ (git-ignored). Four
routes under projects/{id}/snapshots use the same owner check as the
project, so an admin also sees foreign snapshots.
- Create writes the autosave first. With unsaved edits (play with open
edits, failed save, conflict) no snapshot is made and a message says
why. The rule is the pure function in src/editor/snapshots.ts.
- An empty name falls back to the local date and time.
- Restore asks first, then keeps the current state as "Before
restoring <name>". It writes the snapshot back as a new revision and
reloads the project, so the undo list is empty. A restore can itself
be undone by restoring that snapshot.
- Deleting a project deletes its snapshots too. Images, sounds, fonts
and pixel art are content-addressed and never deleted, so a snapshot
can always reach them.
- Client: SnapshotStore and HttpSnapshotStore in src/core/store, the
SnapshotsDialog, and the flows in ProjectSession. A stale revision
(409) becomes StaleRevisionError.
- CI runs the new PHP test in the gate job next to accounts.
Not covered by e2e: the lock during play and the conflict case. Both
are unit tests only.
Tests:
- check.sh: 164 files, 2401 tests, CHECK EXIT 0.
- tests/php/snapshots.test.php: 14 checks, all passed.
- tests/editor/snapshots.test.ts: 5 tests for the time format, the
blocker rule, the routes and 409.
- New e2e module 50 on a throwaway project: create, rename, restore,
undo the restore, delete, then delete the project and get 404 on
its snapshots. 46b expects the new menu entry. e2e 46,50: 20 checks
passed. The check sum goes from 1385 to 1393 (computed).
- Counter-checks, originals restored and compared with cmp: blocker
always allowed fails 1 unit test. No sorting fails 2 PHP checks.
Restore without keeping the old state fails 50c and aborts 50d.
- The first sort test with 2 snapshots passed by chance half the time
because of random ids. It now uses 8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
873 lines
36 KiB
TypeScript
873 lines
36 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,
|
||
Menu,
|
||
type MenuEntry,
|
||
Panel,
|
||
renderCount,
|
||
requestRender,
|
||
Splitter,
|
||
Theme,
|
||
ui,
|
||
VStack,
|
||
type Widget,
|
||
} from '../ui';
|
||
import { TabStrip, type TabPlace } from './TabStrip';
|
||
import type { AssetTabEditor } from './AssetTabEditor';
|
||
import { closeDocument, type DocumentRow, openDocument, replaceDocument } from './documents';
|
||
import { ConsolePanel } from './console/ConsolePanel';
|
||
import { GamePane } from './viewport/GamePane';
|
||
import { GameViewport } from './viewport/GameViewport';
|
||
import { SceneViewport } from './viewport/SceneViewport';
|
||
import { TAB_OF_ASSET } from './assetTabs';
|
||
import { attachAutosave } from './autosave';
|
||
import { PasswordDialog } from './auth/PasswordDialog';
|
||
import type { OpenProjectDialog } from './OpenProjectDialog';
|
||
import type { SnapshotsDialog } from './SnapshotsDialog';
|
||
import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
|
||
import { dialogSlot } from './FormDialog';
|
||
import { ContainerEditor } from './container/ContainerEditor';
|
||
import { EditorRun } from './EditorRun';
|
||
import { GraphEditor } from './graph/GraphEditor';
|
||
import { Inspector } from './inspector/Inspector';
|
||
import { ListEditor } from './list/ListEditor';
|
||
import { resolveList } from './list/slot';
|
||
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 } from './state';
|
||
import { PixelArtEditor } from './pixelart/PixelArtEditor';
|
||
import { VectorArtEditor } from './vectorart/VectorArtEditor';
|
||
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'
|
||
| 'tile'
|
||
| 'container'
|
||
| 'list'
|
||
| '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 the list the List tab edits – there is one. The
|
||
* user management (#179) is no document, but it opens and closes like one.
|
||
*/
|
||
export type DocumentId = `asset:${string}` | 'list' | '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 | 'list' | 'management'>;
|
||
|
||
const FIXED_TABS = ['scene', 'game'] as const;
|
||
|
||
const PANE_ORDER = [
|
||
'scene',
|
||
'game',
|
||
'graph',
|
||
'statemachine',
|
||
'shader',
|
||
'particles',
|
||
'texture',
|
||
'vectorart',
|
||
'pixelart',
|
||
'animation',
|
||
'tile',
|
||
'container',
|
||
'list',
|
||
'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',
|
||
tile: 'Tile',
|
||
container: 'Container',
|
||
list: 'List',
|
||
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 === 'list' || tab === 'management' || tab.startsWith('asset:');
|
||
}
|
||
|
||
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 tileEditor: TileEditor;
|
||
readonly containerEditor: ContainerEditor;
|
||
readonly listEditor: ListEditor;
|
||
/** 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 tab: its view, and the tilemap panel to its right when there is one. */
|
||
private readonly sceneRow = new HStack();
|
||
readonly console = new ConsolePanel();
|
||
tab: TabId = 'scene';
|
||
/** Frame rate, refreshed twice a second; null until the first measurement. */
|
||
fps: number | null = null;
|
||
private readonly play: PlaySession;
|
||
private readonly editorRun: EditorRun;
|
||
private readonly projects: ProjectSession;
|
||
private readonly leftColumn: VStack;
|
||
readonly leftSplitter = new Splitter('x');
|
||
readonly rightSplitter = new Splitter('x');
|
||
readonly consoleSplitter = new Splitter('y');
|
||
/** Between the asset panel and the scene objects below it. */
|
||
readonly columnSplitter = new Splitter('y');
|
||
private readonly workspace: HStack;
|
||
private readonly centre: VStack;
|
||
private readonly tabs: TabStrip<StripTab>;
|
||
private readonly panes: Record<CentreTab, Widget>;
|
||
private readonly assetEditors: Record<AssetKind, AssetTabEditor<Asset>>;
|
||
private documents: DocumentRow<DocumentId> = { docs: [], active: null };
|
||
private readonly pluginPanes = new Map<PluginTabId, Widget>();
|
||
/** The plugins of this editor and their switches; null until `startPlugins` has run. */
|
||
plugins: PluginManager | null = null;
|
||
private readonly toolbar: Toolbar;
|
||
private readonly passwordDialog = dialogSlot<PasswordDialog>();
|
||
/** Drawn-frame count at the last measurement, not a counter of its own. */
|
||
private fpsFrames = 0;
|
||
private fpsElapsed = 0;
|
||
/** What this editor registered outside itself, as the calls that take it away again (#177). */
|
||
private readonly cleanups: (() => void)[] = [];
|
||
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
|
||
private disposed = false;
|
||
private leftWidth = 250;
|
||
private rightWidth = 350;
|
||
/** Starts out as wide as the inspector, then goes its own way. */
|
||
private tilemapWidth = this.rightWidth;
|
||
private consoleHeight = 300;
|
||
/** The asset panel's part of the left column's height; the scene objects get the rest. */
|
||
private assetsShare = 0.5;
|
||
|
||
constructor(
|
||
app: Application,
|
||
private readonly root: VStack,
|
||
private readonly state: EditorState,
|
||
store: ProjectStore,
|
||
textures: TextureStore,
|
||
fonts: FontStore,
|
||
sounds: SoundStore,
|
||
snapshots: SnapshotStore,
|
||
account: EditorAccount,
|
||
) {
|
||
this.viewport = new SceneViewport(state);
|
||
this.gameView = new GameViewport(state);
|
||
this.gamePane = new GamePane(this.gameView);
|
||
this.graphEditor = new GraphEditor(state);
|
||
this.stateMachineEditor = new StateMachineEditor(state);
|
||
this.shaderEditor = new ShaderEditor(state);
|
||
this.particlesEditor = new ParticlesEditor(state);
|
||
this.textureEditor = new TextureEditor(state);
|
||
this.vectorArtEditor = new VectorArtEditor(state);
|
||
this.pixelArtEditor = new PixelArtEditor(state);
|
||
this.animationEditor = new AnimationEditor(state);
|
||
this.tileEditor = new TileEditor(state);
|
||
this.containerEditor = new ContainerEditor(state);
|
||
this.listEditor = new ListEditor(state);
|
||
this.hierarchy = new HierarchyPanel(state);
|
||
this.inspector = new Inspector(state);
|
||
this.tilemapPanel = new TilemapPanel(state);
|
||
this.play = new PlaySession(state, this);
|
||
this.editorRun = new EditorRun(state, {
|
||
// The game view is the one place where no key means anything to the editor, so that is where
|
||
// a behavior may have them – unless a widget in it is focused and wants them itself.
|
||
focused: () => this.tab === 'game' && ui().focus.focused === null,
|
||
onWorld: (world) => {
|
||
this.gameView.setEditorWorld(world);
|
||
this.viewport.setEditorWorld(world);
|
||
},
|
||
});
|
||
this.projects = new ProjectSession(
|
||
state,
|
||
store,
|
||
() => {
|
||
if (this.play.isPlaying) this.play.stop();
|
||
},
|
||
{ store: snapshots, flush: () => this.autosave.flush() },
|
||
// Every project brings its own plugins (#119); until `startPlugins` has run there is no
|
||
// manager, and a project with plugins simply has none of them.
|
||
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
|
||
);
|
||
|
||
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
|
||
this.graphEditor.canvas.timeProvider = () => this.play.time;
|
||
// Only one of the two ever runs a given machine, for the reason the live variables are one call.
|
||
this.stateMachineEditor.canvas.stateProvider = (assetId) => {
|
||
const playing = this.play.machineStates(assetId);
|
||
return playing.length > 0 ? playing : this.editorRun.machineStates(assetId);
|
||
};
|
||
// Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`).
|
||
this.inspector.liveVariables = (assetId) =>
|
||
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
|
||
|
||
this.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.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,
|
||
tile: this.tileEditor,
|
||
container: this.containerEditor,
|
||
};
|
||
this.panes = {
|
||
scene: this.sceneRow,
|
||
game: this.gamePane,
|
||
...this.assetEditors,
|
||
list: this.listEditor,
|
||
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(),
|
||
copyKeys: () => this.tab === 'animation' && this.animationEditor.canvas.copyKeys(),
|
||
pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(),
|
||
}),
|
||
);
|
||
const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000);
|
||
app.ticker.add(onTick);
|
||
this.cleanups.push(() => app.ticker.remove(onTick));
|
||
// 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}`);
|
||
});
|
||
// Opening a list is a request to look at it, so this switches even when the slot is unchanged.
|
||
state.on('list', () => {
|
||
if (state.listSlot) this.openDocument('list');
|
||
else this.closeDocument('list');
|
||
});
|
||
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.showTab('scene');
|
||
});
|
||
state.on('mode', () => this.editorRun.invalidate());
|
||
}
|
||
|
||
/**
|
||
* The main menu's last group (#179): the user management for an administrator, and the plugins
|
||
* for everybody – both open the one tab, which holds the plugins below the accounts.
|
||
*/
|
||
private managementEntries(): MenuEntry[] {
|
||
const open = () => this.openDocument('management');
|
||
return [
|
||
{ separator: true },
|
||
...(this.usersPane ? [{ label: 'User management', onSelect: open }] : []),
|
||
{ label: 'Plugins', onSelect: open },
|
||
];
|
||
}
|
||
|
||
/**
|
||
* The account menu under its button (#182). The name stands in it as an entry that cannot be
|
||
* picked: it says who is signed in without costing the crowded toolbar another label.
|
||
*/
|
||
private openAccountMenu(anchor: Widget, account: EditorAccount): void {
|
||
new Menu([
|
||
{ label: `Signed in as ${account.user}`, disabled: true, onSelect: () => {} },
|
||
{ separator: true },
|
||
{
|
||
label: 'Change password…',
|
||
onSelect: () => {
|
||
const dialog = this.passwordDialog.open(
|
||
new PasswordDialog(account.changePassword, () =>
|
||
this.state.setNotice('Password changed – your other sessions were signed out'),
|
||
),
|
||
);
|
||
dialog.focusFirstField();
|
||
},
|
||
},
|
||
{ label: 'Logout', onSelect: () => account.logOut() },
|
||
]).openBelow(anchor);
|
||
}
|
||
|
||
/** The open "Change password" dialog, for the check scripts. */
|
||
accountDialog(): PasswordDialog | null {
|
||
return this.passwordDialog.current();
|
||
}
|
||
|
||
/** The open "Project settings" dialog (#183), for the check scripts. */
|
||
projectSettingsDialog(): ProjectSettingsDialog | null {
|
||
return this.projects.projectSettings();
|
||
}
|
||
|
||
/** The open "Open project" dialog (#183), for the check scripts. */
|
||
projectPickerDialog(): OpenProjectDialog | null {
|
||
return this.projects.projectPicker();
|
||
}
|
||
|
||
/** The open "Snapshots" dialog (#231), for the check scripts. */
|
||
snapshotsDialog(): SnapshotsDialog | null {
|
||
return this.projects.snapshotList();
|
||
}
|
||
|
||
/** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */
|
||
hasTab(id: string): boolean {
|
||
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
|
||
}
|
||
|
||
/** Puts a plugin's tab after the fixed ones, ahead of the documents; `hasTab` has said the id is free. */
|
||
addPluginTab(id: string, label: string, content: Widget): void {
|
||
const tab: PluginTabId = `plugin:${id}`;
|
||
content.visible = false;
|
||
this.centre.addChildAt(content, this.centre.getChildIndex(this.consoleSplitter));
|
||
content.flex = 1;
|
||
this.tabs.addTab(tab, label, FIXED_TABS.length + this.pluginPanes.size);
|
||
this.pluginPanes.set(tab, content);
|
||
this.centre.relayout();
|
||
}
|
||
|
||
/**
|
||
* Takes a plugin's tab away again – the plugin was switched off, or its `activate` failed after
|
||
* adding it. Whoever was looking at it is put back on the scene.
|
||
*/
|
||
removePluginTab(id: string): void {
|
||
const tab: PluginTabId = `plugin:${id}`;
|
||
const pane = this.pluginPanes.get(tab);
|
||
if (!pane) return;
|
||
this.pluginPanes.delete(tab);
|
||
pane.destroy({ children: true });
|
||
this.tabs.removeTab(tab);
|
||
if (this.tab === tab) this.showTab('scene');
|
||
else this.centre.relayout();
|
||
}
|
||
|
||
/** What a plugin put into its tab, for the check scripts. */
|
||
pluginPane(id: string): Widget | undefined {
|
||
return this.pluginPanes.get(`plugin:${id}`);
|
||
}
|
||
|
||
/** The documents open in the tab row, in order, and the one showing – for the check scripts. */
|
||
openDocuments(): { docs: DocumentId[]; active: DocumentId | null } {
|
||
return { docs: [...this.documents.docs], active: this.documents.active };
|
||
}
|
||
|
||
/** Brings a document up, opening a tab for it if it has none. Unknown assets are ignored. */
|
||
openDocument(doc: DocumentId): void {
|
||
if (!this.kindOf(doc)) return;
|
||
if (this.documents.active === doc) {
|
||
// Still worth a look at the name: the List tab may have been pointed at another list.
|
||
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));
|
||
// The List tab edits the one slot there is; a closed tab lets go of it.
|
||
if (doc === 'list' && this.state.listSlot) this.state.openList(null);
|
||
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 === 'list') {
|
||
if (this.state.listSlot) this.openDocument('list');
|
||
return;
|
||
}
|
||
if (tab === 'management') {
|
||
this.openDocument('management');
|
||
return;
|
||
}
|
||
if (tab in this.assetEditors) {
|
||
const editor = this.assetEditors[tab as AssetKind];
|
||
const id =
|
||
editor.selectedId() ||
|
||
editor.currentAssetId ||
|
||
this.state.project.assets.find((a) => editor.isMine(a))?.id;
|
||
if (id && findAsset(this.state.project, id)) this.openDocument(`asset:${id}`);
|
||
else this.display(tab, null);
|
||
return;
|
||
}
|
||
this.display(tab, tab as FixedTab | PluginTabId);
|
||
}
|
||
|
||
private pickTab(tab: StripTab): void {
|
||
if (isDocument(tab)) this.openDocument(tab);
|
||
else this.showTab(tab);
|
||
}
|
||
|
||
private kindOf(doc: DocumentId): CentreTab | null {
|
||
if (doc === 'list') return this.state.listSlot ? 'list' : 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));
|
||
// 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 the list's owner and name, as its tab heads it. */
|
||
private labelOf(doc: DocumentId): string {
|
||
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
|
||
if (doc === 'list') {
|
||
const slot = this.state.listSlot;
|
||
return (slot && resolveList(this.state, slot)?.title) ?? '';
|
||
}
|
||
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 type = doc === 'list' ? undefined : findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||
return type ? ASSET_ICON_SVG[type] : LIST_ICON_SVG;
|
||
}
|
||
|
||
/** Brings the row in line with `documents`: new tabs in their place, gone ones out, names current. */
|
||
private syncStrip(): void {
|
||
for (const tab of [...this.tabs.tabs])
|
||
if (isDocument(tab) && !this.documents.docs.includes(tab)) this.tabs.removeTab(tab);
|
||
const base = FIXED_TABS.length + this.pluginPanes.size;
|
||
this.documents.docs.forEach((doc, index) => {
|
||
const kind = this.kindOf(doc);
|
||
if (!this.tabs.has(doc) && kind)
|
||
this.tabs.addTab(doc, this.labelOf(doc), base + index, {
|
||
kind: doc === 'management' ? this.labelOf(doc) : TAB_LABELS[kind],
|
||
icon: this.iconOf(doc),
|
||
onClose: () => this.closeDocument(doc),
|
||
});
|
||
else this.tabs.setLabel(doc, this.labelOf(doc));
|
||
});
|
||
}
|
||
|
||
/** After an edit: a deleted asset takes its tab along, a renamed one renames it. */
|
||
private syncDocuments(): void {
|
||
for (const doc of this.documents.docs) if (!this.kindOf(doc)) this.closeDocument(doc);
|
||
this.syncStrip();
|
||
}
|
||
|
||
/** Shows the pane for `tab`, with `strip` lit in the row – none for an empty kind reached by `showTab`. */
|
||
private display(tab: TabId, strip: StripTab | null): void {
|
||
this.tab = tab;
|
||
this.documents = { docs: this.documents.docs, active: strip && isDocument(strip) ? strip : null };
|
||
// The hierarchy lists what the visible tab edits; every other tab is about the scene.
|
||
this.hierarchy.showObjectsOf(
|
||
tab === 'container'
|
||
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
|
||
: () => this.state.activeScene,
|
||
);
|
||
for (const key of PANE_ORDER) this.panes[key].visible = key === tab;
|
||
for (const [key, pane] of this.pluginPanes) pane.visible = key === tab;
|
||
// The view checks its own flag before it redraws, and the row around it is what switches now.
|
||
this.viewport.visible = tab === 'scene';
|
||
this.gameView.visible = tab === 'game';
|
||
this.syncTilemapPanel();
|
||
this.tabs.setActive(strip);
|
||
this.centre.relayout();
|
||
// Hidden, it skipped every change; its pane may come back at the same size and not relayout.
|
||
this.gameView.redraw();
|
||
ui().focus.validate();
|
||
}
|
||
|
||
/** The tilemap panel belongs to the scene tab and needs a map to paint; otherwise the view gets its room. */
|
||
private syncTilemapPanel(): void {
|
||
const shown = this.tab === 'scene' && this.tilemapPanel.hasTarget;
|
||
if (this.tilemapPanel.visible === shown) return;
|
||
this.tilemapPanel.visible = shown;
|
||
this.tilemapSplitter.visible = shown;
|
||
this.sceneRow.relayout();
|
||
}
|
||
|
||
/**
|
||
* Global x-extent of the tab buttons in the row, from the widget box – not `getBounds()`, which
|
||
* on an untoggled ghost tab sees nothing but the label (the background is not drawn at all).
|
||
*/
|
||
tabRow(): { shown: TabPlace<StripTab>[]; hidden: StripTab[]; order: StripTab[] } {
|
||
return { shown: this.tabs.places(), hidden: this.tabs.hidden(), order: [...this.tabs.tabs] };
|
||
}
|
||
|
||
boot(): Promise<void> {
|
||
return this.projects.boot();
|
||
}
|
||
|
||
/**
|
||
* Takes the editor off the screen and lets go of everything it registered elsewhere – ticker,
|
||
* shortcuts, file drops, plugins, the autosave. Signing out does this instead of reloading the
|
||
* page (#177): the canvas, the renderer and the UI context stay, only what is drawn changes.
|
||
*
|
||
* Unsaved work is written first, while the session that owns it is still good.
|
||
*/
|
||
async dispose(): Promise<void> {
|
||
if (this.disposed) return;
|
||
this.disposed = true;
|
||
await this.autosave.flush();
|
||
this.autosave.stop();
|
||
if (this.play.isPlaying) this.play.stop();
|
||
this.editorRun.shutDown();
|
||
await this.plugins?.stopAll();
|
||
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
|
||
// Closed rather than just thrown away with the overlay below, so the slot lets go of it too.
|
||
this.passwordDialog.current()?.close();
|
||
// A menu or a picker left open belongs to widgets that are about to be destroyed.
|
||
for (const popup of ui().overlay.removeChildren()) popup.destroy({ children: true });
|
||
ui().focus.blur();
|
||
this.root.removeChild(this.toolbar, this.workspace);
|
||
this.toolbar.destroy({ children: true });
|
||
this.workspace.destroy({ children: true });
|
||
// The sign-in screen sets `root` to the size of the window as well, and `resize` lays out only
|
||
// when the size changed – the same reason `App.ts` forgets the size after signing in.
|
||
this.root.resize(0, 0);
|
||
}
|
||
|
||
showGame(): void {
|
||
this.showTab('game');
|
||
}
|
||
|
||
leaveGame(): void {
|
||
if (this.tab === 'game') this.showTab('scene');
|
||
}
|
||
|
||
private tick(dt: number): void {
|
||
this.measureFps(dt);
|
||
// One step for everything below: a world gets exactly the seconds the particle, shader and
|
||
// animation clocks moved, so the global time scale cannot pull them apart.
|
||
// The game's own pause holds the clock as well, so particles and shaders freeze with the world.
|
||
const step = gameClock.advance(dt, this.state.paused || this.play.isGamePaused);
|
||
this.play.tick(step, timeStep(dt, gameClock.scale, this.state.paused));
|
||
this.editorRun.tick(step);
|
||
this.viewport.animate();
|
||
this.gameView.animate();
|
||
this.console.refresh();
|
||
if (this.tab === 'animation') this.animationEditor.tick(step);
|
||
if (this.tab === 'tile') this.tileEditor.tick(step);
|
||
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
||
// to do it, and its states move just the same.
|
||
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
||
if (this.tab === 'particles') this.particlesEditor.tick();
|
||
if (this.tab === 'shader') this.shaderEditor.tick();
|
||
if (this.tab === 'pixelart') this.pixelArtEditor.tick();
|
||
if (this.movesByItself()) requestRender();
|
||
}
|
||
|
||
/**
|
||
* What changes without anyone touching it, and therefore cannot be woken by an event: the
|
||
* running world, a Time shader on screen, the previews that are moving pictures.
|
||
*/
|
||
private movesByItself(): boolean {
|
||
return (
|
||
this.play.isPlaying ||
|
||
this.editorRun.isRunning ||
|
||
(this.viewport.visible && this.viewport.animates) ||
|
||
(this.gameView.visible && this.gameView.animates) ||
|
||
this.tab === 'particles' ||
|
||
this.tab === 'pixelart' ||
|
||
(this.tab === 'shader' && this.shaderEditor.animates) ||
|
||
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
|
||
(this.tab === 'tile' && this.tileEditor.isPlaying)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 } {
|
||
return {
|
||
playing: this.play.isPlaying,
|
||
paused: this.play.isPaused,
|
||
gamePaused: this.play.isGamePaused,
|
||
time: this.play.time,
|
||
sceneId: this.play.sceneId,
|
||
};
|
||
}
|
||
}
|