Files
feuerwerk/src/editor/debug/sceneHooks.ts
T
adminandClaude Opus 5.5 b2b4cbad28
game-maker / gate (push) Successful in 4m21s
game-maker / suite (push) Failing after 17m16s
Keep a video's loading out of recordings and first frames (#417)
A Start recording right after Play composition still began with
empty frames. The composition's clock waited for its video, but the
recording went on and took every frame while the video loaded. A
clip that starts later in the video also slipped through, because
its player already had one picture while it still sought to the
clip's start.

- The view now counts every video sprite it leaves out as missing,
  not only one without any first picture.
- GameRecorder takes no frame while the game view misses a video,
  for at most VIDEO_WAIT_MS (3 s) at a time. The recorded time
  stands and the sound of that time is dropped, so it stays in sync.
- RecordingClock.start forgets a hold from before the first frame.
  Without that, the first real frames all got the time 0.
- A followed player starts at 1x until the first speed reading.
  Before, it paused and sought every frame for about 100 ms, so the
  first pictures after a wait stuck and then jumped. A step faster
  than 4x and a time running backwards do not count as a start.
- Test hooks: __gm.gameVideo(id) reads the game view's own player,
  TakeInfo.lastFrameAt shows when the last frame was taken.

Tests:
- Unit recording (clock after an early hold), video-follow (2 new,
  1 adapted): 449 green in tests/core/recording, tests/viewport and
  tests/core/composition*
- e2e 68e (a clip from 0.8 s shows that picture first and then
  plays), 68f (a recording that already runs takes no frame while
  the composition's video loads)
- 68e is red without the start at 1x, 68f without the hold
- README: 2038 editor checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:38:37 +02:00

298 lines
15 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),
/** How often the Scene tab was drawn so far – a gesture should cost one per frame (#347). */
sceneRedraws: () => viewport.redrawCount,
/** 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(),
/** The scene view's centre and zoom, and whether a zoom transition is under way (#309). */
sceneView: () => viewport.viewState(),
/** The world point under a screen point, as the scene view shows it right now. */
sceneWorldAt: (x: number, y: number) => viewport.worldAt(x, y),
/** 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 lines of the joints in the Scene tab, in world units, and the anchor handle in global pixels (#154). */
joints: () => viewport.jointInfo(),
/** 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),
gameDrawnRect: (id: string) => gameView.drawnRect(id),
/** The player of a driven video the Game tab draws for this object, not another view's. */
gameVideo: (id: string) => gameView.videoOf(id),
gameCenter: () => centreOf(gameView),
/** What the main camera draws in the Game tab, back to front (#302). */
gameDrawOrder: () => gameView.drawOrder(),
/** The overlay cameras laid over it (#302): what each draws and its picture in global pixels. */
gameOverlays: () => {
const origin = gameView.getGlobalPosition();
return gameView.overlayInfo().map(({ rect, ...overlay }) => ({
...overlay,
rect: { ...rect, x: origin.x + rect.x, y: origin.y + rect.y },
}));
},
/** 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(),
},
};
}