Files
feuerwerk/src/editor/App.ts
T
rootandClaude Fable 5.1 d4b1ffa051 Z-order via position.z and reset-to-default for asset properties
- 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>
2026-09-07 11:42:18 +02:00

258 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
});
}
}