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