The Container tab reuses SceneViewport against any object holder, so a container template is edited exactly like a scene. Right-click no longer changes the selection, which previously made nesting impossible: opening the menu on a container loaded it, and "Place in container" then targeted itself. Template edits (move, rename, override) now reach already-placed instances per slot, through a shared templateSync used by both the apply path and the plain object commands. Copies the user has touched keep their own value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
9.2 KiB
TypeScript
233 lines
9.2 KiB
TypeScript
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<CentreTab, Button>;
|
|
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<void> {
|
|
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();
|
|
}
|
|
}
|