import type { Application } from 'pixi.js'; import { findAsset, findObjectHolder } from '../core/model/Project'; import type { ProjectStore } from '../core/store/ProjectStore'; import type { TextureStore } from '../core/store/TextureStore'; import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui'; import { GameViewport } from '../viewport/GameViewport'; import { SceneViewport } from '../viewport/SceneViewport'; import { attachAutosave } from './autosave'; import { ContainerEditor } from './container/ContainerEditor'; import { GraphEditor } from './graph/GraphEditor'; import { Inspector } from './inspector/Inspector'; import { AssetPanel, type PlacementTarget } from './panels/AssetPanel'; import { HierarchyPanel } from './panels/HierarchyPanel'; import { Toolbar } from './panels/Toolbar'; import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession'; import { ProjectSession } from './ProjectSession'; import { installShortcuts } from './shortcuts'; import type { EditorState } from './state'; import { TextureEditor } from './texture/TextureEditor'; import { installTextureImport } from './textureImport'; export type CentreTab = 'scene' | 'graph' | 'game' | 'texture' | 'container'; const TAB_ORDER = ['scene', 'game', 'graph', 'texture', 'container'] as const; const MIN_LEFT_WIDTH = 160; const MAX_LEFT_WIDTH = 500; const MIN_RIGHT_WIDTH = 200; const MAX_RIGHT_WIDTH = 600; /** * 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 graphEditor: GraphEditor; readonly textureEditor: TextureEditor; readonly containerEditor: ContainerEditor; readonly hierarchy: HierarchyPanel; tab: CentreTab = 'scene'; /** Frame rate, refreshed twice a second; null until the first measurement. */ fps: number | null = null; private readonly play: PlaySession; private readonly projects: ProjectSession; private readonly leftColumn: VStack; private readonly inspector: Inspector; private readonly leftSplitter = new Splitter('x'); private readonly rightSplitter = new Splitter('x'); private readonly workspace: HStack; private readonly centre: VStack; private readonly tabButtons: Record; private readonly toolbar: Toolbar; private fpsFrames = 0; private fpsElapsed = 0; private leftWidth = 240; private rightWidth = 300; constructor( app: Application, root: VStack, private readonly state: EditorState, store: ProjectStore, textures: TextureStore, ) { this.viewport = new SceneViewport(state); this.gameView = new GameViewport(state); this.graphEditor = new GraphEditor(state); this.textureEditor = new TextureEditor(state); this.containerEditor = new ContainerEditor(state); this.hierarchy = new HierarchyPanel(state); this.inspector = new Inspector(state); this.play = new PlaySession(state, this); this.projects = new ProjectSession(state, store, () => { if (this.play.isPlaying) this.play.stop(); }); this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId); this.graphEditor.canvas.timeProvider = () => this.play.time; this.toolbar = new Toolbar(state, { openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor), openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor), togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()), }); root.add(this.toolbar); this.workspace = new HStack(); this.leftColumn = new VStack({ gap: 1 }); this.leftColumn.add(new AssetPanel(state, () => this.placementTarget(), textures), 1); installTextureImport(app.canvas, { state, textures }); this.leftColumn.add(this.hierarchy, 1); this.centre = new VStack(); const tabs = new HStack({ gap: 2, padding: 4, align: 'center' }); tabs.minHeight = Theme.controlHeight + 8; this.tabButtons = { scene: new Button('Scene', { variant: 'ghost', width: 90, onClick: () => this.showTab('scene') }), game: new Button('Game', { variant: 'ghost', width: 90, onClick: () => this.showTab('game') }), graph: new Button('Graph', { variant: 'ghost', width: 90, onClick: () => this.showTab('graph') }), texture: new Button('Texture', { variant: 'ghost', width: 90, onClick: () => this.showTab('texture'), }), container: new Button('Container', { variant: 'ghost', width: 90, onClick: () => this.showTab('container'), }), }; for (const tab of TAB_ORDER) tabs.add(this.tabButtons[tab]); this.centre.add(tabs); this.centre.add(this.viewport, 1); this.centre.add(this.graphEditor, 1); this.centre.add(this.gameView, 1); this.centre.add(this.textureEditor, 1); this.centre.add(this.containerEditor, 1); this.graphEditor.visible = false; this.gameView.visible = false; this.textureEditor.visible = false; this.containerEditor.visible = false; this.tabButtons.scene.setToggled(true); this.leftColumn.minWidth = this.leftWidth; this.inspector.minWidth = this.rightWidth; this.leftSplitter.onDrag = (delta) => { this.leftWidth = Math.max(MIN_LEFT_WIDTH, Math.min(MAX_LEFT_WIDTH, this.leftWidth + delta)); this.leftColumn.minWidth = this.leftWidth; this.workspace.relayout(); }; this.rightSplitter.onDrag = (delta) => { this.rightWidth = Math.max(MIN_RIGHT_WIDTH, Math.min(MAX_RIGHT_WIDTH, this.rightWidth - delta)); 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); attachAutosave(state, store); installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() }); app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000)); // A node, a frame and a template object exist only in their own tab, so selecting one // switches to it; selecting the behavior, texture or container asset itself does not. state.on('selection', () => { const s = state.selection; if (s?.kind === 'node' && this.tab !== 'graph') this.showTab('graph'); if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture'); if ( s?.kind === 'object' && this.tab !== 'container' && findAsset(state.project, s.holderId)?.type === 'container' ) this.showTab('container'); }); state.on('project', () => this.play.syncFromModel()); } showTab(tab: CentreTab): void { this.tab = tab; this.viewport.visible = tab === 'scene'; this.graphEditor.visible = tab === 'graph'; this.gameView.visible = tab === 'game'; this.textureEditor.visible = tab === 'texture'; this.containerEditor.visible = tab === 'container'; // 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 TAB_ORDER) this.tabButtons[key].setToggled(key === tab); this.centre.relayout(); ui().focus.validate(); } /** Where the Assets panel places: into the container being edited, otherwise into the scene. */ private placementTarget(): PlacementTarget | undefined { if (this.tab === 'container') { const holder = findObjectHolder(this.state.project, this.containerEditor.currentAssetId); return holder ? { holder, at: this.containerEditor.worldCenter() } : undefined; } const scene = this.state.activeScene; return scene ? { holder: scene, at: this.viewport.worldCenter() } : undefined; } boot(): Promise { return this.projects.boot(); } // --- PlayHost -------------------------------------------------------------------------------- showGame(): void { this.showTab('game'); } leaveGame(): void { if (this.tab === 'game') this.showTab('scene'); } // --- frame ----------------------------------------------------------------------------------- private tick(dt: number): void { this.measureFps(dt); this.play.tick(dt); } /** Refreshes the toolbar readout twice a second. */ private measureFps(dt: number): void { this.fpsFrames += 1; this.fpsElapsed += dt; if (this.fpsElapsed < 0.5) return; this.fps = Math.round(this.fpsFrames / this.fpsElapsed); this.fpsFrames = 0; 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(); } }