Show each camera only its layers and lay overlay cameras on top (#302)
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:
1 parent
f03ca0d382
commit
b2326085ed
33 files changed
+802
-115
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user