A camera could only show the whole scene, so a HUD drawn in the world would run through a camera shader (#299) with everything else. Render layers say which cameras see an object, and overlay cameras draw their own picture over the main one. - The project has a list of up to 16 render layers, edited in Project settings next to the collision layers. It is a list of its own, read by the same rules (`layerNamesOf`). - Sprites, texts, tilemaps, particle sources and containers carry a row `Render layer` under their switches. Its default `Inherit` takes the parent's layer, at the top the first one. - A camera has `Sees` (all layers by default) and `Overlay`. The main camera is the first one switched on without Overlay, so two plain cameras still draw through the first one only. - Every view through a camera keeps only what that camera sees and lays one see-through view per overlay camera over itself. That view is a `WorldViewport` of its own, handed its view and frame from below. - An overlay is fitted into the main camera's whole picture without distortion. One of the same size and zoom covers it exactly. - `z` sorts within one camera only. The pictures lie on top of each other as a whole, which is what a shader per camera (#299) needs. - The Game tab's preview now collects the scene once per redraw. Before it collected it twice, with the cameras it would have been five times. - `SetCollisionLayersCommand` became `SetLayerNamesCommand`, which takes the list it sets. Follow-ups: #304 (the mouse through a chosen camera) and #305 (change the render layer at run time). Tests: - Gate: 226 files, 3487 tests (3480 + 7) - e2e 63 (new): 4 checks. A green overlay sprite at z -10 covers a red main sprite at z 1000, in the preview and in the running game. A `UI` sprite in front of the main camera and a `Default` one in front of the overlay stay away. Without Overlay the second camera draws nothing. The settings dialog adds a render layer at the end. - Counter-checks: inheriting from parents switched off fails 1 unit test. Overlays never drawn fails the two overlay checks in e2e 63. - e2e total 1720 (1716 + 4), computed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
15 KiB
TypeScript
289 lines
15 KiB
TypeScript
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 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),
|
||
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(),
|
||
},
|
||
};
|
||
}
|