game-maker / suite (push) Successful in 6m48s
The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.
The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.
Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.
564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.
Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
605 lines
31 KiB
TypeScript
605 lines
31 KiB
TypeScript
import { type Application, Color, type ColorSource, type Container, Mesh, Rectangle, Text } from 'pixi.js';
|
||
import { NameTransitionCommand } from '../core/commands/graphCommands';
|
||
import { resolveObjects } from '../core/model/instance';
|
||
import { findObjectHolder } from '../core/model/Project';
|
||
import type { FrameRect } from '../core/model/texture';
|
||
import { effectiveScale, gameClock } from '../core/time';
|
||
import {
|
||
activeDrag,
|
||
afterFrames,
|
||
Button,
|
||
currentMenu,
|
||
isRendering,
|
||
renderCount,
|
||
requestRender,
|
||
ui,
|
||
Widget,
|
||
} from '../ui';
|
||
import { particleLibrary } from '../viewport/ParticleLibrary';
|
||
import { shaderLibrary } from '../viewport/ShaderLibrary';
|
||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||
import { textureLibrary } from '../viewport/TextureLibrary';
|
||
import type { CentreTab, Editor } from './Editor';
|
||
import { currentFontPicker } from './font/FontPicker';
|
||
import { currentNodePalette } from './graph/NodePalette';
|
||
import { currentSoundPicker } from './sound/SoundPicker';
|
||
import { currentImagePicker } from './texture/ImagePicker';
|
||
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, assets, inspector } =
|
||
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 };
|
||
};
|
||
/** The overlap of two global rectangles; an empty one when they do not meet. */
|
||
const intersect = (a: Rectangle, b: Rectangle): Rectangle => {
|
||
const x = Math.max(a.x, b.x);
|
||
const y = Math.max(a.y, b.y);
|
||
return new Rectangle(
|
||
x,
|
||
y,
|
||
Math.max(0, Math.min(a.right, b.right) - x),
|
||
Math.max(0, Math.min(a.bottom, b.bottom) - y),
|
||
);
|
||
};
|
||
const rectOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
|
||
const p = widget.getGlobalPosition();
|
||
return { x: p.x, y: p.y, w: widget.w, h: widget.h };
|
||
};
|
||
/**
|
||
* Every text under `root` that is really on screen, in drawing order (the topmost last), handed
|
||
* to `visit` with the centre a click on it would use.
|
||
*
|
||
* A row scrolled out of its list is still `visible` – only masked away – and reporting it would
|
||
* hand a check a point outside the panel, where the click lands on whatever is underneath. That
|
||
* is how a step that passes on a short list fails on a long one (#39 in CI), so a text whose
|
||
* centre falls outside the clipping it sits in counts as not being there at all.
|
||
*
|
||
* Every hook that reads text goes through here. A `locate` that refuses a scrolled-away row
|
||
* while `overlayTexts` still lists it is worse than either rule alone: a check picks a row off
|
||
* the one hook and then cannot find it with the other.
|
||
*
|
||
* The clip is the box of the *widget* that owns the mask, not the mask itself: Pixi hands a
|
||
* graphics that serves as a mask to the mask effect, and `getBounds()` on it then reports an
|
||
* empty rectangle at the origin – which would throw away every masked text in the editor.
|
||
*/
|
||
const eachText = (root: Container, visit: (node: Text, point: { x: number; y: number }) => void): void => {
|
||
const walk = (node: Container, clip: Rectangle | null, owner: Widget | null): void => {
|
||
if (!node.visible) return;
|
||
const holder = node instanceof Widget ? node : owner;
|
||
let inner = clip;
|
||
if (node.mask && holder) {
|
||
const r = rectOf(holder);
|
||
const box = new Rectangle(r.x, r.y, r.w, r.h);
|
||
inner = clip ? intersect(clip, box) : box;
|
||
}
|
||
if (node instanceof Text) {
|
||
// Global bounds, not `width`/`height`: those are unscaled, and inside the graph canvas
|
||
// (which is scaled by the zoom) that put the point right of the text it was meant to hit.
|
||
const box = node.getBounds();
|
||
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||
if (!inner || inner.contains(point.x, point.y)) visit(node, point);
|
||
return;
|
||
}
|
||
for (const child of node.children) walk(child, inner, holder);
|
||
};
|
||
walk(root, null, null);
|
||
};
|
||
/** Centres of every visible text equal to `text`, in drawing order (the topmost last). */
|
||
const locateAll = (text: string): { x: number; y: number }[] => {
|
||
const hits: { x: number; y: number }[] = [];
|
||
eachText(app.stage, (node, point) => {
|
||
if (node.text === text) hits.push(point);
|
||
});
|
||
return hits;
|
||
};
|
||
const locate = (text: string, last = false): { x: number; y: number } | null => {
|
||
const hits = locateAll(text);
|
||
return (last ? hits.at(-1) : hits[0]) ?? null;
|
||
};
|
||
/** 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;
|
||
};
|
||
/**
|
||
* Centre of the control of an inspector row, addressed by its label. A row aligns its label to a
|
||
* *minimum* width, so a label wider than the column pushes its control right – which is why a
|
||
* fixed offset from the label's own text is the wrong way to click one.
|
||
*/
|
||
const fieldOf = (label: string, last = false): { x: number; y: number; left: number } | null => {
|
||
let found: Text | null = null;
|
||
const walk = (node: Container): void => {
|
||
if ((found !== null && !last) || !node.visible) return;
|
||
if (node instanceof Text && node.text === label) {
|
||
found = node;
|
||
return;
|
||
}
|
||
for (const child of node.children) walk(child);
|
||
};
|
||
walk(app.stage);
|
||
// TypeScript does not track the assignment inside `walk`, hence the cast.
|
||
let owner: Container | null = (found as Text | null)?.parent ?? null;
|
||
while (owner && !(owner instanceof Widget)) owner = owner.parent;
|
||
const row = owner?.parent;
|
||
if (!row || !owner) return null;
|
||
const control = row.children[row.children.indexOf(owner) + 1];
|
||
if (!(control instanceof Widget)) return null;
|
||
return { ...centreOf(control), left: control.getGlobalPosition().x };
|
||
};
|
||
/**
|
||
* 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, and a
|
||
* list taller than its popup only reports the rows currently scrolled into it.
|
||
*/
|
||
const overlayTexts = (): string[] => {
|
||
const out: string[] = [];
|
||
eachText(ui().overlay, (node) => {
|
||
if (node.text) out.push(node.text);
|
||
});
|
||
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;
|
||
};
|
||
/** What the last visible button labelled `text` draws – shorter when it was truncated. */
|
||
const shownLabel = (text: string): string | null => {
|
||
let shown: string | null = null;
|
||
const walk = (node: Container): void => {
|
||
if (!node.visible) return;
|
||
if (node instanceof Button && node.text === text) shown = node.shownText;
|
||
for (const child of node.children) walk(child);
|
||
};
|
||
walk(app.stage);
|
||
return shown;
|
||
};
|
||
/** 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,
|
||
/** The Pixi application, for probes that need the renderer or the stage. */
|
||
app,
|
||
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
|
||
locate,
|
||
locateAll,
|
||
inspectorField: fieldOf,
|
||
inspectorInfo: () => inspector.debugInfo(),
|
||
textColor,
|
||
overlayTexts,
|
||
overlayRect,
|
||
/** Entries of the open menu in drawn order, `null` for a separator line (#43). */
|
||
menuRows: () => currentMenu()?.rowsDrawn() ?? null,
|
||
textOverflows,
|
||
labelsDropped,
|
||
shownLabel,
|
||
tabRow: () => editor.tabRow(),
|
||
/** The dividers left and right of the centre column – dragging them narrows the tab row. */
|
||
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
|
||
viewportCenter: () => centreOf(viewport),
|
||
/** Pans the Scene tab to a world point – a step's own lane, away from what earlier steps left. */
|
||
lookAt: (x: number, y: number) => viewport.lookAt(x, y),
|
||
/** Its drawn box – a step that wants an empty spot must stay inside what the console left over. */
|
||
viewportRect: () => {
|
||
const p = viewport.getGlobalPosition();
|
||
return { x: p.x, y: p.y, w: viewport.w, h: viewport.h };
|
||
},
|
||
/**
|
||
* A point in the viewport that no object covers – steps click there to deselect. A fixed
|
||
* offset from the centre lands on an object as soon as the viewport changes size.
|
||
*/
|
||
emptySpot: () => {
|
||
const p = viewport.getGlobalPosition();
|
||
const scene = state.activeScene;
|
||
const taken = scene
|
||
? objectsOf(scene.id)
|
||
.map((object) => viewport.objectRect(object.id))
|
||
.filter((rect): rect is NonNullable<typeof rect> => rect !== null)
|
||
: [];
|
||
const clear = (x: number, y: number) =>
|
||
!taken.some((r) => x >= r.x - 12 && x <= r.x + r.w + 12 && y >= r.y - 12 && y <= r.y + r.h + 12);
|
||
for (let y = p.y + viewport.h - 24; y > p.y + 24; y -= 24) {
|
||
for (let x = p.x + 24; x < p.x + viewport.w - 24; x += 24) if (clear(x, y)) return { x, y };
|
||
}
|
||
return null;
|
||
},
|
||
viewportInfo: () => viewport.debugInfo(),
|
||
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
|
||
dropPreview: () => viewport.dropInfo(),
|
||
/** The panel-to-view drag in flight, if any – a drop that never started looks like a refused one. */
|
||
dragPayload: () => activeDrag(),
|
||
/** An asset row by id – two assets can share a name with the container picker's label. */
|
||
assetRow: (id: string) => assets.rowGlobal(id),
|
||
assetRowColor: (id: string) => assets.rowColor(id),
|
||
/** The panel's own rectangle – what tells a row in view from one scrolled past its edge. */
|
||
assetsRect: () => rectOf(assets),
|
||
gameInfo: () => gameView.debugInfo(),
|
||
/** Selection outlines in the Scene tab: a switched-off object is invisible but still framed. */
|
||
gizmos: () => viewport.gizmoInfo(),
|
||
layerOf: (id: string) => viewport.layerOf(id),
|
||
/** The icon a particle source wears in the Scene tab, with its name and its owner link. */
|
||
sceneIcon: (id: string) => viewport.iconInfo(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),
|
||
gameRect: (id: string) => gameView.objectRect(id),
|
||
gameCenter: () => centreOf(gameView),
|
||
graph: {
|
||
current: () => graphEditor.currentAssetId,
|
||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
|
||
chevronAt: (nodeId: string) => graphEditor.canvas.chevronGlobal(nodeId),
|
||
nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId),
|
||
nodeProblem: (nodeId: string) => graphEditor.canvas.nodeProblem(nodeId),
|
||
nodeSettings: (nodeId: string) => graphEditor.canvas.nodeSettings(nodeId),
|
||
/** Each input row as drawn: its name, and the inline value beside it while unconnected. */
|
||
nodeInputs: (nodeId: string) => graphEditor.canvas.nodeInputs(nodeId),
|
||
/** Each output row as drawn: while playing, the value it last carried in front of its name. */
|
||
nodeOutputs: (nodeId: string) => graphEditor.canvas.nodeOutputs(nodeId),
|
||
/** Beads in flight on exec edges as drawn, in graph coordinates; `progress` 0 = source, 1 = target. */
|
||
beads: () => graphEditor.canvas.beadsDrawn(),
|
||
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
|
||
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
|
||
canvasRect: () => graphEditor.canvas.canvasRect(),
|
||
/** Bare canvas below every node – where a step right-clicks to open the palette. */
|
||
emptySpot: () => graphEditor.canvas.emptySpotGlobal(),
|
||
/**
|
||
* What the open palette lists, in order. No colour check can see a ranking: the rows all
|
||
* look alike, only their order carries the answer (#43).
|
||
*/
|
||
paletteRows: () => currentNodePalette()?.rowsDrawn() ?? null,
|
||
selected: () => state.selectedNodeIds,
|
||
dropTarget: () => graphEditor.canvas.pendingDropTarget(),
|
||
danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(),
|
||
edgeDrawn: (edgeId: string) => graphEditor.canvas.edgeDrawn(edgeId),
|
||
foldMark: (edgeId: string, isOutput: boolean) => graphEditor.canvas.foldMarkGlobal(edgeId, isOutput),
|
||
foldMarks: () => graphEditor.canvas.foldMarksDrawn(),
|
||
// The canvas, not the whole tab: a centre measured through the header sits above the
|
||
// drawing area.
|
||
center: () => centreOf(graphEditor.canvas),
|
||
pickerWidth: () => graphEditor.picker.w,
|
||
pickerAt: () => centreOf(graphEditor.picker),
|
||
snapping: () => graphEditor.canvas.snapEnabled,
|
||
zoom: () => graphEditor.canvas.zoomLevel,
|
||
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
|
||
},
|
||
stateMachine: {
|
||
current: () => editor.stateMachineEditor.currentAssetId,
|
||
pickerAt: () => centreOf(editor.stateMachineEditor.picker),
|
||
nodeAt: (nodeId: string) => editor.stateMachineEditor.canvas.nodeGlobal(nodeId),
|
||
nodeLabel: (nodeId: string) => editor.stateMachineEditor.canvas.nodeLabel(nodeId),
|
||
nodeRect: (nodeId: string) => editor.stateMachineEditor.canvas.nodeRect(nodeId),
|
||
canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(),
|
||
center: () => centreOf(editor.stateMachineEditor.canvas),
|
||
snapping: () => editor.stateMachineEditor.canvas.snapEnabled,
|
||
/** Where a state's hidden pin sits. It is no longer grabbable – `portGrabbable` is the check. */
|
||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||
editor.stateMachineEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||
portGrabbable: (nodeId: string, port: string, isOutput: boolean) =>
|
||
editor.stateMachineEditor.canvas.portGrabbable(nodeId, port, isOutput),
|
||
edgeDrawn: (edgeId: string) => editor.stateMachineEditor.canvas.edgeDrawn(edgeId),
|
||
transitionLabel: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelDrawn(edgeId),
|
||
hint: () => editor.stateMachineEditor.canvas.shownHint(),
|
||
/** Names a transition the way the inspector row does, for steps that need one already named. */
|
||
nameTransition: (edgeId: string, name: string) =>
|
||
state.execute(new NameTransitionCommand(editor.stateMachineEditor.currentAssetId, edgeId, name)),
|
||
dropTarget: () => editor.stateMachineEditor.canvas.pendingDropTarget(),
|
||
danglingEdge: () => editor.stateMachineEditor.canvas.danglingEdgeVisible(),
|
||
emptySpot: () => editor.stateMachineEditor.canvas.emptySpotGlobal(),
|
||
selected: () => state.selectedNodeIds,
|
||
/** The start ring and the live marks as drawn – what no colour check could tell apart. */
|
||
startRing: (nodeId: string) => editor.stateMachineEditor.canvas.startRingDrawn(nodeId),
|
||
stateMarks: () => editor.stateMachineEditor.canvas.stateMarksDrawn(),
|
||
},
|
||
particles: {
|
||
current: () => editor.particlesEditor.currentAssetId,
|
||
pickerAt: () => centreOf(editor.particlesEditor.picker),
|
||
info: () => editor.particlesEditor.shownInfo,
|
||
nodeAt: (nodeId: string) => editor.particlesEditor.canvas.nodeGlobal(nodeId),
|
||
nodeLabel: (nodeId: string) => editor.particlesEditor.canvas.nodeLabel(nodeId),
|
||
nodeRect: (nodeId: string) => editor.particlesEditor.canvas.nodeRect(nodeId),
|
||
nodeProblem: (nodeId: string) => editor.particlesEditor.canvas.nodeProblem(nodeId),
|
||
canvasRect: () => editor.particlesEditor.canvas.canvasRect(),
|
||
emptySpot: () => editor.particlesEditor.canvas.emptySpotGlobal(),
|
||
center: () => centreOf(editor.particlesEditor.canvas),
|
||
/** The panel beside the graph: how many particles it draws and which second it stands at. */
|
||
preview: () => ({
|
||
count: editor.particlesEditor.preview.count,
|
||
time: Math.round(editor.particlesEditor.preview.time * 100) / 100,
|
||
holding: editor.particlesEditor.preview.holding,
|
||
}),
|
||
previewRect: () => rectOf(editor.particlesEditor.preview),
|
||
/** Hold button, time readout and slider as they are shown, not as the preview holds them. */
|
||
controls: () => editor.particlesEditor.controls,
|
||
splitterAt: () => centreOf(editor.particlesEditor.splitter),
|
||
sliderAt: () => centreOf(editor.particlesEditor.slider),
|
||
/** Jumps the preview to a second – exact, because a particle is a function of its age. */
|
||
hold: (at: number | null) => editor.particlesEditor.preview.hold(at),
|
||
},
|
||
shader: {
|
||
current: () => editor.shaderEditor.currentAssetId,
|
||
pickerAt: () => centreOf(editor.shaderEditor.picker),
|
||
info: () => editor.shaderEditor.shownInfo,
|
||
nodeAt: (nodeId: string) => editor.shaderEditor.canvas.nodeGlobal(nodeId),
|
||
nodeLabel: (nodeId: string) => editor.shaderEditor.canvas.nodeLabel(nodeId),
|
||
nodeRect: (nodeId: string) => editor.shaderEditor.canvas.nodeRect(nodeId),
|
||
nodeProblem: (nodeId: string) => editor.shaderEditor.canvas.nodeProblem(nodeId),
|
||
nodeSettings: (nodeId: string) => editor.shaderEditor.canvas.nodeSettings(nodeId),
|
||
canvasRect: () => editor.shaderEditor.canvas.canvasRect(),
|
||
emptySpot: () => editor.shaderEditor.canvas.emptySpotGlobal(),
|
||
center: () => centreOf(editor.shaderEditor.canvas),
|
||
snapping: () => editor.shaderEditor.canvas.snapEnabled,
|
||
},
|
||
/** The console panel: its lines as kept, its drawn height, and the divider above it. */
|
||
console: {
|
||
lines: () => editor.console.debugInfo().lines,
|
||
height: () => editor.console.h,
|
||
clear: () => editor.console.clear(),
|
||
scroll: () => editor.console.scrollInfo(),
|
||
centre: () => centreOf(editor.console),
|
||
splitterAt: () => centreOf(editor.consoleSplitter),
|
||
},
|
||
/** The open image picker: what it offers, what is picked, and where a tile sits. */
|
||
imagePicker: () => currentImagePicker()?.debugInfo() ?? null,
|
||
imagePickerTile: (file: string) => currentImagePicker()?.tileGlobal(file) ?? null,
|
||
/** The same for the font picker, whose rows are the preview. */
|
||
fontPicker: () => currentFontPicker()?.debugInfo() ?? null,
|
||
fontPickerRow: (file: string) => currentFontPicker()?.rowGlobal(file) ?? null,
|
||
/** And for the sound picker, whose rows carry a ▶ of their own. */
|
||
soundPicker: () => currentSoundPicker()?.debugInfo() ?? null,
|
||
soundPickerRow: (file: string) => currentSoundPicker()?.rowGlobal(file) ?? null,
|
||
soundPickerPlay: (file: string) => currentSoundPicker()?.playGlobal(file) ?? null,
|
||
/**
|
||
* Voices started, stopped and in flight. CI runs the browser with `--mute-audio`, so nothing
|
||
* is ever heard there: these counters are the only way a check can see that a graph played
|
||
* anything at all, the way `renderStats` is for a repaint that did not happen.
|
||
*/
|
||
sound: {
|
||
stats: () => soundLibrary.stats(),
|
||
duration: (file: string) => soundLibrary.duration(file),
|
||
/** Decodes ahead of a check, so a play can be asserted without racing the network. */
|
||
load: (file: string) => soundLibrary.load(file).then((buffer) => buffer?.duration ?? null),
|
||
stopAll: () => soundLibrary.stopAll(),
|
||
},
|
||
/** Which typeface a text ended up drawn in, and how wide it made it. */
|
||
textInfo: (id: string) => viewport.textInfo(id),
|
||
gameTextInfo: (id: string) => gameView.textInfo(id),
|
||
texture: {
|
||
current: () => textureEditor.currentAssetId,
|
||
pickerWidth: () => textureEditor.picker.w,
|
||
/** 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),
|
||
rect: () => textureEditor.canvasRect(),
|
||
},
|
||
animation: {
|
||
/** Frames, the one on screen, play state and where the preview picture is drawn. */
|
||
current: () => editor.animationEditor.canvas.debugInfo(),
|
||
tileCentre: (index: number) => editor.animationEditor.canvas.tileCentre(index),
|
||
playButton: () => centreOf(editor.animationEditor.playButton),
|
||
playLabel: () => editor.animationEditor.playButton.text,
|
||
},
|
||
container: {
|
||
current: () => containerEditor.currentAssetId,
|
||
pickerWidth: () => containerEditor.picker.w,
|
||
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),
|
||
dropPreview: () => containerEditor.dropInfo(),
|
||
},
|
||
list: {
|
||
info: () => editor.listEditor.debugInfo(),
|
||
/** Where an item's widget sits, so a check can click it – a number field has no findable text. */
|
||
fieldAt: (index: number, field = '') => editor.listEditor.fieldAt(index, field),
|
||
/** The kind mark of a row, or of one field of a struct item – both are pickers. */
|
||
markAt: (index: number, field = '') => editor.listEditor.markAt(index, field),
|
||
nameAt: (index: number, field: string) => editor.listEditor.nameAt(index, field),
|
||
},
|
||
world: () => editor.worldSnapshot(),
|
||
playInfo: () => editor.playInfo(),
|
||
/** The start-scene switch: what it says, and where to click it (its caption is elided). */
|
||
playFrom: () => editor.playFromInfo(),
|
||
playFromAt: () => centreOf(editor.playFromButton),
|
||
/** The scene selector: where it is, and whether it takes a click (it does, play mode or not). */
|
||
sceneSelect: () => ({ ...centreOf(editor.sceneButton), enabled: editor.sceneButton.enabled }),
|
||
/** Behaviors running without Play (`Run in editor`), and how long they have been at it. */
|
||
editorRun: () => editor.editorRunInfo(),
|
||
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
|
||
hierarchyOrder: () => hierarchy.rowOrder(),
|
||
hierarchyChevron: (id: string) => hierarchy.chevronPosition(id),
|
||
/** Centre of a hierarchy row, clear of the chevron – for clicks that must hit the row itself. */
|
||
hierarchyRow: (id: string) => hierarchy.rowPosition(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])}`;
|
||
},
|
||
/** The filter and wrap the GPU sampler actually got, next to the model's – only the driver knows the former. */
|
||
textureFilter: (file: string, rect: FrameRect | null = null) => {
|
||
const texture = textureLibrary.peek(file, rect);
|
||
if (!texture) return null;
|
||
const source = texture.source;
|
||
const renderer = app.renderer as unknown as {
|
||
gl?: WebGL2RenderingContext;
|
||
uid: number;
|
||
texture: { bindSource(source: unknown, location: number): void };
|
||
};
|
||
const gl = renderer.gl;
|
||
const uploaded = Boolean(
|
||
(source as unknown as { _gpuData: Record<number, unknown> })._gpuData[renderer.uid],
|
||
);
|
||
let magFilter = 0;
|
||
let wrapT = 0;
|
||
if (gl) {
|
||
// Through Pixi rather than a raw `bindTexture`, so its idea of what is bound stays true.
|
||
renderer.texture.bindSource(source, 0);
|
||
magFilter = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER) as number;
|
||
wrapT = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T) as number;
|
||
}
|
||
return {
|
||
scaleMode: source.scaleMode,
|
||
gpu: magFilter === gl?.NEAREST ? 'nearest' : magFilter === gl?.LINEAR ? 'linear' : 'none',
|
||
wrap:
|
||
wrapT === gl?.CLAMP_TO_EDGE
|
||
? 'clamp'
|
||
: wrapT === gl?.REPEAT
|
||
? 'repeat'
|
||
: wrapT === gl?.MIRRORED_REPEAT
|
||
? 'mirror'
|
||
: 'none',
|
||
uploaded,
|
||
};
|
||
},
|
||
fps: () => editor.fps,
|
||
/**
|
||
* What the render loop did: frames actually drawn, and whether it is currently awake. The
|
||
* only way to see a missing repaint from a check script – `pixelAt` re-renders on demand and
|
||
* would answer correctly even if the screen had been frozen for a minute.
|
||
*/
|
||
renderStats: () => ({ drawn: renderCount(), awake: isRendering() }),
|
||
/** Draw again, whatever happens next – what a screenshot needs before it is taken. */
|
||
wake: () => requestRender(),
|
||
/**
|
||
* Resolves once the editor has drawn `count` more frames. The checks await this instead of
|
||
* sleeping a guessed number of milliseconds: a frame is the editor saying it has caught up,
|
||
* where a deadline is only the harness hoping it has.
|
||
*/
|
||
afterFrames: (count = 2, timeoutMs = 2000) => afterFrames(count, timeoutMs),
|
||
/** Visible nodes carrying a filter – one extra render pass each; since #77 a shader is none. */
|
||
filterCount: () => {
|
||
let n = 0;
|
||
const walk = (node: Container): void => {
|
||
if (!node.visible) return;
|
||
if (((node.filters as readonly unknown[] | undefined)?.length ?? 0) > 0) n += 1;
|
||
for (const child of node.children) walk(child);
|
||
};
|
||
walk(app.stage);
|
||
return n;
|
||
},
|
||
/** Visible sprite meshes drawing with a shader of their own – one draw call each, no pass (#77). */
|
||
shaderCount: () => {
|
||
let n = 0;
|
||
const walk = (node: Container): void => {
|
||
if (!node.visible) return;
|
||
if (node instanceof Mesh && node.shader) n += 1;
|
||
for (const child of node.children) walk(child);
|
||
};
|
||
walk(app.stage);
|
||
return n;
|
||
},
|
||
/** The global clock: the second it stands at, the scale set, and the one a pause makes of it. */
|
||
globalTime: () => ({
|
||
now: gameClock.now,
|
||
scale: gameClock.scale,
|
||
effective: effectiveScale(gameClock.scale, state.paused),
|
||
}),
|
||
/** Sets the global time scale – there is no slider for it in the GUI yet. */
|
||
setTimeScale: (scale: number) => {
|
||
gameClock.scale = scale;
|
||
},
|
||
/** Holds the shader clock at `at` seconds, so a Time shader's pixels can be measured; null releases it. */
|
||
shaderTime: (at: number | null) => shaderLibrary.freezeTime(at),
|
||
/** The same for particle time, kept apart so one can be held without the other. */
|
||
particleTime: (at: number | null) => particleLibrary.freezeTime(at),
|
||
particleCompiles: () => particleLibrary.compiles,
|
||
/** Every source's clock back to zero on the current (usually frozen) second. */
|
||
particlesRestart: () => {
|
||
viewport.restartParticles(particleLibrary.now());
|
||
gameView.restartParticles(particleLibrary.now());
|
||
},
|
||
/** What a source is drawing in the scene tab, or in the game view while playing. */
|
||
particlesOf: (objectId: string, where: 'scene' | 'game' = 'scene', limit = 8) =>
|
||
(where === 'game' ? gameView : viewport).particleInfo(objectId, limit),
|
||
/** Graphs compiled so far; a moved node must not add one (#65). */
|
||
shaderCompiles: () => shaderLibrary.compiles,
|
||
/** Which views redraw every tick because a Time shader is on screen. */
|
||
animates: () => ({ scene: viewport.animates, game: gameView.animates }),
|
||
lowLatency: () =>
|
||
(app.renderer as unknown as { gl?: WebGL2RenderingContext }).gl?.getContextAttributes()
|
||
?.desynchronized === true,
|
||
tab: () => editor.tab,
|
||
showTab: (tab: CentreTab) => editor.showTab(tab),
|
||
};
|
||
}
|