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 => 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 })._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), }; }