Fix findUsages/undo/node-lookup bugs, split Viewport and Editor, share command and inspector helpers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1eb28314cf
commit
f140c2fd77
58 files changed
+1969
-1171
No files matched your search
+48
-172
@@ -1,45 +1,43 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import { SetActiveSceneCommand } from '../core/commands/assetCommands';
|
||||
import { worldPositionOf } from '../core/graph';
|
||||
import { listScenes } from '../core/model/Project';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import { BehaviorSystem } from '../runtime/systems/BehaviorSystem';
|
||||
import { CollisionSystem } from '../runtime/systems/CollisionSystem';
|
||||
import { Input } from '../runtime/systems/Input';
|
||||
import { World } from '../runtime/World';
|
||||
import { Button, Dialog, HStack, Menu, type MenuItem, Splitter, Theme, ui, VStack, type Widget } from '../ui';
|
||||
import { Viewport } from '../viewport/Viewport';
|
||||
import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui';
|
||||
import { GameViewport } from '../viewport/GameViewport';
|
||||
import { SceneViewport } from '../viewport/SceneViewport';
|
||||
import { attachAutosave } from './autosave';
|
||||
import { GraphEditor } from './graph/GraphEditor';
|
||||
import { Inspector } from './inspector/Inspector';
|
||||
import { AssetPanel } 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';
|
||||
|
||||
export type CentreTab = 'scene' | 'graph' | 'game';
|
||||
|
||||
/** Facts about the running world for the check scripts. */
|
||||
export interface WorldSnapshot {
|
||||
entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[];
|
||||
log: string[];
|
||||
}
|
||||
const MIN_LEFT_WIDTH = 160;
|
||||
const MAX_LEFT_WIDTH = 500;
|
||||
const MIN_RIGHT_WIDTH = 200;
|
||||
const MAX_RIGHT_WIDTH = 600;
|
||||
|
||||
/**
|
||||
* Composes toolbar, panels, viewports, graph editor and runtime into the one application that
|
||||
* edits and plays. Owns the play session: the world, its systems and the tick.
|
||||
* 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 {
|
||||
export class Editor implements PlayHost {
|
||||
/** Scene tab: the editable model view (live positions while playing). */
|
||||
readonly viewport: Viewport;
|
||||
readonly viewport: SceneViewport;
|
||||
/** Game tab: the running world through its camera. */
|
||||
readonly gameView: Viewport;
|
||||
readonly gameView: GameViewport;
|
||||
readonly graphEditor: GraphEditor;
|
||||
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');
|
||||
@@ -48,33 +46,37 @@ export class Editor {
|
||||
private readonly centre: VStack;
|
||||
private readonly tabButtons: Record<CentreTab, Button>;
|
||||
private readonly toolbar: Toolbar;
|
||||
private world: World | null = null;
|
||||
private behaviors: BehaviorSystem | null = null;
|
||||
private readonly input = new Input();
|
||||
private fpsFrames = 0;
|
||||
private fpsElapsed = 0;
|
||||
private leftWidth = 240;
|
||||
private rightWidth = 300;
|
||||
|
||||
constructor(app: Application, root: VStack, private readonly state: EditorState, private readonly store: ProjectStore) {
|
||||
constructor(app: Application, root: VStack, state: EditorState, store: ProjectStore) {
|
||||
this.viewport = new SceneViewport(state);
|
||||
this.gameView = new GameViewport(state);
|
||||
this.graphEditor = new GraphEditor(state);
|
||||
this.hierarchy = new HierarchyPanel(state);
|
||||
this.inspector = new Inspector(state);
|
||||
this.play = new PlaySession(state, this);
|
||||
// A run holds a copy of the project, so it has to end before a different one is loaded.
|
||||
this.projects = new ProjectSession(state, store, () => {
|
||||
if (this.play.isPlaying) this.play.stop();
|
||||
});
|
||||
|
||||
this.graphEditor.traceProvider = (assetId) => this.play.traceFor(assetId);
|
||||
this.graphEditor.timeProvider = () => this.play.time;
|
||||
|
||||
this.toolbar = new Toolbar(state, {
|
||||
openProjectMenu: (anchor) => this.openProjectMenu(anchor),
|
||||
openSceneMenu: (anchor) => this.openSceneMenu(anchor),
|
||||
togglePlay: () => (state.mode === 'edit' ? this.play() : this.stop()),
|
||||
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.viewport = new Viewport(state);
|
||||
this.gameView = new Viewport(state, { game: true });
|
||||
this.graphEditor = new GraphEditor(state);
|
||||
this.graphEditor.traceProvider = (assetId) => this.behaviors?.traceFor(assetId) ?? null;
|
||||
this.graphEditor.timeProvider = () => this.world?.time ?? 0;
|
||||
this.leftColumn = new VStack({ gap: 1 });
|
||||
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1);
|
||||
this.hierarchy = new HierarchyPanel(state);
|
||||
this.leftColumn.add(this.hierarchy, 1);
|
||||
this.inspector = new Inspector(state);
|
||||
|
||||
// Centre: a tab strip (Scene | Game | Graph) above the scene view, the game view or the graph editor.
|
||||
this.centre = new VStack();
|
||||
@@ -97,12 +99,12 @@ export class Editor {
|
||||
this.leftColumn.minWidth = this.leftWidth;
|
||||
this.inspector.minWidth = this.rightWidth;
|
||||
this.leftSplitter.onDrag = (delta) => {
|
||||
this.leftWidth = Math.max(160, Math.min(500, this.leftWidth + 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(200, Math.min(600, this.rightWidth - delta));
|
||||
this.rightWidth = Math.max(MIN_RIGHT_WIDTH, Math.min(MAX_RIGHT_WIDTH, this.rightWidth - delta));
|
||||
this.inspector.minWidth = this.rightWidth;
|
||||
this.workspace.relayout();
|
||||
};
|
||||
@@ -115,14 +117,14 @@ export class Editor {
|
||||
root.resize(app.screen.width, app.screen.height);
|
||||
|
||||
attachAutosave(state, store);
|
||||
installShortcuts(state, { play: () => this.play(), stop: () => this.stop() });
|
||||
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
|
||||
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
|
||||
// Selecting a graph node switches to the graph; selecting a behavior does not force it.
|
||||
state.on('selection', () => {
|
||||
if (state.selection?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
|
||||
});
|
||||
// While playing, every edit (command, undo, scrub preview) is mirrored into the world at once.
|
||||
state.on('project', () => this.world?.syncFromModel());
|
||||
state.on('project', () => this.play.syncFromModel());
|
||||
}
|
||||
|
||||
showTab(tab: CentreTab): void {
|
||||
@@ -135,144 +137,26 @@ export class Editor {
|
||||
ui().focus.validate();
|
||||
}
|
||||
|
||||
// --- project lifecycle -----------------------------------------------------------------------
|
||||
|
||||
/** Opens the project named in the URL, else the first one on the server, else a new one. */
|
||||
async boot(): Promise<void> {
|
||||
const wanted = new URLSearchParams(location.search).get('project');
|
||||
try {
|
||||
if (wanted) {
|
||||
await this.open(wanted);
|
||||
return;
|
||||
}
|
||||
await this.openFirstOrCreate();
|
||||
} catch (error) {
|
||||
this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
boot(): Promise<void> {
|
||||
return this.projects.boot();
|
||||
}
|
||||
|
||||
private async open(id: string): Promise<void> {
|
||||
if (this.world) this.stop();
|
||||
const loaded = await this.store.load(id);
|
||||
this.state.loadProject(loaded.project, loaded.revision);
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('project', id);
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
// --- PlayHost --------------------------------------------------------------------------------
|
||||
|
||||
private async openFirstOrCreate(): Promise<void> {
|
||||
const list = await this.store.list();
|
||||
if (list[0]) await this.open(list[0].id);
|
||||
else await this.createProject('My first project');
|
||||
}
|
||||
|
||||
private async createProject(name: string): Promise<void> {
|
||||
const created = await this.store.create(name);
|
||||
await this.open(created.project.id);
|
||||
}
|
||||
|
||||
private async deleteCurrentProject(): Promise<void> {
|
||||
if (this.world) this.stop();
|
||||
await this.store.delete(this.state.project.id);
|
||||
await this.openFirstOrCreate();
|
||||
}
|
||||
|
||||
private openProjectMenu(anchor: Widget): void {
|
||||
const open = async () => {
|
||||
const list = await this.store.list();
|
||||
const items: MenuItem[] = [
|
||||
{ label: 'New project', onSelect: () => void this.createProject(`Project ${list.length + 1}`) },
|
||||
...list.map((p) => ({
|
||||
label: p.id === this.state.project.id ? `● ${p.name}` : p.name,
|
||||
onSelect: () => void this.open(p.id),
|
||||
disabled: p.id === this.state.project.id,
|
||||
})),
|
||||
{
|
||||
label: 'Delete this project…',
|
||||
danger: true,
|
||||
onSelect: () =>
|
||||
new Dialog(`Delete project "${this.state.project.name}"?`, 'All assets and scenes are removed permanently.', [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => void this.deleteCurrentProject() },
|
||||
]).openCentered(),
|
||||
},
|
||||
];
|
||||
new Menu(items).openBelow(anchor);
|
||||
};
|
||||
void open().catch((error) => this.state.setNotice(String(error)));
|
||||
}
|
||||
|
||||
private openSceneMenu(anchor: Widget): void {
|
||||
const items: MenuItem[] = listScenes(this.state.project).map((scene) => ({
|
||||
label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name,
|
||||
onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
|
||||
new Menu(items).openBelow(anchor);
|
||||
}
|
||||
|
||||
// --- play mode -------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Play: the state switches to a runtime copy of the project, the world is built from that
|
||||
* copy and shown in the Game tab. All panels stay open and edit the copy – the world picks
|
||||
* those edits up immediately, and nothing reaches the saved model.
|
||||
*/
|
||||
private play(): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
ui().focus.blur();
|
||||
const runtime = this.state.enterPlay();
|
||||
const world = World.instantiate(runtime, scene.id);
|
||||
this.world = world;
|
||||
this.input.attach();
|
||||
// Collisions are detected first so behaviors see this frame's enter/stay/exit events.
|
||||
const collisions = new CollisionSystem();
|
||||
this.behaviors = new BehaviorSystem(runtime, this.input, collisions);
|
||||
world.addSystem(collisions);
|
||||
world.addSystem(this.behaviors);
|
||||
this.gameView.setWorld(world);
|
||||
showGame(): void {
|
||||
this.showTab('game');
|
||||
this.state.setNotice(world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
|
||||
if (world.stopRequested) this.stop();
|
||||
}
|
||||
|
||||
/** Stop: the world and the runtime copy are discarded; the saved model returns untouched. */
|
||||
private stop(): void {
|
||||
const world = this.world;
|
||||
this.world = null;
|
||||
this.behaviors = null;
|
||||
this.input.detach();
|
||||
ui().focus.blur();
|
||||
world?.destroy();
|
||||
this.gameView.setWorld(null);
|
||||
this.state.exitPlay();
|
||||
leaveGame(): void {
|
||||
if (this.tab === 'game') this.showTab('scene');
|
||||
const lastError = world?.log.find((line) => line.startsWith('Error in behavior'));
|
||||
this.state.setNotice(lastError ?? '');
|
||||
}
|
||||
|
||||
// --- frame -----------------------------------------------------------------------------------
|
||||
|
||||
private tick(dt: number): void {
|
||||
this.measureFps(dt);
|
||||
const world = this.world;
|
||||
if (!world) return;
|
||||
world.tick(Math.min(dt, 0.1));
|
||||
this.input.endFrame();
|
||||
if (world.stopRequested) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
// Restart game: throw the run away and start the scene again from the saved model.
|
||||
if (world.restartRequested) {
|
||||
this.stop();
|
||||
this.play();
|
||||
return;
|
||||
}
|
||||
// Mirror what the systems did into the runtime copy so the panels show live values.
|
||||
// A destroyed entity removes its object – that is a structural change the lists must see.
|
||||
if (world.syncToModel()) this.state.preview();
|
||||
this.state.runtimeTick();
|
||||
if (this.graphEditor.visible) this.graphEditor.redraw();
|
||||
this.play.tick(dt);
|
||||
}
|
||||
|
||||
/** Counts rendered frames (edit and play alike) and refreshes the toolbar readout twice a second. */
|
||||
@@ -288,14 +172,6 @@ export class Editor {
|
||||
|
||||
/** Read-only view of the running world for the check scripts. */
|
||||
worldSnapshot(): WorldSnapshot | null {
|
||||
const world = this.world;
|
||||
if (!world) return null;
|
||||
return {
|
||||
entities: world.entities.map((e) => {
|
||||
const w = worldPositionOf(e);
|
||||
return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
|
||||
}),
|
||||
log: [...world.log],
|
||||
};
|
||||
return this.play.snapshot();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user