Show each camera only its layers and lay overlay cameras on top (#302)
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m26s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 15:32:52 +02:00
1 parent f03ca0d382
commit b2326085ed
33 files changed
+802 -115

No files matched your search

+6 -3
View File
@@ -3,8 +3,8 @@
*/
import {
RenameProjectCommand,
SetCollisionLayersCommand,
SetGameLinkZoomCommand,
SetLayerNamesCommand,
SetPlayFromCommand,
SetSymbolScaleCommand,
SetWhilePlayingCommand,
@@ -323,7 +323,7 @@ export class ProjectSession {
/**
* Name, the scene Play starts, how closely the panels follow it, the zoom of the game link, the
* symbol scale and the collision layers (#152) in one dialog
* symbol scale, the collision layers (#152) and the render layers (#302) in one dialog
* (#183). Each is an undo step of its own – one each, and only for what actually changed.
*/
openProjectSettings(): ProjectSettingsDialog {
@@ -334,6 +334,7 @@ export class ProjectSession {
whilePlaying: project.whilePlaying,
gameLinkZoom: project.gameLinkZoom,
collisionLayers: [...project.collisionLayers],
renderLayers: [...project.renderLayers],
symbolScale: project.symbolScale,
};
return this.settingsDialog.open(
@@ -341,7 +342,9 @@ export class ProjectSession {
const change = settingsChanges(before, settings);
if (change.name !== undefined) this.state.execute(new RenameProjectCommand(change.name));
if (change.collisionLayers !== undefined)
this.state.execute(new SetCollisionLayersCommand(change.collisionLayers));
this.state.execute(new SetLayerNamesCommand('collisionLayers', change.collisionLayers));
if (change.renderLayers !== undefined)
this.state.execute(new SetLayerNamesCommand('renderLayers', change.renderLayers));
if (change.symbolScale !== undefined)
this.state.execute(new SetSymbolScaleCommand(change.symbolScale));
if (change.playFrom !== undefined) this.state.execute(new SetPlayFromCommand(change.playFrom));
+100 -63
View File
@@ -1,6 +1,6 @@
import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers';
import { clampSymbolScale, SYMBOL_SCALE_MAX, SYMBOL_SCALE_MIN } from '../core/model/symbolScale';
import { Button, HStack, Label, Menu, NumberField, TextField, Theme, VStack } from '../ui';
import { Button, HStack, Label, Menu, NumberField, TextField, Theme, VStack, type Widget } from '../ui';
import {
GAME_LINK_ZOOMS,
type GameLinkZoom,
@@ -17,6 +17,7 @@ export interface ProjectSettings {
whilePlaying: WhilePlaying;
gameLinkZoom: GameLinkZoom;
collisionLayers: string[];
renderLayers: string[];
symbolScale: number;
}
@@ -33,6 +34,7 @@ export function settingsChanges(
): {
name?: string;
collisionLayers?: string[];
renderLayers?: string[];
symbolScale?: number;
playFrom?: PlayFrom;
whilePlaying?: WhilePlaying;
@@ -45,6 +47,9 @@ export function settingsChanges(
...(JSON.stringify(after.collisionLayers) === JSON.stringify(before.collisionLayers)
? {}
: { collisionLayers: [...after.collisionLayers] }),
...(JSON.stringify(after.renderLayers) === JSON.stringify(before.renderLayers)
? {}
: { renderLayers: [...after.renderLayers] }),
...(symbolScale === before.symbolScale ? {} : { symbolScale }),
...(after.playFrom === before.playFrom ? {} : { playFrom: after.playFrom }),
...(after.whilePlaying === before.whilePlaying ? {} : { whilePlaying: after.whilePlaying }),
@@ -68,30 +73,87 @@ const GAME_LINK_ZOOM_LABEL: Record<GameLinkZoom, string> = {
fit: 'Fit',
};
/**
* One list of layer names with its rows and its add button. Objects name a layer by its *number*,
* so a layer taken out of the middle would silently move every object below it onto another one;
* only the last layer can be removed, and the list is otherwise grown at the end.
*/
class LayerNameRows {
readonly names: string[];
readonly rows = new VStack({ gap: Theme.gap });
readonly addButton: Button;
constructor(
names: readonly string[],
addLabel: string,
private readonly label: (widget: string) => Widget,
private readonly changed: () => void,
) {
this.names = [...names];
this.addButton = new Button(addLabel, { onClick: () => this.add() });
}
private add(): void {
if (this.names.length >= MAX_COLLISION_LAYERS) return;
this.names.push(layerFallback(this.names.length));
this.build();
}
build(): void {
this.rows.removeChildren();
for (const [i, name] of this.names.entries()) {
const field = new TextField(name, {
onChange: (value) => {
// An empty name would leave the layer nameless, which `layerNamesOf` reads back as its
// number – so the field shows that number rather than staying blank.
const name = String(value).trim() || layerFallback(i);
this.names[i] = name;
if (name !== String(value)) field.setValue(name);
},
});
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(this.label(String(i)));
row.add(field, 1);
// Only the last one: removing any other would move every object below it onto a new layer.
if (i > 0 && i === this.names.length - 1)
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
tooltip: 'Remove layer',
onClick: () => {
this.names.pop();
this.build();
},
}),
);
this.rows.add(row);
}
this.addButton.setEnabled(this.names.length < MAX_COLLISION_LAYERS);
this.changed();
}
}
/**
* Everything about the project that is not an asset (#183): its name, which scene Play starts and
* how closely the panels follow it (#265), the zoom of the game link (#279), the
* size of the scene view's symbol icons, and the collision layers (#152). The top bar carries icons
* only, so this is the one place they are edited.
*
* Colliders name a layer by its *number*, so a layer taken out of the middle would silently move
* every collider below it onto another one; only the last layer can be removed, and the list is
* otherwise grown at the end.
* how closely the panels follow it (#265), the zoom of the game link (#279), the size of the scene
* view's symbol icons, the collision layers (#152) and the render layers (#302). The top bar carries
* icons only, so this is the one place they are edited.
*/
export class ProjectSettingsDialog extends FormDialog {
private projectName: string;
private playFrom: PlayFrom;
private whilePlaying: WhilePlaying;
private gameLinkZoom: GameLinkZoom;
private layers: string[];
private symbolScale: number;
private readonly nameField: TextField;
private readonly playFromButton: Button;
private readonly whilePlayingButton: Button;
private readonly gameLinkZoomButton: Button;
private readonly symbolScaleField: NumberField;
private readonly rows = new VStack({ gap: Theme.gap });
private readonly addButton: Button;
private readonly collisionLayers: LayerNameRows;
private readonly renderLayers: LayerNameRows;
constructor(settings: ProjectSettings, onApply: (settings: ProjectSettings) => void) {
super(340, 'Project settings');
@@ -99,8 +161,16 @@ export class ProjectSettingsDialog extends FormDialog {
this.playFrom = settings.playFrom;
this.whilePlaying = settings.whilePlaying;
this.gameLinkZoom = settings.gameLinkZoom;
this.layers = [...settings.collisionLayers];
this.symbolScale = clampSymbolScale(settings.symbolScale);
// A row more or less makes the dialog taller or shorter; `resize` only lays out on a change,
// which is exactly what happened here.
const fit = () => {
const size = this.measure();
this.resize(size.w, size.h);
};
const label = (text: string) => this.fieldLabel(text);
this.collisionLayers = new LayerNameRows(settings.collisionLayers, 'Add layer', label, fit);
this.renderLayers = new LayerNameRows(settings.renderLayers, 'Add render layer', label, fit);
this.nameField = new TextField(this.projectName, {
placeholder: 'Project name',
@@ -173,9 +243,13 @@ export class ProjectSettingsDialog extends FormDialog {
style: 'muted',
}),
);
this.content.add(this.rows);
this.addButton = new Button('Add layer', { onClick: () => this.addLayer() });
this.content.add(this.formRow(this.addButton));
this.content.add(this.collisionLayers.rows);
this.content.add(this.formRow(this.collisionLayers.addButton));
this.content.add(new Label('Render layers', { style: 'bodyBold' }));
this.content.add(new Label('A camera shows only the layers it sees.', { style: 'muted' }));
this.content.add(this.renderLayers.rows);
this.content.add(this.formRow(this.renderLayers.addButton));
this.addButtons('Apply', 'primary', () =>
onApply({
@@ -183,16 +257,23 @@ export class ProjectSettingsDialog extends FormDialog {
playFrom: this.playFrom,
whilePlaying: this.whilePlaying,
gameLinkZoom: this.gameLinkZoom,
collisionLayers: this.layers,
collisionLayers: this.collisionLayers.names,
renderLayers: this.renderLayers.names,
symbolScale: this.symbolScale,
}),
);
this.buildLayers();
this.collisionLayers.build();
this.renderLayers.build();
}
/** The names as the fields stand (test hook). */
/** The collision layer names as the fields stand (test hook). */
layerNames(): string[] {
return [...this.layers];
return [...this.collisionLayers.names];
}
/** The render layer names as the fields stand (test hook). */
renderLayerNames(): string[] {
return [...this.renderLayers.names];
}
/** Name, start-scene switch, panel preset, link zoom and symbol scale as they stand (test hook). */
@@ -278,48 +359,4 @@ export class ProjectSettingsDialog extends FormDialog {
})),
).openBelow(this.gameLinkZoomButton);
}
private addLayer(): void {
if (this.layers.length >= MAX_COLLISION_LAYERS) return;
this.layers.push(layerFallback(this.layers.length));
this.buildLayers();
}
private buildLayers(): void {
this.rows.removeChildren();
for (const [i, name] of this.layers.entries()) {
const field = new TextField(name, {
onChange: (value) => {
// An empty name would leave the layer nameless, which `collisionLayersOf` reads back as
// its number – so the field shows that number rather than staying blank.
const name = String(value).trim() || layerFallback(i);
this.layers[i] = name;
if (name !== String(value)) field.setValue(name);
},
});
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(this.fieldLabel(String(i)));
row.add(field, 1);
// Only the last one: removing any other would move every collider below it onto a new layer.
if (i > 0 && i === this.layers.length - 1)
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
tooltip: 'Remove layer',
onClick: () => {
this.layers.pop();
this.buildLayers();
},
}),
);
this.rows.add(row);
}
this.addButton.setEnabled(this.layers.length < MAX_COLLISION_LAYERS);
// A row more or less makes the dialog taller or shorter; `resize` only lays out on a change,
// which is exactly what happened here.
const size = this.measure();
this.resize(size.w, size.h);
}
}
+10
View File
@@ -107,6 +107,16 @@ export function sceneHooks({ state, editor }: HookContext) {
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();
+5
View File
@@ -291,6 +291,11 @@ export function uiHooks({ app, state, editor }: HookContext) {
project: [...state.project.collisionLayers],
dialog: editor.projectSettingsDialog()?.layerNames() ?? null,
}),
/** The same for the render layers (#302). */
renderLayers: () => ({
project: [...state.project.renderLayers],
dialog: editor.projectSettingsDialog()?.renderLayerNames() ?? null,
}),
/** The "Project settings" dialog (#183): name, start-scene switch and symbol scale as its fields stand. */
projectSettings: () => editor.projectSettingsDialog()?.values() ?? null,
/** Where to click the start-scene switch inside that dialog. */
+4 -1
View File
@@ -29,6 +29,7 @@ import {
type VariableOwnerAsset,
type VariablesAsset,
} from '../../core/model/Asset';
import { RENDER_LAYER_KEY } from '../../core/model/renderLayers';
import {
MakeListItemTypeCommand,
MakeStructTypeCommand,
@@ -181,7 +182,9 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole
return true;
}
const isSwitch = (key: string): boolean => COMMON_PROPS.some((def) => def.key === key);
/** The switches, and the render layer (#302): both say whether the object is seen at all. */
const isSwitch = (key: string): boolean =>
key === RENDER_LAYER_KEY || COMMON_PROPS.some((def) => def.key === key);
/** The rows of `schema` that are shown, under `title` – which is left out with them. */
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[], title: string): void {
+12 -6
View File
@@ -9,6 +9,7 @@ import {
maskLabel,
maskWith,
} from '../../core/model/collisionLayers';
import { INHERIT_LABEL, INHERIT_LAYER, ownRenderLayer } from '../../core/model/renderLayers';
import {
assetChoiceLabel,
assetRefChoices,
@@ -634,18 +635,23 @@ export function createField(
}
case 'layer': {
// The names live in the project, not in the row: renaming a layer renames it everywhere.
const names = () => ctx.project().collisionLayers;
const labelOf = (v: PropertyValue) => layerName(names(), layerIndexOf(v));
const render = def.renderLayers === true;
const names = () => (render ? ctx.project().renderLayers : ctx.project().collisionLayers);
const labelOf = (v: PropertyValue) => {
if (!render) return layerName(names(), layerIndexOf(v));
const own = ownRenderLayer(v);
return own === INHERIT_LAYER ? INHERIT_LABEL : layerName(names(), own);
};
const button = new Dropdown(labelOf(value));
button.onClick = () => {
new Menu(names().map((name, i) => ({ label: name, onSelect: () => handlers.onChange(i) }))).openBelow(
button,
);
const layers = names().map((name, i) => ({ label: name, onSelect: () => handlers.onChange(i) }));
const inherit = { label: INHERIT_LABEL, onSelect: () => handlers.onChange(INHERIT_LAYER) };
new Menu(render ? [inherit, ...layers] : layers).openBelow(button);
};
return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) };
}
case 'layers': {
const names = () => ctx.project().collisionLayers;
const names = () => (def.renderLayers ? ctx.project().renderLayers : ctx.project().collisionLayers);
const labelOf = (v: PropertyValue) => maskLabel(layerMaskOf(v), names());
let mask = layerMaskOf(value);
const button = new Dropdown(labelOf(value));
+3
View File
@@ -9,6 +9,7 @@ import type { ValuePath } from '../../core/properties/valuePath';
import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands';
import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap';
import { findUsages } from '../../core/model/usages';
import { RENDER_LAYER_KEY } from '../../core/model/renderLayers';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
@@ -177,6 +178,8 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
if (asset.type === 'behavior')
for (const def of schemaFor('behavior').filter((d) => !isCommonProp(d.key)))
propertyRow(host, holderId, objectId, asset, def, resolved);
for (const def of schemaForObject(project, asset, object).filter((d) => d.key === RENDER_LAYER_KEY))
propertyRow(host, holderId, objectId, asset, def, resolved);
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
for (const axis of POSITION_AXES) {
+30 -3
View File
@@ -6,7 +6,13 @@ import { isSymbolType } from '../../core/model/AssetTypes';
import type { EditorState } from '../state';
import type { World } from '../../runtime/World';
import { requestRender, Theme, textStyle, ui, crispText } from '../../ui';
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
import {
type ActiveCamera,
cameraOfItems,
cameraOfWorld,
overlaysOfItems,
overlaysOfWorld,
} from '../../viewport/activeCamera';
import { FullscreenCorner } from '../../viewport/FullscreenCorner';
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
import { routePointer } from '../../viewport/gamePointer';
@@ -35,6 +41,9 @@ export class GameViewport extends WorldViewport<EditorState> {
private performanceShown = false;
/** Milliseconds spent in `redraw` so far; the meter takes the difference over a frame. */
drawTime = 0;
/** The model collected once per redraw of the preview: view, cameras and items all read it. */
private redrawFrame: Frame | null = null;
private redrawing = false;
constructor(state: EditorState) {
super(state);
@@ -107,7 +116,13 @@ export class GameViewport extends WorldViewport<EditorState> {
override redraw(): void {
if (!this.visible) return;
const start = performance.now();
super.redraw();
this.redrawing = true;
try {
super.redraw();
} finally {
this.redrawing = false;
this.redrawFrame = null;
}
this.drawTime += performance.now() - start;
}
@@ -133,10 +148,22 @@ export class GameViewport extends WorldViewport<EditorState> {
return world ? cameraOfWorld(world) : cameraOfItems(this.modelFrame().items);
}
protected override mainCamera(): ActiveCamera | null {
return this.activeCamera();
}
protected override overlayCameras(): ActiveCamera[] {
const world = this.playWorld;
return world ? overlaysOfWorld(world) : overlaysOfItems(this.modelFrame().items);
}
private modelFrame(): Frame {
if (this.redrawFrame) return this.redrawFrame;
const state = this.state;
const background = Theme.colors.background;
return collectHolderItems(state.project, state.activeScene, background, this.editorWorld);
const frame = collectHolderItems(state.project, state.activeScene, background, this.editorWorld);
if (this.redrawing) this.redrawFrame = frame;
return frame;
}
/**