From ed29b2f3dcad4368735da2b6d5d815464c65d2b4 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 23 Sep 2026 10:20:06 +0200 Subject: [PATCH] Split the debug hook into one file per topic (#215) debugHook.ts had grown to 1097 lines of unrelated test hooks. They now live in seven files under src/editor/debug/, grouped by topic. debugHook.ts only assembles them and is down to 86 lines. - The shape of window.__gm is unchanged for tools/. Measured in the browser before and after: 120 top-level keys and 313 leaves with their arity, byte-identical JSON. - The Text import is explicit in the helpers. Without it the name fell back to the DOM type and instanceof would never have matched. Findings: - The comment for vectorArt sat above pixelArt. It is back in place. - An orphaned duplicate comment on assetRow is gone. Tests: - ./check.sh: 145 files, 2192 tests, CHECK EXIT 0 (unchanged) - e2e 1-16,37,39-43,47,48: ALL PASSED, 734 checks - The shape probe turns red against a deliberately misnested hook. Co-Authored-By: Claude Opus 5.5 --- src/editor/debug/documentHooks.ts | 89 +++ src/editor/debug/graphHooks.ts | 143 ++++ src/editor/debug/hookHelpers.ts | 35 + src/editor/debug/pixelArtHooks.ts | 157 +++++ src/editor/debug/renderHooks.ts | 159 +++++ src/editor/debug/sceneHooks.ts | 197 ++++++ src/editor/debug/uiHooks.ts | 234 +++++++ src/editor/debug/vectorArtHooks.ts | 80 +++ src/editor/debugHook.ts | 1051 +--------------------------- 9 files changed, 1114 insertions(+), 1031 deletions(-) create mode 100644 src/editor/debug/documentHooks.ts create mode 100644 src/editor/debug/graphHooks.ts create mode 100644 src/editor/debug/hookHelpers.ts create mode 100644 src/editor/debug/pixelArtHooks.ts create mode 100644 src/editor/debug/renderHooks.ts create mode 100644 src/editor/debug/sceneHooks.ts create mode 100644 src/editor/debug/uiHooks.ts create mode 100644 src/editor/debug/vectorArtHooks.ts diff --git a/src/editor/debug/documentHooks.ts b/src/editor/debug/documentHooks.ts new file mode 100644 index 00000000..d71b36e0 --- /dev/null +++ b/src/editor/debug/documentHooks.ts @@ -0,0 +1,89 @@ +import { findAsset, findAssetOf } from '../../core/model/Project'; +import { previewObjectId } from '../../core/model/animationPreview'; +import { currentFramesDialog } from '../animation/FramesDialog'; +import { currentRowDialog } from '../animation/RowDialog'; +import { centreOf, type HookContext } from './hookHelpers'; + +/** The Texture, Animation, Tile and List tabs. */ +export function documentHooks({ state, editor }: HookContext) { + const { textureEditor } = editor; + return { + texture: { + current: () => textureEditor.currentAssetId, + /** 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(), + /** Whether a press edits the selected frame's collision outline (#141). */ + collisionMode: () => textureEditor.canvas.collisionMode, + /** The open "Generate collision…" dialog (#161), or null. */ + generateDialog: () => textureEditor.generateDialog.current()?.info ?? null, + }, + animation: { + current: () => editor.animationEditor.currentAssetId, + info: () => editor.animationEditor.shownInfo, + rootPickerAt: () => centreOf(editor.animationEditor.rootPicker), + addRowAt: () => centreOf(editor.animationEditor.addRowButton), + playAt: () => ({ + ...centreOf(editor.animationEditor.playButton), + text: editor.animationEditor.playButton.text, + }), + /** Where a cell, a row's header and a frame on the ruler sit – no text names a cell. */ + cellAt: (trackId: string, frame: number) => editor.animationEditor.canvas.cellGlobal(trackId, frame), + headerAt: (trackId: string) => editor.animationEditor.canvas.headerGlobal(trackId), + rulerAt: (frame: number) => editor.animationEditor.canvas.rulerGlobal(frame), + keys: () => editor.animationEditor.canvas.keysDrawn(), + rows: () => editor.animationEditor.canvas.rowsDrawn(), + view: () => editor.animationEditor.canvas.viewInfo(), + scrub: (frame: number) => editor.animationEditor.canvas.scrub(frame), + /** The open row dialog: what it offers and holds, and a way to pick without clicking through menus. */ + rowDialog: () => currentRowDialog()?.debugInfo() ?? null, + rowDialogPick: (target: string, property: string) => currentRowDialog()?.pick(target, property), + /** Copy/paste and frames (#102): what the clipboard holds, and the open frames dialog. */ + clipboard: () => structuredClone(state.keyClipboard), + framesDialog: () => currentFramesDialog()?.debugInfo() ?? null, + framesDialogCount: (count: number) => currentFramesDialog()?.setCount(count), + framesDialogConfirm: () => { + const dialog = currentFramesDialog(); + if (!dialog) return null; + const g = dialog.confirm.getGlobalPosition(); + return { x: g.x + dialog.confirm.w / 2, y: g.y + dialog.confirm.h / 2 }; + }, + /** The preview above the timeline (#99): where a row's target is drawn and with which values. */ + preview: { + /** The id the preview draws a row's target under (`''` = the root itself). */ + objectId: (target: string) => { + const asset = findAssetOf(state.project, editor.animationEditor.currentAssetId, 'animation'); + return previewObjectId(asset && findAsset(state.project, asset.root), target); + }, + rect: (objectId: string) => editor.animationEditor.preview.objectRect(objectId), + item: (objectId: string) => editor.animationEditor.preview.itemDrawn(objectId), + info: () => editor.animationEditor.preview.debugInfo(), + fitAt: () => centreOf(editor.animationEditor.fitButton), + }, + }, + tile: { + current: () => editor.tileEditor.currentAssetId, + info: () => editor.tileEditor.shownInfo, + /** What the board drew: tiles, the picture it is showing and what stands in the way. */ + board: () => editor.tileEditor.canvas.debugInfo(), + pictures: () => editor.tileEditor.strip.debugInfo(), + /** A tile of the board: its quarters and where each of them is drawn. */ + tile: (index: number) => editor.tileEditor.canvas.tileDrawn(index), + /** Which board tile a cell of the sample map became – `-1` when that cell is empty. */ + sampleTile: (row: number, column: number) => editor.tileEditor.canvas.sampleTile(row, column), + kindPickerAt: () => centreOf(editor.tileEditor.kindPicker), + addPictureAt: () => centreOf(editor.tileEditor.strip.addButton), + removePictureAt: () => centreOf(editor.tileEditor.strip.removeButton), + pictureRowAt: (index: number) => editor.tileEditor.strip.list.rowGlobal(String(index)), + fitAt: () => centreOf(editor.tileEditor.fitButton), + }, + 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), + }, + }; +} diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts new file mode 100644 index 00000000..1fab33be --- /dev/null +++ b/src/editor/debug/graphHooks.ts @@ -0,0 +1,143 @@ +import { NameTransitionCommand } from '../../core/commands/graphCommands'; +import { currentNodePalette } from '../graph/NodePalette'; +import { rectOf } from '../textProbe'; +import { centreOf, snapButton, type HookContext } from './hookHelpers'; + +/** The node graphs: behaviors, state machines, particles and shaders. */ +export function graphHooks({ state, editor }: HookContext) { + const { graphEditor } = editor; + return { + 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, + /** Groups and comments as drawn – text and both colours included, since no pixel says them. */ + annotations: () => graphEditor.canvas.annotationsDrawn(), + /** Where one is grabbed: its title bar, or with `corner` the handle that resizes it. */ + annotationAt: (id: string, corner = false) => graphEditor.canvas.annotationGlobal(id, corner), + /** The box as drawn on screen – how a check sees whether a fit brought it into view. */ + annotationRect: (id: string) => graphEditor.canvas.annotationRect(id), + dropTarget: () => graphEditor.canvas.pendingDropTarget(), + danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(), + edgeDrawn: (edgeId: string) => graphEditor.canvas.edgeDrawn(edgeId), + /** The colour it was stroked in: `danger` is an edge whose two ends stopped fitting (#207). */ + edgeColor: (edgeId: string) => graphEditor.canvas.edgeColor(edgeId), + /** The types the node's ports are drawn with, after a shared group had its say (#207). */ + portTypes: (nodeId: string) => graphEditor.canvas.portTypes(nodeId), + 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), + snapping: () => graphEditor.canvas.snapEnabled, + snapButton: () => snapButton(graphEditor.snap), + zoom: () => graphEditor.canvas.zoomLevel, + crumbs: () => graphEditor.breadcrumb.crumbsDrawn(), + }, + stateMachine: { + current: () => editor.stateMachineEditor.currentAssetId, + 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, + snapButton: () => snapButton(editor.stateMachineEditor.snap), + /** Where a state's hidden pin sits; it cannot be grabbed – `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), + transitionLabelAt: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelAt(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, + info: () => editor.particlesEditor.shownInfo, + snapButton: () => snapButton(editor.particlesEditor.snap), + 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, + 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, + snapButton: () => snapButton(editor.shaderEditor.snap), + /** The panel beside the graph (#168): what it shows, its clock and the sliders' values. */ + preview: () => ({ + ...editor.shaderEditor.controls, + holding: editor.shaderEditor.preview.holding, + seconds: Math.round(editor.shaderEditor.preview.time * 100) / 100, + }), + previewRect: () => rectOf(editor.shaderEditor.preview), + /** Where the picture sits inside the panel, in screen pixels. */ + pictureRect: () => editor.shaderEditor.preview.pictureRect, + sliderAt: (key: string) => { + const slider = editor.shaderEditor.sliderOf(key); + return slider ? rectOf(slider) : null; + }, + splitterAt: () => centreOf(editor.shaderEditor.splitter), + hold: (at: number | null) => editor.shaderEditor.hold(at), + }, + }; +} diff --git a/src/editor/debug/hookHelpers.ts b/src/editor/debug/hookHelpers.ts new file mode 100644 index 00000000..ae6b2ab3 --- /dev/null +++ b/src/editor/debug/hookHelpers.ts @@ -0,0 +1,35 @@ +import type { Application } from 'pixi.js'; +import type { Editor } from '../Editor'; +import type { SnapToggle } from '../graph/SnapToggle'; +import type { EditorState } from '../state'; + +/** What every group of test hooks is built from. */ +export interface HookContext { + app: Application; + state: EditorState; + editor: Editor; +} + +export function centreOf(widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }): { + x: number; + y: number; +} { + const p = widget.getGlobalPosition(); + return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; +} + +/** A tab's magnet (#194): where to click it, how big it is drawn, and whether it is on. */ +export function snapButton(snap: SnapToggle): { + x: number; + y: number; + w: number; + h: number; + toggled: boolean; +} { + return { + ...centreOf(snap.button), + w: snap.button.w, + h: snap.button.h, + toggled: snap.button.toggled, + }; +} diff --git a/src/editor/debug/pixelArtHooks.ts b/src/editor/debug/pixelArtHooks.ts new file mode 100644 index 00000000..646e8a7c --- /dev/null +++ b/src/editor/debug/pixelArtHooks.ts @@ -0,0 +1,157 @@ +import type { Container } from 'pixi.js'; +import { findAsset } from '../../core/model/Project'; +import { Button, type Widget } from '../../ui'; +import { pixelCels } from '../../viewport/PixelCels'; +import { currentResizeDialog } from '../pixelart/ResizeCanvasDialog'; +import { centreOf, type HookContext } from './hookHelpers'; + +/** The Pixel art tab (#130). */ +export function pixelArtHooks({ state, editor }: HookContext) { + return { + /** The Pixel art tab (#130): where its controls are, what it paints with, and the asset's pixels. */ + pixelArt: { + current: () => editor.pixelArtEditor.currentAssetId, + info: () => editor.pixelArtEditor.shownInfo, + /** Where the centre of canvas pixel (x, y) is on screen. */ + pixelAt: (x: number, y: number) => editor.pixelArtEditor.canvas.pixelToScreen(x, y), + zoom: () => editor.pixelArtEditor.canvas.zoom, + tool: () => editor.pixelArtEditor.canvas.tool, + color: () => editor.pixelArtEditor.canvas.color, + frameId: () => editor.pixelArtEditor.canvas.frameId, + layerId: () => editor.pixelArtEditor.canvas.layerId, + /** A control of the side panel or the header, scrolled into view: its centre and whether it is enabled. */ + control: (name: string) => { + const side = editor.pixelArtEditor.side; + const byName: Record = { + brush: side.brushButton, + filled: side.filledBox, + contiguous: side.contiguousBox, + color: side.colorField, + addColor: side.addColorButton, + removeColor: side.removeColorButton, + createPalette: side.createPaletteButton, + onion: side.onionBox, + tiles: side.tilesBox, + fps: side.fpsField, + palette: side.palette, + preview: side.preview, + resize: editor.pixelArtEditor.resizeButton, + ...Object.fromEntries([...side.toolButtons].map(([tool, button]) => [tool, button])), + ...Object.fromEntries(Object.entries(side.layerButtons).map(([k, b]) => [`layer:${k}`, b])), + }; + // The plus in a list's header is no widget of its own. + const list = name === 'layer:add' ? side.layers : name === 'frame:add' ? side.frames : null; + if (list) { + side.reveal(list); + const at = list.addGlobal(); + return at ? { ...at, enabled: true, text: '+' } : null; + } + const widget = byName[name]; + // A tool's options hide in their row (#201), so a hidden parent hides the control as well. + for (let at: Container | null = widget ?? null; at; at = at.parent) if (!at.visible) return null; + if (!widget) return null; + if (widget !== editor.pixelArtEditor.resizeButton) side.reveal(widget); + return { + ...centreOf(widget), + w: widget.w, + enabled: widget.enabled, + text: widget instanceof Button ? widget.text : '', + }; + }, + /** Where the controls of the side panel may reach, left and right. */ + sideSpan: () => editor.pixelArtEditor.side.contentSpan(), + swatch: (index: number) => { + const side = editor.pixelArtEditor.side; + side.reveal(side.palette); + return side.palette.swatchGlobal(index); + }, + /** + * The centre of a layer's or frame's row, scrolled into view, with the centre of its picture; + * `eye` is the layer's switch, `duplicate` and `remove` the marks at the end (no cross on the + * last one), `name` where a double-click edits the name, `opacity` the field with its text and + * `renaming` the field a name is being edited in. + */ + layerRow: (layerId: string) => { + const side = editor.pixelArtEditor.side; + side.reveal(side.layers); + const row = side.layers.rowGlobal(layerId); + const opacity = side.layers.valueField(layerId); + const renaming = side.layers.renameField; + // On the name: the middle of the row may be the opacity field, which a press would scrub. + const name = side.layers.nameGlobal(layerId); + return row && name + ? { + x: name.x, + y: name.y, + thumb: side.layers.thumbGlobal(layerId), + name, + eye: side.layers.eyeGlobal(layerId), + duplicate: side.layers.duplicateGlobal(layerId), + remove: side.layers.removeGlobal(layerId), + opacity: opacity ? { ...centreOf(opacity), value: opacity.value } : null, + renaming: renaming ? { ...centreOf(renaming), value: renaming.value } : null, + } + : null; + }, + frameRow: (frameId: string) => { + const side = editor.pixelArtEditor.side; + side.reveal(side.frames); + const row = side.frames.rowGlobal(frameId); + return row + ? { + x: row.x + row.w / 2, + y: row.y + row.h / 2, + thumb: side.frames.thumbGlobal(frameId), + duplicate: side.frames.duplicateGlobal(frameId), + remove: side.frames.removeGlobal(frameId), + } + : null; + }, + /** The ids as the two lists show them, top first. */ + layerRows: () => editor.pixelArtEditor.side.layers.shownIds, + frameRows: () => editor.pixelArtEditor.side.frames.shownIds, + previewIndex: () => editor.pixelArtEditor.side.preview.shownIndex, + /** Canvas, layers and frames as the model holds them. */ + asset: (assetId: string) => { + const asset = findAsset(state.project, assetId); + return asset?.type === 'pixelart' + ? (JSON.parse( + JSON.stringify({ + width: asset.width, + height: asset.height, + layers: asset.layers, + frames: asset.frames, + props: asset.props, + }), + ) as unknown) + : null; + }, + /** The palette indices of a cel, row by row; null for an empty cel, 'loading' while it loads. */ + cel: (file: string) => { + const cel = pixelCels.read(file); + return cel === undefined + ? 'loading' + : cel === null + ? null + : { width: cel.width, height: cel.height, data: [...cel.data] }; + }, + /** Cels painted here that the server does not hold yet. */ + pending: () => pixelCels.pending(state.project).length, + resizeDialog: () => { + const dialog = currentResizeDialog(); + if (!dialog) return null; + return { + ...dialog.debugInfo(), + width: centreOf(dialog.widthField), + height: centreOf(dialog.heightField), + confirm: centreOf(dialog.confirm), + anchors: Object.fromEntries( + ([0, 0.5, 1] as const).flatMap((y) => + ([0, 0.5, 1] as const).map((x) => [`${x},${y}`, dialog.anchorButton(x, y)]), + ), + ), + }; + }, + }, + }; +} diff --git a/src/editor/debug/renderHooks.ts b/src/editor/debug/renderHooks.ts new file mode 100644 index 00000000..4de50415 --- /dev/null +++ b/src/editor/debug/renderHooks.ts @@ -0,0 +1,159 @@ +import { type FrameRect, resolvePicture } from '../../core/model/texture'; +import { effectiveScale, gameClock } from '../../core/time'; +import { afterFrames, isRendering, renderCount, requestRender } from '../../ui'; +import { particleLibrary } from '../../viewport/ParticleLibrary'; +import { shaderLibrary } from '../../viewport/ShaderLibrary'; +import { textureLibrary } from '../../viewport/TextureLibrary'; +import { type Container, Mesh, Rectangle } from 'pixi.js'; +import type { HookContext } from './hookHelpers'; + +/** What was really drawn, and the clocks behind it: pixels, textures, frames, shaders and particles. */ +export function renderHooks({ app, state, editor }: HookContext) { + const { viewport, gameView } = editor; + /** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */ + const pixelAt = (x: number, y: number): string => { + 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])}`; + }; + return { + pixelAt, + /** + * A horizontal run of pixels in one read. A check that has to tell a repeated pattern from a + * stretched one needs dozens of samples, and dozens of round trips would cost more than the + * step it belongs to. + */ + pixelRow: (x: number, y: number, count: number) => { + const width = Math.max(1, Math.min(1024, Math.round(count))); + const row: string[] = []; + // One extract per pixel, not one strip: a wider frame comes back resampled, and a row that + // blends its neighbours cannot tell a hard border from a soft one – which is what it is for. + for (let i = 0; i < width; i++) row.push(pixelAt(x + i, y)); + return row; + }, + /** 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, + }; + }, + /** + * A picture an asset draws itself (#129, #185): the key it is kept under, the canvas the model + * says it is, and the size it actually lies on the GPU in – which is the only place the + * supersampling of the rasteriser is visible. `kept` false means the key is not in memory, + * which is what a check reads to see an old revision let go. + */ + drawnPicture: (assetId: string) => { + const ref = resolvePicture(state.project, assetId); + if (!ref) return null; + const texture = textureLibrary.peek(ref.file); + return { + file: ref.file, + width: ref.sourceWidth, + height: ref.sourceHeight, + pixelWidth: texture?.source.pixelWidth ?? 0, + pixelHeight: texture?.source.pixelHeight ?? 0, + kept: texture !== null, + }; + }, + 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 – either kind – makes of it. */ + globalTime: () => ({ + now: gameClock.now, + scale: gameClock.scale, + effective: effectiveScale(gameClock.scale, state.paused || editor.playInfo().gamePaused), + }), + /** 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), + /** 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, + }; +} diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts new file mode 100644 index 00000000..a0d8811e --- /dev/null +++ b/src/editor/debug/sceneHooks.ts @@ -0,0 +1,197 @@ +import { findAssetOf, findObjectHolder } from '../../core/model/Project'; +import { resolveObjects } from '../../core/model/instance'; +import { findLayer, layerZ } from '../../core/model/tilemap'; +import { currentTilemapWidgets, paintedLayerId, paintedTilemap } from '../panels/TilemapPanel'; +import type { TilemapTool } from '../state'; +import { rectOf } from '../textProbe'; +import { centreOf, type HookContext } from './hookHelpers'; + +/** The Scene and Game tabs, the tilemap painted in the scene view, and the Container tab. */ +export function sceneHooks({ state, editor }: HookContext) { + const { viewport, gameView, containerEditor } = editor; + /** + * 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) : []; + }; + return { + sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []), + 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(), + /** A full redraw of the Scene tab – what a pan, which only moves the camera, is compared with. */ + viewportRedraw: () => viewport.redraw(), + /** Where a dragged asset would land in the Scene tab, and why not when it may not. */ + dropPreview: () => viewport.dropInfo(), + gameInfo: () => gameView.debugInfo(), + /** Selection outlines in the Scene tab: a switched-off object is invisible but still framed. */ + gizmos: () => viewport.gizmoInfo(), + /** The corner handles of the polygon collider being edited, in global pixels (#151). */ + polygonCorners: () => viewport.cornerInfo(), + /** The rays the editor run cast in the last moment, as the Scene tab draws them, in world units (#153). */ + rays: () => viewport.rayInfo(), + 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), + /** What a click at a screen point picks from in the Scene tab, front to back (#203). */ + sceneStack: (x: number, y: number) => viewport.stackInfo(x, y), + /** 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), + /** 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), + tilemap: { + /** What the scene view painted a map with, and on which layer (#128). */ + paint: () => ({ ...state.tilemapPaint }), + /** The cells of one layer as the model holds them: `"x,y"` to tile id. */ + cells: (assetId: string, layerId: string) => { + const asset = findAssetOf(state.project, assetId, 'tilemap'); + const layer = asset && findLayer(asset, layerId); + return layer ? { ...layer.cells } : null; + }, + layers: (assetId: string) => + findAssetOf(state.project, assetId, 'tilemap')?.layers.map((layer) => ({ + id: layer.id, + name: layer.name, + hidden: layer.hidden === true, + z: layerZ(layer), + cells: Object.keys(layer.cells).length, + })) ?? null, + /** What the scene view drew: meshes (one draw call each), cells and visible layers. */ + info: (objectId: string) => viewport.tilemapInfo(objectId), + gameInfo: (objectId: string) => gameView.tilemapInfo(objectId), + /** Where the scene view outlines the map as blocking: the merged rectangles, in cells (#132). */ + solids: (objectId: string) => viewport.solidInfo(objectId), + /** + * The painting panel beside the scene view (#142): whether it is on screen, its box, the + * scene view's box next to it and its splitter – null boxes while it is hidden. + */ + panel: () => { + // The editor hides the panel itself on every other tab, so its own flag is the whole answer. + const shown = editor.tilemapPanel.visible; + return { + shown, + title: editor.tilemapPanel.title, + panel: shown ? rectOf(editor.tilemapPanel) : null, + splitter: shown ? centreOf(editor.tilemapSplitter) : null, + viewport: rectOf(viewport), + }; + }, + /** Where the widgets of the painting panel sit: their texts are on screen elsewhere too. */ + toolAt: (tool: string) => { + const button = currentTilemapWidgets()?.tools.get(tool as TilemapTool); + return button ? centreOf(button) : null; + }, + tileButtonAt: (assetId: string) => + currentTilemapWidgets()?.palette?.swatchGlobal(assetId)?.centre ?? null, + /** A tile's box in the palette: its centre, where its picture sits and the palette's columns (#139). */ + tileSwatch: (assetId: string) => { + const palette = currentTilemapWidgets()?.palette; + const swatch = palette?.swatchGlobal(assetId); + return palette && swatch ? { ...swatch, columns: palette.columns } : null; + }, + /** The plus in the layer list's header. */ + addLayerAt: () => currentTilemapWidgets()?.layers.addGlobal() ?? null, + /** + * Where the painted layer's name stands in its row – a double-click there edits it (#138) – + * with the name the row shows and the field it is being edited in, while it is. + */ + layerNameAt: () => { + const widgets = currentTilemapWidgets(); + const target = paintedTilemap(state); + if (!widgets || !target) return null; + const layerId = paintedLayerId(state, target.asset); + const at = widgets.layers.nameGlobal(layerId); + const editing = widgets.layers.renameField; + return at + ? { + ...at, + value: findLayer(target.asset, layerId)?.name ?? '', + editing: editing ? { ...centreOf(editing), value: editing.value } : null, + } + : null; + }, + /** The Z field in the painted layer's row, with what it holds (#174). */ + layerZAt: () => { + const widgets = currentTilemapWidgets(); + const target = paintedTilemap(state); + const field = widgets && target && widgets.layers.valueField(paintedLayerId(state, target.asset)); + return field ? { ...centreOf(field), value: field.value } : null; + }, + /** The duplicate mark on the painted layer's row. */ + duplicateLayerAt: () => { + const widgets = currentTilemapWidgets(); + const target = paintedTilemap(state); + return widgets && target ? widgets.layers.duplicateGlobal(paintedLayerId(state, target.asset)) : null; + }, + focusAt: () => { + const button = currentTilemapWidgets()?.focus; + return button ? { ...centreOf(button), label: button.text } : null; + }, + /** One row of the layer list, top-down as drawn: the layer, where to press it, and its eye. */ + layerRowAt: (index: number) => { + const list = currentTilemapWidgets()?.layers; + const id = list?.shownIds[index]; + // On the name: the middle of the row may be the Z field, which a press would scrub. + const name = id ? list.nameGlobal(id) : null; + if (!list || !id || !name) return null; + // `y` is the row's own line, so a check can tell whether it is inside the panel. + return { id, y: name.y, name, visible: list.eyeGlobal(id) }; + }, + /** The cross on the painted layer's row; `enabled` is false where the row has none (the last layer). */ + removeLayerAt: () => { + const widgets = currentTilemapWidgets(); + const target = paintedTilemap(state); + if (!widgets || !target) return null; + const layerId = paintedLayerId(state, target.asset); + const at = widgets.layers.removeGlobal(layerId); + const row = widgets.layers.rowGlobal(layerId); + return at ? { ...at, enabled: true } : row ? { x: row.x, y: row.y, enabled: false } : null; + }, + /** Where a cell sits on screen – for a click that paints it or a pixel that proves it. */ + cellAt: (objectId: string, x: number, y: number) => viewport.cellScreen(objectId, x, y), + gameCellAt: (objectId: string, x: number, y: number) => gameView.cellScreen(objectId, x, y), + }, + container: { + current: () => containerEditor.currentAssetId, + 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(), + }, + }; +} diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts new file mode 100644 index 00000000..621d4de4 --- /dev/null +++ b/src/editor/debug/uiHooks.ts @@ -0,0 +1,234 @@ +import type { AssetType } from '../../core/model/AssetTypes'; +import { Button, Panel, Widget, activeDrag, currentMenu, ui } from '../../ui'; +import { Theme } from '../../ui/Theme'; +import { currentFontPicker } from '../font/FontPicker'; +import { currentSoundPicker } from '../sound/SoundPicker'; +import { rectOf, textProbe } from '../textProbe'; +import { currentImagePicker } from '../texture/ImagePicker'; +import { Color, type ColorSource, type Container, Text } from 'pixi.js'; +import { centreOf, type HookContext } from './hookHelpers'; + +/** Widgets found by their visible text, the panels, menus, pickers and dialogs around the tabs. */ +export function uiHooks({ app, state, editor }: HookContext) { + const { hierarchy, assets, inspector } = editor; + const { eachText, locate, locateAll, locateIcons } = textProbe(app); + /** 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 }; + }; + return { + locate, + locateAll, + /** Pictures on screen drawn from the SVG file with this id (#214), e.g. `reset`, `kind-struct`. */ + locateIcons, + /** The first such picture (or the topmost with `last`), or `null`. */ + locateIcon: (name: string, last = false) => { + const hits = locateIcons(name); + return (last ? hits.at(-1) : hits[0]) ?? null; + }, + inspectorField: fieldOf, + /** The inspector panel on screen – what tells whether a row of it can be clicked at all. */ + inspectorRect: () => rectOf(inspector), + /** Where the focused widget sits on screen, or `null` – for checks on its focus border. */ + focusedRect: () => { + const widget = ui().focus.focused; + return widget ? rectOf(widget) : null; + }, + /** What a panel dragged shut keeps: its header. Follows the theme, so checks need not. */ + panelHeader: Panel.HEADER_HEIGHT, + /** A theme colour as a hex string, so a check names the colour instead of its current value. */ + themeColor: (name: keyof typeof Theme.colors) => `#${Theme.colors[name].toString(16).padStart(6, '0')}`, + inspectorInfo: () => inspector.debugInfo(), + textColor, + overlayTexts, + overlayRect, + /** Entries of the open menu in drawn order, `null` for a separator line (#43). */ + menuRows: () => currentMenu()?.rowsDrawn() ?? null, + /** Per entry of `menuRows`, whether an icon stands in front of it. */ + menuIcons: () => currentMenu()?.iconsDrawn() ?? null, + /** Per entry of `menuRows`, its tick box: `true`/`false`, `null` for an entry without one. */ + menuTicks: () => currentMenu()?.ticksDrawn() ?? null, + /** Per entry of `menuRows`, whether it can be picked (a greyed-out one cannot). */ + menuEnabled: () => currentMenu()?.enabledDrawn() ?? null, + textOverflows, + labelsDropped, + shownLabel, + tabRow: () => editor.tabRow(), + /** The open "Change password" dialog (#182): its message and whether it is waiting, or null. */ + passwordDialog: () => editor.accountDialog()?.info() ?? null, + /** The project's collision layers (#152), and the names the open dialog holds (null when shut). */ + collisionLayers: () => ({ + project: [...state.project.collisionLayers], + dialog: editor.projectSettingsDialog()?.layerNames() ?? null, + }), + /** The "Project settings" dialog (#183): name and start-scene switch as its fields stand. */ + projectSettings: () => editor.projectSettingsDialog()?.values() ?? null, + /** Where to click the start-scene switch inside that dialog. */ + projectSettingsPlayFromAt: () => { + const dialog = editor.projectSettingsDialog(); + return dialog ? centreOf(dialog.playFromControl) : null; + }, + /** The "Open project" dialog (#183): the projects it lists and the row picked. */ + projectPicker: () => editor.projectPickerDialog()?.info() ?? null, + /** The dividers left and right of the centre column – dragging them narrows the tab row. */ + panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }), + /** The left column: asset panel on top, scene objects below, and the divider between them. */ + leftColumn: () => ({ + assets: rectOf(editor.assets), + objects: rectOf(editor.hierarchy), + splitter: centreOf(editor.columnSplitter), + }), + /** The panel-to-view drag in flight, if any – a drop that never started looks like a refused one. */ + dragPayload: () => activeDrag(), + /** An asset's row, its group unfolded first as a user would; `assetRowShown` leaves it folded. */ + assetRow: (id: string) => assets.rowGlobal(id, true), + assetRowShown: (id: string) => assets.rowGlobal(id), + assetRowColor: (id: string) => assets.rowColor(id), + /** The asset panel's groups in order; a heading's row is `assetRow('group:')`. */ + assetGroups: () => assets.groups(), + assetGroupIcon: (type: AssetType) => assets.groupIcon(type), + /** The panel's own rectangle – what tells a row in view from one scrolled past its edge. */ + assetsRect: () => rectOf(assets), + /** 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, + /** 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), + /** Centre of the type icon at the right edge of a hierarchy row. */ + hierarchyIcon: (id: string) => hierarchy.iconPosition(id), + hierarchyExpandAll: () => hierarchy.expandAll(), + hierarchyCollapseAll: () => hierarchy.collapseAll(), + }; +} diff --git a/src/editor/debug/vectorArtHooks.ts b/src/editor/debug/vectorArtHooks.ts new file mode 100644 index 00000000..e273a0b8 --- /dev/null +++ b/src/editor/debug/vectorArtHooks.ts @@ -0,0 +1,80 @@ +import { findAsset } from '../../core/model/Project'; +import { currentGradientBar, currentShapeList } from '../inspector/shapeSection'; +import { centreOf, snapButton, type HookContext } from './hookHelpers'; + +/** The Vector art tab (#129). */ +export function vectorArtHooks({ state, editor }: HookContext) { + const { vectorArtEditor } = editor; + return { + /** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */ + vectorArt: { + current: () => vectorArtEditor.currentAssetId, + info: () => vectorArtEditor.shownInfo, + pointAt: (x: number, y: number) => vectorArtEditor.canvasToScreen(x, y), + rect: () => vectorArtEditor.canvasRect(), + tool: () => vectorArtEditor.canvas.currentTool, + snapping: () => vectorArtEditor.canvas.snapping, + snapButton: () => snapButton(vectorArtEditor.snap), + /** Whether the pen has a line open that the next click would add a point to. */ + drawing: () => vectorArtEditor.canvas.drawing, + /** The header's point type (#195): where to click it, what it says, and the type it reads. */ + pointButton: () => ({ + ...centreOf(vectorArtEditor.pointButton), + text: vectorArtEditor.pointButton.text, + enabled: vectorArtEditor.pointButton.enabled, + kind: vectorArtEditor.canvas.pickedKind, + }), + /** The shapes as the model holds them, so a check can read points and handles exactly. */ + shapes: (assetId: string) => { + const asset = findAsset(state.project, assetId); + if (!asset || asset.type !== 'vectorart') return null; + return asset.shapes.map((shape) => ({ + id: shape.id, + name: shape.name, + fill: shape.fill, + opacity: shape.opacity, + stroke: shape.stroke ? (JSON.parse(JSON.stringify(shape.stroke)) as unknown) : null, + paint: shape.paint ? (JSON.parse(JSON.stringify(shape.paint)) as unknown) : null, + rings: shape.rings.map((ring) => ({ + closed: ring.closed, + points: ring.points.map((point) => ({ + x: point.x, + y: point.y, + in: point.in ? { ...point.in } : null, + out: point.out ? { ...point.out } : null, + mode: point.mode ?? null, + })), + })), + })); + }, + selected: () => (state.selection?.kind === 'shape' ? state.selection.shapeId : ''), + selectedIds: () => state.selectedShapeIds, + /** + * The shape list in the inspector (#129, stage 4): the order it shows, top first, and where a + * row sits. A row carries a name that also stands in the tab, so a check cannot click it by text. + */ + shapeRows: () => currentShapeList()?.shownIds ?? null, + shapeRowAt: (shapeId: string) => currentShapeList()?.rowGlobal(shapeId) ?? null, + /** The cross on a shape's row. */ + shapeRemoveAt: (shapeId: string) => currentShapeList()?.removeGlobal(shapeId) ?? null, + /** The duplicate mark on a shape's row, and where a double-click edits its name. */ + shapeDuplicateAt: (shapeId: string) => currentShapeList()?.duplicateGlobal(shapeId) ?? null, + shapeNameAt: (shapeId: string) => currentShapeList()?.nameGlobal(shapeId) ?? null, + /** The field a shape's name is being edited in, with what it holds; null while none is. */ + shapeRenaming: () => { + const field = currentShapeList()?.renameField; + return field ? { ...centreOf(field), value: field.value } : null; + }, + /** The Paint tool (#186): its button, the "Handles" menu, and which part it shows. */ + paintButton: () => centreOf(vectorArtEditor.paintButton), + paintTarget: () => ({ + ...centreOf(vectorArtEditor.paintTargetButton), + text: vectorArtEditor.paintTargetButton.text, + target: vectorArtEditor.canvas.paintTarget, + }), + /** A colour stop's marker, or the bar at an offset, in the inspector's fill or outline bar. */ + stopAt: (part: 'fill' | 'stroke', index: number) => currentGradientBar(part)?.stopGlobal(index) ?? null, + barAt: (part: 'fill' | 'stroke', offset: number) => currentGradientBar(part)?.barGlobal(offset) ?? null, + }, + }; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index d95906ad..f583d2b4 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -1,487 +1,36 @@ -import { type Application, Color, type ColorSource, type Container, Mesh, Rectangle, Text } from 'pixi.js'; -import { NameTransitionCommand } from '../core/commands/graphCommands'; -import { previewObjectId } from '../core/model/animationPreview'; -import type { AssetType } from '../core/model/AssetTypes'; -import { resolveObjects } from '../core/model/instance'; -import { findAsset, findAssetOf, findObjectHolder } from '../core/model/Project'; -import { type FrameRect, resolvePicture } from '../core/model/texture'; -import { findLayer, layerZ } from '../core/model/tilemap'; -import { effectiveScale, gameClock } from '../core/time'; +import type { Application } from 'pixi.js'; import { physicsStatus } from '../runtime/physics/engine'; -import { - activeDrag, - afterFrames, - Button, - currentMenu, - isRendering, - Panel, - renderCount, - requestRender, - ui, - Widget, -} from '../ui'; -import { particleLibrary } from '../viewport/ParticleLibrary'; -import { pixelCels } from '../viewport/PixelCels'; -import { shaderLibrary } from '../viewport/ShaderLibrary'; import { soundLibrary } from '../viewport/SoundLibrary'; -import { textureLibrary } from '../viewport/TextureLibrary'; -import { currentFramesDialog } from './animation/FramesDialog'; -import { currentRowDialog } from './animation/RowDialog'; +import { documentHooks } from './debug/documentHooks'; +import { graphHooks } from './debug/graphHooks'; +import { centreOf, type HookContext } from './debug/hookHelpers'; +import { pixelArtHooks } from './debug/pixelArtHooks'; +import { renderHooks } from './debug/renderHooks'; +import { sceneHooks } from './debug/sceneHooks'; +import { uiHooks } from './debug/uiHooks'; +import { vectorArtHooks } from './debug/vectorArtHooks'; import type { DocumentId, Editor, TabId } from './Editor'; import { NodeBoard } from './plugins/NodeBoard'; -import { currentFontPicker } from './font/FontPicker'; -import { currentNodePalette } from './graph/NodePalette'; -import { currentSoundPicker } from './sound/SoundPicker'; -import { currentImagePicker } from './texture/ImagePicker'; -import { currentTilemapWidgets, paintedLayerId, paintedTilemap } from './panels/TilemapPanel'; -import { currentResizeDialog } from './pixelart/ResizeCanvasDialog'; -import { currentGradientBar, currentShapeList } from './inspector/shapeSection'; -import type { SnapToggle } from './graph/SnapToggle'; -import type { EditorState, TilemapTool } from './state'; -import { rectOf, textProbe } from './textProbe'; -import { Theme } from '../ui/Theme'; +import type { EditorState } from './state'; +import { rectOf } from './textProbe'; /** * Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their - * visible text and to read editor state. + * visible text and to read editor state. The hooks of each area live in `debug/`. */ export function installDebugHook(app: Application, state: EditorState, editor: Editor): void { - const { - viewport, - gameView, - graphEditor, - textureEditor, - vectorArtEditor, - 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 }; - }; - /** A tab's magnet (#194): where to click it, how big it is drawn, and whether it is on. */ - const snapButton = (snap: SnapToggle) => ({ - ...centreOf(snap.button), - w: snap.button.w, - h: snap.button.h, - toggled: snap.button.toggled, - }); - const { eachText, locate, locateAll, locateIcons } = textProbe(app); - /** 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) : []; - }; - /** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */ - const pixelAt = (x: number, y: number): string => { - 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])}`; - }; + const context: HookContext = { app, state, editor }; (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, - /** Pictures on screen drawn from the SVG file with this id (#214), e.g. `reset`, `kind-struct`. */ - locateIcons, - /** The first such picture (or the topmost with `last`), or `null`. */ - locateIcon: (name: string, last = false) => { - const hits = locateIcons(name); - return (last ? hits.at(-1) : hits[0]) ?? null; - }, - inspectorField: fieldOf, - /** The inspector panel on screen – what tells whether a row of it can be clicked at all. */ - inspectorRect: () => rectOf(inspector), - /** Where the focused widget sits on screen, or `null` – for checks on its focus border. */ - focusedRect: () => { - const widget = ui().focus.focused; - return widget ? rectOf(widget) : null; - }, - /** What a panel dragged shut keeps: its header. Follows the theme, so checks need not. */ - panelHeader: Panel.HEADER_HEIGHT, - /** A theme colour as a hex string, so a check names the colour instead of its current value. */ - themeColor: (name: keyof typeof Theme.colors) => `#${Theme.colors[name].toString(16).padStart(6, '0')}`, - inspectorInfo: () => inspector.debugInfo(), - textColor, - overlayTexts, - overlayRect, - /** Entries of the open menu in drawn order, `null` for a separator line (#43). */ - menuRows: () => currentMenu()?.rowsDrawn() ?? null, - /** Per entry of `menuRows`, whether an icon stands in front of it. */ - menuIcons: () => currentMenu()?.iconsDrawn() ?? null, - /** Per entry of `menuRows`, its tick box: `true`/`false`, `null` for an entry without one. */ - menuTicks: () => currentMenu()?.ticksDrawn() ?? null, - /** Per entry of `menuRows`, whether it can be picked (a greyed-out one cannot). */ - menuEnabled: () => currentMenu()?.enabledDrawn() ?? null, - textOverflows, - labelsDropped, - shownLabel, - tabRow: () => editor.tabRow(), - /** The open "Change password" dialog (#182): its message and whether it is waiting, or null. */ - passwordDialog: () => editor.accountDialog()?.info() ?? null, - /** The project's collision layers (#152), and the names the open dialog holds (null when shut). */ - collisionLayers: () => ({ - project: [...state.project.collisionLayers], - dialog: editor.projectSettingsDialog()?.layerNames() ?? null, - }), - /** The "Project settings" dialog (#183): name and start-scene switch as its fields stand. */ - projectSettings: () => editor.projectSettingsDialog()?.values() ?? null, - /** Where to click the start-scene switch inside that dialog. */ - projectSettingsPlayFromAt: () => { - const dialog = editor.projectSettingsDialog(); - return dialog ? centreOf(dialog.playFromControl) : null; - }, - /** The "Open project" dialog (#183): the projects it lists and the row picked. */ - projectPicker: () => editor.projectPickerDialog()?.info() ?? null, - /** The dividers left and right of the centre column – dragging them narrows the tab row. */ - panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }), - /** The left column: asset panel on top, scene objects below, and the divider between them. */ - leftColumn: () => ({ - assets: rectOf(editor.assets), - objects: rectOf(editor.hierarchy), - splitter: centreOf(editor.columnSplitter), - }), - 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(), - /** A full redraw of the Scene tab – what a pan, which only moves the camera, is compared with. */ - viewportRedraw: () => viewport.redraw(), - /** 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. */ - /** An asset's row, its group unfolded first as a user would; `assetRowShown` leaves it folded. */ - assetRow: (id: string) => assets.rowGlobal(id, true), - assetRowShown: (id: string) => assets.rowGlobal(id), - assetRowColor: (id: string) => assets.rowColor(id), - /** The asset panel's groups in order; a heading's row is `assetRow('group:')`. */ - assetGroups: () => assets.groups(), - assetGroupIcon: (type: AssetType) => assets.groupIcon(type), - /** 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(), - /** The corner handles of the polygon collider being edited, in global pixels (#151). */ - polygonCorners: () => viewport.cornerInfo(), - /** The rays the editor run cast in the last moment, as the Scene tab draws them, in world units (#153). */ - rays: () => viewport.rayInfo(), - 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), - /** What a click at a screen point picks from in the Scene tab, front to back (#203). */ - sceneStack: (x: number, y: number) => viewport.stackInfo(x, y), - /** 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, - /** Groups and comments as drawn – text and both colours included, since no pixel says them. */ - annotations: () => graphEditor.canvas.annotationsDrawn(), - /** Where one is grabbed: its title bar, or with `corner` the handle that resizes it. */ - annotationAt: (id: string, corner = false) => graphEditor.canvas.annotationGlobal(id, corner), - /** The box as drawn on screen – how a check sees whether a fit brought it into view. */ - annotationRect: (id: string) => graphEditor.canvas.annotationRect(id), - dropTarget: () => graphEditor.canvas.pendingDropTarget(), - danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(), - edgeDrawn: (edgeId: string) => graphEditor.canvas.edgeDrawn(edgeId), - /** The colour it was stroked in: `danger` is an edge whose two ends stopped fitting (#207). */ - edgeColor: (edgeId: string) => graphEditor.canvas.edgeColor(edgeId), - /** The types the node's ports are drawn with, after a shared group had its say (#207). */ - portTypes: (nodeId: string) => graphEditor.canvas.portTypes(nodeId), - 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), - snapping: () => graphEditor.canvas.snapEnabled, - snapButton: () => snapButton(graphEditor.snap), - zoom: () => graphEditor.canvas.zoomLevel, - crumbs: () => graphEditor.breadcrumb.crumbsDrawn(), - }, - stateMachine: { - current: () => editor.stateMachineEditor.currentAssetId, - 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, - snapButton: () => snapButton(editor.stateMachineEditor.snap), - /** Where a state's hidden pin sits; it cannot be grabbed – `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), - transitionLabelAt: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelAt(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, - info: () => editor.particlesEditor.shownInfo, - snapButton: () => snapButton(editor.particlesEditor.snap), - 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, - 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, - snapButton: () => snapButton(editor.shaderEditor.snap), - /** The panel beside the graph (#168): what it shows, its clock and the sliders' values. */ - preview: () => ({ - ...editor.shaderEditor.controls, - holding: editor.shaderEditor.preview.holding, - seconds: Math.round(editor.shaderEditor.preview.time * 100) / 100, - }), - previewRect: () => rectOf(editor.shaderEditor.preview), - /** Where the picture sits inside the panel, in screen pixels. */ - pictureRect: () => editor.shaderEditor.preview.pictureRect, - sliderAt: (key: string) => { - const slider = editor.shaderEditor.sliderOf(key); - return slider ? rectOf(slider) : null; - }, - splitterAt: () => centreOf(editor.shaderEditor.splitter), - hold: (at: number | null) => editor.shaderEditor.hold(at), - }, - /** 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, + ...uiHooks(context), + ...sceneHooks(context), + ...graphHooks(context), + ...documentHooks(context), + ...pixelArtHooks(context), + ...vectorArtHooks(context), + ...renderHooks(context), /** * 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 @@ -494,421 +43,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E 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, - /** 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(), - /** Whether a press edits the selected frame's collision outline (#141). */ - collisionMode: () => textureEditor.canvas.collisionMode, - /** The open "Generate collision…" dialog (#161), or null. */ - generateDialog: () => textureEditor.generateDialog.current()?.info ?? null, - }, - /** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */ - /** The Pixel art tab (#130): where its controls are, what it paints with, and the asset's pixels. */ - pixelArt: { - current: () => editor.pixelArtEditor.currentAssetId, - info: () => editor.pixelArtEditor.shownInfo, - /** Where the centre of canvas pixel (x, y) is on screen. */ - pixelAt: (x: number, y: number) => editor.pixelArtEditor.canvas.pixelToScreen(x, y), - zoom: () => editor.pixelArtEditor.canvas.zoom, - tool: () => editor.pixelArtEditor.canvas.tool, - color: () => editor.pixelArtEditor.canvas.color, - frameId: () => editor.pixelArtEditor.canvas.frameId, - layerId: () => editor.pixelArtEditor.canvas.layerId, - /** A control of the side panel or the header, scrolled into view: its centre and whether it is enabled. */ - control: (name: string) => { - const side = editor.pixelArtEditor.side; - const byName: Record = { - brush: side.brushButton, - filled: side.filledBox, - contiguous: side.contiguousBox, - color: side.colorField, - addColor: side.addColorButton, - removeColor: side.removeColorButton, - createPalette: side.createPaletteButton, - onion: side.onionBox, - tiles: side.tilesBox, - fps: side.fpsField, - palette: side.palette, - preview: side.preview, - resize: editor.pixelArtEditor.resizeButton, - ...Object.fromEntries([...side.toolButtons].map(([tool, button]) => [tool, button])), - ...Object.fromEntries(Object.entries(side.layerButtons).map(([k, b]) => [`layer:${k}`, b])), - }; - // The plus in a list's header is no widget of its own. - const list = name === 'layer:add' ? side.layers : name === 'frame:add' ? side.frames : null; - if (list) { - side.reveal(list); - const at = list.addGlobal(); - return at ? { ...at, enabled: true, text: '+' } : null; - } - const widget = byName[name]; - // A tool's options hide in their row (#201), so a hidden parent hides the control as well. - for (let at: Container | null = widget ?? null; at; at = at.parent) if (!at.visible) return null; - if (!widget) return null; - if (widget !== editor.pixelArtEditor.resizeButton) side.reveal(widget); - return { - ...centreOf(widget), - w: widget.w, - enabled: widget.enabled, - text: widget instanceof Button ? widget.text : '', - }; - }, - /** Where the controls of the side panel may reach, left and right. */ - sideSpan: () => editor.pixelArtEditor.side.contentSpan(), - swatch: (index: number) => { - const side = editor.pixelArtEditor.side; - side.reveal(side.palette); - return side.palette.swatchGlobal(index); - }, - /** - * The centre of a layer's or frame's row, scrolled into view, with the centre of its picture; - * `eye` is the layer's switch, `duplicate` and `remove` the marks at the end (no cross on the - * last one), `name` where a double-click edits the name, `opacity` the field with its text and - * `renaming` the field a name is being edited in. - */ - layerRow: (layerId: string) => { - const side = editor.pixelArtEditor.side; - side.reveal(side.layers); - const row = side.layers.rowGlobal(layerId); - const opacity = side.layers.valueField(layerId); - const renaming = side.layers.renameField; - // On the name: the middle of the row may be the opacity field, which a press would scrub. - const name = side.layers.nameGlobal(layerId); - return row && name - ? { - x: name.x, - y: name.y, - thumb: side.layers.thumbGlobal(layerId), - name, - eye: side.layers.eyeGlobal(layerId), - duplicate: side.layers.duplicateGlobal(layerId), - remove: side.layers.removeGlobal(layerId), - opacity: opacity ? { ...centreOf(opacity), value: opacity.value } : null, - renaming: renaming ? { ...centreOf(renaming), value: renaming.value } : null, - } - : null; - }, - frameRow: (frameId: string) => { - const side = editor.pixelArtEditor.side; - side.reveal(side.frames); - const row = side.frames.rowGlobal(frameId); - return row - ? { - x: row.x + row.w / 2, - y: row.y + row.h / 2, - thumb: side.frames.thumbGlobal(frameId), - duplicate: side.frames.duplicateGlobal(frameId), - remove: side.frames.removeGlobal(frameId), - } - : null; - }, - /** The ids as the two lists show them, top first. */ - layerRows: () => editor.pixelArtEditor.side.layers.shownIds, - frameRows: () => editor.pixelArtEditor.side.frames.shownIds, - previewIndex: () => editor.pixelArtEditor.side.preview.shownIndex, - /** Canvas, layers and frames as the model holds them. */ - asset: (assetId: string) => { - const asset = findAsset(state.project, assetId); - return asset?.type === 'pixelart' - ? (JSON.parse( - JSON.stringify({ - width: asset.width, - height: asset.height, - layers: asset.layers, - frames: asset.frames, - props: asset.props, - }), - ) as unknown) - : null; - }, - /** The palette indices of a cel, row by row; null for an empty cel, 'loading' while it loads. */ - cel: (file: string) => { - const cel = pixelCels.read(file); - return cel === undefined - ? 'loading' - : cel === null - ? null - : { width: cel.width, height: cel.height, data: [...cel.data] }; - }, - /** Cels painted here that the server does not hold yet. */ - pending: () => pixelCels.pending(state.project).length, - resizeDialog: () => { - const dialog = currentResizeDialog(); - if (!dialog) return null; - return { - ...dialog.debugInfo(), - width: centreOf(dialog.widthField), - height: centreOf(dialog.heightField), - confirm: centreOf(dialog.confirm), - anchors: Object.fromEntries( - ([0, 0.5, 1] as const).flatMap((y) => - ([0, 0.5, 1] as const).map((x) => [`${x},${y}`, dialog.anchorButton(x, y)]), - ), - ), - }; - }, - }, - vectorArt: { - current: () => vectorArtEditor.currentAssetId, - info: () => vectorArtEditor.shownInfo, - pointAt: (x: number, y: number) => vectorArtEditor.canvasToScreen(x, y), - rect: () => vectorArtEditor.canvasRect(), - tool: () => vectorArtEditor.canvas.currentTool, - snapping: () => vectorArtEditor.canvas.snapping, - snapButton: () => snapButton(vectorArtEditor.snap), - /** Whether the pen has a line open that the next click would add a point to. */ - drawing: () => vectorArtEditor.canvas.drawing, - /** The header's point type (#195): where to click it, what it says, and the type it reads. */ - pointButton: () => ({ - ...centreOf(vectorArtEditor.pointButton), - text: vectorArtEditor.pointButton.text, - enabled: vectorArtEditor.pointButton.enabled, - kind: vectorArtEditor.canvas.pickedKind, - }), - /** The shapes as the model holds them, so a check can read points and handles exactly. */ - shapes: (assetId: string) => { - const asset = findAsset(state.project, assetId); - if (!asset || asset.type !== 'vectorart') return null; - return asset.shapes.map((shape) => ({ - id: shape.id, - name: shape.name, - fill: shape.fill, - opacity: shape.opacity, - stroke: shape.stroke ? (JSON.parse(JSON.stringify(shape.stroke)) as unknown) : null, - paint: shape.paint ? (JSON.parse(JSON.stringify(shape.paint)) as unknown) : null, - rings: shape.rings.map((ring) => ({ - closed: ring.closed, - points: ring.points.map((point) => ({ - x: point.x, - y: point.y, - in: point.in ? { ...point.in } : null, - out: point.out ? { ...point.out } : null, - mode: point.mode ?? null, - })), - })), - })); - }, - selected: () => (state.selection?.kind === 'shape' ? state.selection.shapeId : ''), - selectedIds: () => state.selectedShapeIds, - /** - * The shape list in the inspector (#129, stage 4): the order it shows, top first, and where a - * row sits. A row carries a name that also stands in the tab, so a check cannot click it by text. - */ - shapeRows: () => currentShapeList()?.shownIds ?? null, - shapeRowAt: (shapeId: string) => currentShapeList()?.rowGlobal(shapeId) ?? null, - /** The cross on a shape's row. */ - shapeRemoveAt: (shapeId: string) => currentShapeList()?.removeGlobal(shapeId) ?? null, - /** The duplicate mark on a shape's row, and where a double-click edits its name. */ - shapeDuplicateAt: (shapeId: string) => currentShapeList()?.duplicateGlobal(shapeId) ?? null, - shapeNameAt: (shapeId: string) => currentShapeList()?.nameGlobal(shapeId) ?? null, - /** The field a shape's name is being edited in, with what it holds; null while none is. */ - shapeRenaming: () => { - const field = currentShapeList()?.renameField; - return field ? { ...centreOf(field), value: field.value } : null; - }, - /** The Paint tool (#186): its button, the "Handles" menu, and which part it shows. */ - paintButton: () => centreOf(vectorArtEditor.paintButton), - paintTarget: () => ({ - ...centreOf(vectorArtEditor.paintTargetButton), - text: vectorArtEditor.paintTargetButton.text, - target: vectorArtEditor.canvas.paintTarget, - }), - /** A colour stop's marker, or the bar at an offset, in the inspector's fill or outline bar. */ - stopAt: (part: 'fill' | 'stroke', index: number) => currentGradientBar(part)?.stopGlobal(index) ?? null, - barAt: (part: 'fill' | 'stroke', offset: number) => currentGradientBar(part)?.barGlobal(offset) ?? null, - }, - animation: { - current: () => editor.animationEditor.currentAssetId, - info: () => editor.animationEditor.shownInfo, - rootPickerAt: () => centreOf(editor.animationEditor.rootPicker), - addRowAt: () => centreOf(editor.animationEditor.addRowButton), - playAt: () => ({ - ...centreOf(editor.animationEditor.playButton), - text: editor.animationEditor.playButton.text, - }), - /** Where a cell, a row's header and a frame on the ruler sit – no text names a cell. */ - cellAt: (trackId: string, frame: number) => editor.animationEditor.canvas.cellGlobal(trackId, frame), - headerAt: (trackId: string) => editor.animationEditor.canvas.headerGlobal(trackId), - rulerAt: (frame: number) => editor.animationEditor.canvas.rulerGlobal(frame), - keys: () => editor.animationEditor.canvas.keysDrawn(), - rows: () => editor.animationEditor.canvas.rowsDrawn(), - view: () => editor.animationEditor.canvas.viewInfo(), - scrub: (frame: number) => editor.animationEditor.canvas.scrub(frame), - /** The open row dialog: what it offers and holds, and a way to pick without clicking through menus. */ - rowDialog: () => currentRowDialog()?.debugInfo() ?? null, - rowDialogPick: (target: string, property: string) => currentRowDialog()?.pick(target, property), - /** Copy/paste and frames (#102): what the clipboard holds, and the open frames dialog. */ - clipboard: () => structuredClone(state.keyClipboard), - framesDialog: () => currentFramesDialog()?.debugInfo() ?? null, - framesDialogCount: (count: number) => currentFramesDialog()?.setCount(count), - framesDialogConfirm: () => { - const dialog = currentFramesDialog(); - if (!dialog) return null; - const g = dialog.confirm.getGlobalPosition(); - return { x: g.x + dialog.confirm.w / 2, y: g.y + dialog.confirm.h / 2 }; - }, - /** The preview above the timeline (#99): where a row's target is drawn and with which values. */ - preview: { - /** The id the preview draws a row's target under (`''` = the root itself). */ - objectId: (target: string) => { - const asset = findAssetOf(state.project, editor.animationEditor.currentAssetId, 'animation'); - return previewObjectId(asset && findAsset(state.project, asset.root), target); - }, - rect: (objectId: string) => editor.animationEditor.preview.objectRect(objectId), - item: (objectId: string) => editor.animationEditor.preview.itemDrawn(objectId), - info: () => editor.animationEditor.preview.debugInfo(), - fitAt: () => centreOf(editor.animationEditor.fitButton), - }, - }, - tile: { - current: () => editor.tileEditor.currentAssetId, - info: () => editor.tileEditor.shownInfo, - /** What the board drew: tiles, the picture it is showing and what stands in the way. */ - board: () => editor.tileEditor.canvas.debugInfo(), - pictures: () => editor.tileEditor.strip.debugInfo(), - /** A tile of the board: its quarters and where each of them is drawn. */ - tile: (index: number) => editor.tileEditor.canvas.tileDrawn(index), - /** Which board tile a cell of the sample map became – `-1` when that cell is empty. */ - sampleTile: (row: number, column: number) => editor.tileEditor.canvas.sampleTile(row, column), - kindPickerAt: () => centreOf(editor.tileEditor.kindPicker), - addPictureAt: () => centreOf(editor.tileEditor.strip.addButton), - removePictureAt: () => centreOf(editor.tileEditor.strip.removeButton), - pictureRowAt: (index: number) => editor.tileEditor.strip.list.rowGlobal(String(index)), - fitAt: () => centreOf(editor.tileEditor.fitButton), - }, - tilemap: { - /** What the scene view painted a map with, and on which layer (#128). */ - paint: () => ({ ...state.tilemapPaint }), - /** The cells of one layer as the model holds them: `"x,y"` to tile id. */ - cells: (assetId: string, layerId: string) => { - const asset = findAssetOf(state.project, assetId, 'tilemap'); - const layer = asset && findLayer(asset, layerId); - return layer ? { ...layer.cells } : null; - }, - layers: (assetId: string) => - findAssetOf(state.project, assetId, 'tilemap')?.layers.map((layer) => ({ - id: layer.id, - name: layer.name, - hidden: layer.hidden === true, - z: layerZ(layer), - cells: Object.keys(layer.cells).length, - })) ?? null, - /** What the scene view drew: meshes (one draw call each), cells and visible layers. */ - info: (objectId: string) => viewport.tilemapInfo(objectId), - gameInfo: (objectId: string) => gameView.tilemapInfo(objectId), - /** Where the scene view outlines the map as blocking: the merged rectangles, in cells (#132). */ - solids: (objectId: string) => viewport.solidInfo(objectId), - /** - * The painting panel beside the scene view (#142): whether it is on screen, its box, the - * scene view's box next to it and its splitter – null boxes while it is hidden. - */ - panel: () => { - // The editor hides the panel itself on every other tab, so its own flag is the whole answer. - const shown = editor.tilemapPanel.visible; - return { - shown, - title: editor.tilemapPanel.title, - panel: shown ? rectOf(editor.tilemapPanel) : null, - splitter: shown ? centreOf(editor.tilemapSplitter) : null, - viewport: rectOf(viewport), - }; - }, - /** Where the widgets of the painting panel sit: their texts are on screen elsewhere too. */ - toolAt: (tool: string) => { - const button = currentTilemapWidgets()?.tools.get(tool as TilemapTool); - return button ? centreOf(button) : null; - }, - tileButtonAt: (assetId: string) => - currentTilemapWidgets()?.palette?.swatchGlobal(assetId)?.centre ?? null, - /** A tile's box in the palette: its centre, where its picture sits and the palette's columns (#139). */ - tileSwatch: (assetId: string) => { - const palette = currentTilemapWidgets()?.palette; - const swatch = palette?.swatchGlobal(assetId); - return palette && swatch ? { ...swatch, columns: palette.columns } : null; - }, - /** The plus in the layer list's header. */ - addLayerAt: () => currentTilemapWidgets()?.layers.addGlobal() ?? null, - /** - * Where the painted layer's name stands in its row – a double-click there edits it (#138) – - * with the name the row shows and the field it is being edited in, while it is. - */ - layerNameAt: () => { - const widgets = currentTilemapWidgets(); - const target = paintedTilemap(state); - if (!widgets || !target) return null; - const layerId = paintedLayerId(state, target.asset); - const at = widgets.layers.nameGlobal(layerId); - const editing = widgets.layers.renameField; - return at - ? { - ...at, - value: findLayer(target.asset, layerId)?.name ?? '', - editing: editing ? { ...centreOf(editing), value: editing.value } : null, - } - : null; - }, - /** The Z field in the painted layer's row, with what it holds (#174). */ - layerZAt: () => { - const widgets = currentTilemapWidgets(); - const target = paintedTilemap(state); - const field = widgets && target && widgets.layers.valueField(paintedLayerId(state, target.asset)); - return field ? { ...centreOf(field), value: field.value } : null; - }, - /** The duplicate mark on the painted layer's row. */ - duplicateLayerAt: () => { - const widgets = currentTilemapWidgets(); - const target = paintedTilemap(state); - return widgets && target ? widgets.layers.duplicateGlobal(paintedLayerId(state, target.asset)) : null; - }, - focusAt: () => { - const button = currentTilemapWidgets()?.focus; - return button ? { ...centreOf(button), label: button.text } : null; - }, - /** One row of the layer list, top-down as drawn: the layer, where to press it, and its eye. */ - layerRowAt: (index: number) => { - const list = currentTilemapWidgets()?.layers; - const id = list?.shownIds[index]; - // On the name: the middle of the row may be the Z field, which a press would scrub. - const name = id ? list.nameGlobal(id) : null; - if (!list || !id || !name) return null; - // `y` is the row's own line, so a check can tell whether it is inside the panel. - return { id, y: name.y, name, visible: list.eyeGlobal(id) }; - }, - /** The cross on the painted layer's row; `enabled` is false where the row has none (the last layer). */ - removeLayerAt: () => { - const widgets = currentTilemapWidgets(); - const target = paintedTilemap(state); - if (!widgets || !target) return null; - const layerId = paintedLayerId(state, target.asset); - const at = widgets.layers.removeGlobal(layerId); - const row = widgets.layers.rowGlobal(layerId); - return at ? { ...at, enabled: true } : row ? { x: row.x, y: row.y, enabled: false } : null; - }, - /** Where a cell sits on screen – for a click that paints it or a pixel that proves it. */ - cellAt: (objectId: string, x: number, y: number) => viewport.cellScreen(objectId, x, y), - gameCellAt: (objectId: string, x: number, y: number) => gameView.cellScreen(objectId, x, y), - }, - container: { - current: () => containerEditor.currentAssetId, - 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(), /** Which scene Play would start, and under which of the two rules (#183: set in the settings). */ @@ -920,151 +54,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E /** Behaviors running without Play (`Run in editor`), and how long they have been at it. */ editorRun: () => editor.editorRunInfo(), physics: () => physicsStatus(), - /** 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), - /** Centre of the type icon at the right edge of a hierarchy row. */ - hierarchyIcon: (id: string) => hierarchy.iconPosition(id), - hierarchyExpandAll: () => hierarchy.expandAll(), - hierarchyCollapseAll: () => hierarchy.collapseAll(), - pixelAt, - /** - * A horizontal run of pixels in one read. A check that has to tell a repeated pattern from a - * stretched one needs dozens of samples, and dozens of round trips would cost more than the - * step it belongs to. - */ - pixelRow: (x: number, y: number, count: number) => { - const width = Math.max(1, Math.min(1024, Math.round(count))); - const row: string[] = []; - // One extract per pixel, not one strip: a wider frame comes back resampled, and a row that - // blends its neighbours cannot tell a hard border from a soft one – which is what it is for. - for (let i = 0; i < width; i++) row.push(pixelAt(x + i, y)); - return row; - }, - /** 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, - }; - }, - /** - * A picture an asset draws itself (#129, #185): the key it is kept under, the canvas the model - * says it is, and the size it actually lies on the GPU in – which is the only place the - * supersampling of the rasteriser is visible. `kept` false means the key is not in memory, - * which is what a check reads to see an old revision let go. - */ - drawnPicture: (assetId: string) => { - const ref = resolvePicture(state.project, assetId); - if (!ref) return null; - const texture = textureLibrary.peek(ref.file); - return { - file: ref.file, - width: ref.sourceWidth, - height: ref.sourceHeight, - pixelWidth: texture?.source.pixelWidth ?? 0, - pixelHeight: texture?.source.pixelHeight ?? 0, - kept: texture !== null, - }; - }, - 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 – either kind – makes of it. */ - globalTime: () => ({ - now: gameClock.now, - scale: gameClock.scale, - effective: effectiveScale(gameClock.scale, state.paused || editor.playInfo().gamePaused), - }), - /** 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), - /** 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: TabId) => editor.showTab(tab), /** The open documents in tab order and the one showing (#171); a document is `asset:` or `list`. */