- Position is now { x, y, z }; higher z draws on top, ties keep hierarchy order
- Viewport sorts the objects layer deterministically (syncDrawOrder)
- Inspector shows a Z field; asset properties that differ from the schema
default are marked bold with a reset button (ResetAssetPropertyCommand)
- isModified() helper, unit tests, E2E checks and seed updated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
258 lines
9.9 KiB
TypeScript
258 lines
9.9 KiB
TypeScript
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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;
|
||
});
|
||
}
|
||
}
|