import { type Application, Container, Text } from 'pixi.js'; import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands'; import { RemoveSceneObjectCommand } from '../core/commands/sceneObjectCommands'; import { listScenes } from '../core/model/Project'; import { HttpProjectStore } from '../core/store/HttpProjectStore'; import type { ProjectStore } from '../core/store/ProjectStore'; import { World } from '../runtime/World'; import { Dialog, HStack, Menu, type MenuItem, Splitter, ui, VStack, type Widget } from '../ui'; import { Viewport } from '../viewport/Viewport'; import { attachAutosave } from './autosave'; import { AssetPanel } from './panels/AssetPanel'; import { HierarchyPanel } from './panels/HierarchyPanel'; import { Inspector } from './panels/Inspector'; import { Toolbar } from './panels/Toolbar'; import { EditorState } from './state'; const API_ENDPOINT = '/game-maker/api/index.php'; /** Composes panels, viewport and runtime into the one application that edits and plays. */ export async function startEditor(app: Application, root: VStack): Promise { const state = new EditorState(); const store: ProjectStore = new HttpProjectStore(API_ENDPOINT); const editor = new Editor(app, root, state, store); installDebugHook(app, state, editor.viewport); await editor.boot(); } /** * `window.__gm` – used by tools/e2e.mjs to find widgets by their visible text and to * inspect state. Harmless in production (read-only access). */ function installDebugHook(app: Application, state: EditorState, viewport: Viewport): void { const viewportCenter = () => { const p = viewport.getGlobalPosition(); return { x: p.x + viewport.w / 2, y: p.y + viewport.h / 2 }; }; const locate = (text: string): { x: number; y: number } | null => { let found: Text | null = null; const walk = (node: Container): void => { if (found || !node.visible) return; if (node instanceof Text && node.text === text) { found = node; return; } for (const child of node.children) walk(child); }; walk(app.stage); if (!found) return null; const node = found as Text; const p = node.getGlobalPosition(); return { x: p.x + node.width / 2, y: p.y + node.height / 2 }; }; (window as unknown as { __gm: unknown }).__gm = { state, locate, viewportCenter, viewportInfo: () => viewport.debugInfo(), layerOf: (id: string) => viewport.layerOf(id), drawOrder: () => viewport.drawOrder() }; } class Editor { readonly viewport: Viewport; 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 world: World | null = null; private leftWidth = 240; private rightWidth = 300; constructor(app: Application, root: VStack, private readonly state: EditorState, private readonly store: ProjectStore) { const toolbar = new Toolbar(state, { openProjectMenu: (anchor) => this.openProjectMenu(anchor), openSceneMenu: (anchor) => this.openSceneMenu(anchor), togglePlay: () => (state.mode === 'edit' ? this.play() : this.stop()), }); root.add(toolbar); this.workspace = new HStack(); this.viewport = new Viewport(state); this.leftColumn = new VStack({ gap: 1 }); this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1); this.leftColumn.add(new HierarchyPanel(state), 1); this.inspector = new Inspector(state); 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.leftColumn.minWidth = this.leftWidth; this.workspace.relayout(); }; this.rightSplitter.onDrag = (delta) => { this.rightWidth = Math.max(200, Math.min(600, 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.viewport, 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); this.installShortcuts(); app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000)); state.on('mode', () => this.applyMode()); } // --- project lifecycle --------------------------------------------------------------- async boot(): Promise { const wanted = new URLSearchParams(location.search).get('project'); try { if (wanted) { await this.open(wanted); return; } const list = await this.store.list(); if (list[0]) await this.open(list[0].id); else await this.createProject('My first project'); } catch (error) { this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`); } } private async open(id: string): Promise { 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); } private async createProject(name: string): Promise { const created = await this.store.create(name); await this.open(created.project.id); } private openProjectMenu(anchor: Widget): void { const g = anchor.getGlobalPosition(); 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).open(g.x, g.y + anchor.h + 2); }; void open().catch((error) => this.state.setNotice(String(error))); } private async deleteCurrentProject(): Promise { await this.store.delete(this.state.project.id); const list = await this.store.list(); if (list[0]) await this.open(list[0].id); else await this.createProject('My first project'); } private openSceneMenu(anchor: Widget): void { const scenes = listScenes(this.state.project); const g = anchor.getGlobalPosition(); const items: MenuItem[] = scenes.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).open(g.x, g.y + anchor.h + 2); } // --- play mode ------------------------------------------------------------------------- private play(): void { const scene = this.state.activeScene; if (!scene) return; ui().focus.blur(); // No systems yet: the world is static. Behaviour arrives with scripting in a later phase. this.world = World.instantiate(this.state.project, scene.id); this.viewport.setWorld(this.world); this.state.setMode('play'); this.state.setNotice(this.world.firstOfType('camera') ? 'Playing · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops'); } private stop(): void { this.world?.destroy(); this.world = null; this.viewport.setWorld(null); this.state.setMode('edit'); this.state.setNotice(''); } private tick(dt: number): void { if (!this.world) return; this.world.tick(Math.min(dt, 0.1)); this.viewport.redraw(); } private applyMode(): void { const editing = this.state.mode === 'edit'; this.leftColumn.visible = editing; this.inspector.visible = editing; this.leftSplitter.visible = editing; this.rightSplitter.visible = editing; this.workspace.relayout(); ui().focus.validate(); } // --- keyboard -------------------------------------------------------------------------- private installShortcuts(): void { ui().focus.globalHandlers.push((event) => { const mod = event.ctrlKey || event.metaKey; if (event.key === 'Escape' && this.state.mode === 'play') { this.stop(); return true; } if (this.state.mode !== 'edit') return false; if (mod && event.key.toLowerCase() === 'z') { if (event.shiftKey) this.state.redo(); else this.state.undo(); return true; } if (mod && event.key.toLowerCase() === 'y') { this.state.redo(); return true; } if (event.key === 'Delete' || event.key === 'Backspace') { const s = this.state.selection; if (s?.kind === 'object') this.state.execute(new RemoveSceneObjectCommand(s.sceneId, s.objectId)); else if (s?.kind === 'asset') { const asset = this.state.project.assets.find((a) => a.id === s.assetId); if (asset) new Dialog(`Delete "${asset.name}"?`, 'Placed instances are removed as well.', [ { label: 'Cancel' }, { label: 'Delete', danger: true, onClick: () => this.state.execute(new DeleteAssetCommand(s.assetId)) }, ]).openCentered(); } return s !== null; } if (mod && event.key === 'Enter') { this.play(); return true; } return false; }); } }