Files
feuerwerk/src/editor/debugHook.ts
T
root ebad3fd530 Make an edge drag say where it will land, and offer nodes when it lands nowhere
Dragging an edge now rings the port it would connect to and snaps the line
there. A drop on a node's body takes that node's first free compatible port,
or the last compatible one when none is free, replacing its link. A drop into
empty space opens the palette filtered to the node types the edge could reach
at all, and the picked node arrives wired - node and link as one undo step,
so a single undo takes back the whole gesture.

The decision of what fits what lives in core/graph/dropTarget.ts as pure
functions, tested directly; the canvas only draws the answer. The palette
takes an optional filter rather than knowing about drags.

Verified remote on the Windows box (GPU): ALL PASSED, 284 checks.
2026-09-09 12:29:57 +02:00

195 lines
8.7 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, 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),
};
}