Files
feuerwerk/src/editor/debug/sceneHooks.ts
T
adminandClaude Opus 5.5 77f4c2c1a3
game-maker / gate (push) Successful in 2m35s
game-maker / suite (push) Successful in 11m44s
Show entities and time per area while playing in the Game View (#275)
A game that stutters gave no hint where the frame goes. The Game tab
now has a Performance switch. It shows a small overlay in the top left
corner of the Game View while a game runs: fps, the number of entities
and the mean milliseconds per frame for each area, averaged over half
a second like the fps counter.

- src/editor/frameMeter.ts holds the pure rule. FrameMeter sums the
  areas per frame, closes a window every 0.5 s and formats the lines.
- World.timeSystem times every system the world ticks. PlaySession
  maps the systems to Behaviors, Physics and Animation and sets the
  hook again after Load scene.
- Animation has its own line. Counting the AnimationSystem under
  Behaviors would have skewed that number.
- Drawing is the build of the game picture plus app.render(). It is
  main thread time only. The GPU work after it is not in the number,
  and app.render() draws the whole canvas including the panels.
- Editor is the rest of the editor tick after the systems and the game
  picture: mirroring into the model, panels and console.
- The overlay lets clicks through to the game. It shows dashes until
  the first window closes. The switch is off by default and is not
  saved. The released game (#266) runs without a meter.

Tests:
- Unit frame-meter: 9 passed.
- Gate on melektaus-atx: 197 files, 2853 tests, CHECK EXIT 0.
- e2e 59 on melektaus-atx: ALL PASSED, 18 checks (13 before, +5 in
  59e with a real mouse on the switch). Run twice.
- Three mutations were caught: the editor share not subtracting the
  systems (2 unit tests), systems running in pause not reported
  (1 unit test), the overlay getting no times (59e).
- The e2e total of 1601 is computed (1596 + 5), not measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:44:53 +02:00

277 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { findAssetOf, findObjectHolder } from '../../core/model/Project';
import { resolveObjects } from '../../core/model/instance';
import { findLayer, layerZ } from '../../core/model/tilemap';
import { currentVisibilityPopup } from '../panels/SceneVisibilityPopup';
import { currentTilemapWidgets, paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
import type { TilemapTool } from '../state';
import { rectOf } from '../textProbe';
import { centreOf, type HookContext, shownOnStage } 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, gamePane, scenePane, 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<typeof rect> => rect !== null)
: [];
const clear = (x: number, y: number) =>
!taken.some((r) => x >= r.x - 12 && x <= r.x + r.w + 12 && y >= r.y - 12 && y <= r.y + r.h + 12);
for (let y = p.y + viewport.h - 24; y > p.y + 24; y -= 24) {
for (let x = p.x + 24; x < p.x + viewport.w - 24; x += 24) if (clear(x, y)) return { x, y };
}
return null;
},
/** Where to click Fit or the eye in the scene tab's header. */
sceneHeaderAt: (name: 'fit' | 'visibility') =>
centreOf(name === 'fit' ? scenePane.fitButton : scenePane.visibilityButton),
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 size gizmo on the selected sprite or collider, in global pixels (#223). */
sizeGizmo: () => viewport.sizeGizmoInfo(),
/** 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),
/** The names shown beside the symbols, each with the plate under it, in world units and on screen. */
sceneLabels: () => viewport.labelInfo(),
/** A collider's outline in the Scene tab: hidden by the eye in its header, it is neither seen nor hit. */
sceneShape: (id: string) => viewport.shapeInfo(id),
/** The switches behind the eye while it stands open: per type and part, on, enabled, where to click. */
visibilityPopup: () =>
currentVisibilityPopup()
?.info()
.map(({ widget, ...entry }) => ({ ...entry, ...centreOf(widget) })) ?? null,
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 eyedropper of an object's place (#276): whether it picks, what it fills, its candidate and
* the object outlined as that in the view, the bar over the view with its two buttons, the tab
* showing – and how the last one ended.
*/
objectPick: () => {
const pick = state.objectPick;
const strip = [scenePane.pickStrip, containerEditor.pickStrip].find((s) => shownOnStage(s));
return {
picking: pick !== null,
name: pick?.name ?? null,
candidate: pick?.candidate ?? null,
outline: viewport.pickOutline() ?? containerEditor.canvas.pickOutline(),
strip: strip
? { text: strip.shownText, take: centreOf(strip.takeButton), cancel: centreOf(strip.cancelButton) }
: null,
tab: editor.tab,
ended: state.pickEnd,
};
},
/** 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),
/** The game view's scale mode and what it came to, the view's box and the picker in its header (#225). */
gameScale: () => {
const origin = gameView.getGlobalPosition();
return {
mode: gameView.scaleMode,
...gameView.scaleInfo(),
view: { x: origin.x, y: origin.y, w: gameView.w, h: gameView.h },
label: gamePane.scalePicker.text,
picker: centreOf(gamePane.scalePicker),
};
},
/**
* Fullscreen of the game view (#267): whether the view is moved to the root, whether the browser
* is in fullscreen, the header button, and the corner button while it shows.
*/
gameFullscreen: () => ({
on: gameView.isFullscreen,
document: document.fullscreenElement !== null,
headerButton: shownOnStage(gamePane.fullscreenButton) ? centreOf(gamePane.fullscreenButton) : null,
corner: shownOnStage(gameView.fullscreenButton) ? centreOf(gameView.fullscreenButton) : null,
}),
/**
* The performance overlay (#275): the header switch, whether the overlay is on screen, where,
* and the lines it shows. The numbers behind them are in `playInfo().performance`.
*/
gamePerformance: () => {
const overlay = gameView.performanceOverlay;
const origin = overlay.getGlobalPosition();
const button = gamePane.performanceButton;
return {
button: { ...centreOf(button), lit: button.toggled },
shown: shownOnStage(overlay),
box: { x: origin.x, y: origin.y, w: overlay.width, h: overlay.height },
view: rectOf(gameView),
lines: overlay.lines,
};
},
/** 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),
sortByY: layer.sortByY === true,
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 Sort by Y box of the painted layer (#228), with whether it is ticked. */
sortByYAt: () => {
const box = currentTilemapWidgets()?.sortByY;
return box ? { ...centreOf(box), checked: box.checked } : 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),
objectRect: (id: string) => containerEditor.objectRect(id),
dropPreview: () => containerEditor.dropInfo(),
},
};
}