import { type Application, Color, type ColorSource, type Container, Rectangle, Text } from 'pixi.js'; import { resolveObjects } from '../core/model/instance'; import { findObjectHolder } from '../core/model/Project'; import { Button, ui, Widget } from '../ui'; import type { CentreTab, Editor } from './Editor'; import type { EditorState } from './state'; /** * Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their * visible text and to read editor state. */ export function installDebugHook(app: Application, state: EditorState, editor: Editor): void { const { viewport, gameView, graphEditor, textureEditor, containerEditor, hierarchy } = editor; const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => { const p = widget.getGlobalPosition(); return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; }; /** Centre of the first (or, with `last`, the topmost/last-drawn) visible text equal to `text`. */ const locate = (text: string, last = false): { x: number; y: number } | null => { let found: Text | null = null; const walk = (node: Container): void => { if ((found && !last) || !node.visible) return; if (node instanceof Text && node.text === text) { found = node; return; } for (const child of node.children) walk(child); }; walk(app.stage); // TypeScript does not track the assignment inside `walk`, hence the disable and the cast. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 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 }; }; /** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */ const textColor = (text: string): string | null => { let found: string | null = null; const walk = (node: Container): void => { if (found !== null || !node.visible) return; if (node instanceof Text && node.text === text) { found = new Color(node.style.fill as ColorSource).toHex(); return; } for (const child of node.children) walk(child); }; walk(app.stage); return found; }; /** * The visible text of the overlay layer, in drawing order – everything an open popup shows. * Menus hide filtered-out entries, so this is also what a search box currently leaves. */ const overlayTexts = (): string[] => { const out: string[] = []; const walk = (node: Container): void => { if (!node.visible) return; if (node instanceof Text) { if (node.text) out.push(node.text); return; } for (const child of node.children) walk(child); }; walk(ui().overlay); return out; }; /** * Visible texts drawn wider than the widget that owns them. The owner's box is its declared * `w`/`h`, not `getBounds()`: a Pixi container's bounds are the union of its children – * including the overflowing text – so every overflow would measure as zero. */ const textOverflows = (all = false): { text: string; owner: string; over: number }[] => { const out: { text: string; owner: string; over: number }[] = []; const walk = (node: Container, owner: Widget | null, clipped: boolean): void => { if (!node.visible) return; const isWidget = node instanceof Widget; const box = isWidget ? node : owner; // A mask further up does not excuse a label spilling out of its own control, so the // question restarts at every widget. `Boolean`, not `!== null`: Pixi's mask getter // returns `undefined` when nothing masks. const masked = (isWidget ? false : clipped) || Boolean(node.mask); if (node instanceof Text) { if (!box || !node.text || (masked && !all)) return; const text = node.getBounds(); const origin = box.getGlobalPosition(); const over = Math.max(text.x + text.width - (origin.x + box.w), origin.x - text.x); if (over > 1 || all) { out.push({ text: node.text, owner: box.constructor.name, over: Math.round(over) }); } return; } for (const child of node.children) walk(child, box, masked); }; walk(app.stage, null, false); return out; }; /** * The counterpart of `textOverflows`: buttons whose label was shortened away to nothing, which * leaves a control that says nothing at all. An empty `Text` is invisible to the overflow walk. */ const labelsDropped = (): { text: string; owner: string; w: number }[] => { const out: { text: string; owner: string; w: number }[] = []; const walk = (node: Container): void => { if (!node.visible) return; if (node instanceof Button && node.text && !node.shownText) { out.push({ text: node.text, owner: node.constructor.name, w: node.w }); } for (const child of node.children) walk(child); }; walk(app.stage); return out; }; /** Screen rectangle of the open popup (the backdrop is not a widget), or null when none is. */ const overlayRect = (): { x: number; y: number; w: number; h: number } | null => { const popups = ui().overlay.children.filter((node): node is Widget => node instanceof Widget); const popup = popups[popups.length - 1]; if (!popup) return null; const p = popup.getGlobalPosition(); return { x: p.x, y: p.y, w: popup.w, h: popup.h }; }; /** * A holder's objects the way the editor sees them: the content of placed containers is resolved * from the template, so it is nowhere in `holder.objects` (see `model/instance.ts`). */ const objectsOf = (holderId: string) => { const holder = findObjectHolder(state.project, holderId); return holder ? resolveObjects(state.project, holder) : []; }; (window as unknown as { __gm: unknown }).__gm = { state, objectsOf, sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []), locate, textColor, overlayTexts, overlayRect, textOverflows, labelsDropped, viewportCenter: () => centreOf(viewport), viewportInfo: () => viewport.debugInfo(), gameInfo: () => gameView.debugInfo(), layerOf: (id: string) => viewport.layerOf(id), drawOrder: () => viewport.drawOrder(), objectScreen: (id: string) => viewport.objectScreen(id), objectRect: (id: string) => viewport.objectRect(id), /** The same for the Game tab – through the camera, so what the player sees. */ gameScreen: (id: string) => gameView.objectScreen(id), graph: { current: () => graphEditor.currentAssetId, portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.canvas.portGlobal(nodeId, port, isOutput), nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId), dropTarget: () => graphEditor.canvas.pendingDropTarget(), // The canvas, not the whole tab: a centre measured through the header sits above the // drawing area. center: () => centreOf(graphEditor.canvas), pickerWidth: () => graphEditor.pickerWidth, }, texture: { current: () => textureEditor.currentAssetId, pickerWidth: () => textureEditor.pickerWidth, /** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */ pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y), }, container: { current: () => containerEditor.currentAssetId, pickerWidth: () => containerEditor.pickerWidth, center: () => centreOf(containerEditor), rect: () => containerEditor.canvasRect(), /** Where a template object is drawn, so a check can click or sample it. */ objectScreen: (id: string) => containerEditor.objectScreen(id), }, world: () => editor.worldSnapshot(), /** Rows the hierarchy shows – collapsed subtrees are not in here. */ hierarchyOrder: () => hierarchy.rowOrder(), hierarchyChevron: (id: string) => hierarchy.chevronPosition(id), hierarchyRowColor: (id: string) => hierarchy.rowColor(id), hierarchyExpandAll: () => hierarchy.expandAll(), hierarchyCollapseAll: () => hierarchy.collapseAll(), /** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */ pixelAt: (x: number, y: number) => { const { pixels } = app.renderer.extract.pixels({ target: app.stage, frame: new Rectangle(x, y, 1, 1), }); const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0'); return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`; }, fps: () => editor.fps, tab: () => editor.tab, showTab: (tab: CentreTab) => editor.showTab(tab), }; }