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>
363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
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, type Widget } from '../ui';
|
||
import {
|
||
GAME_LINK_ZOOMS,
|
||
type GameLinkZoom,
|
||
type PlayFrom,
|
||
WHILE_PLAYING,
|
||
type WhilePlaying,
|
||
} from '../core/model/playSettings';
|
||
import { FormDialog } from './FormDialog';
|
||
|
||
/** What the dialog hands back when Apply is pressed. */
|
||
export interface ProjectSettings {
|
||
name: string;
|
||
playFrom: PlayFrom;
|
||
whilePlaying: WhilePlaying;
|
||
gameLinkZoom: GameLinkZoom;
|
||
collisionLayers: string[];
|
||
renderLayers: string[];
|
||
symbolScale: number;
|
||
}
|
||
|
||
/**
|
||
* What Apply has to carry out. Only what actually changed, so opening the dialog and closing it
|
||
* with Apply is not an undo step: every setting that did change is one command.
|
||
*
|
||
* A blank name is not a change but a fallback to the old one – a project without a name would be
|
||
* unfindable in the picker.
|
||
*/
|
||
export function settingsChanges(
|
||
before: ProjectSettings,
|
||
after: ProjectSettings,
|
||
): {
|
||
name?: string;
|
||
collisionLayers?: string[];
|
||
renderLayers?: string[];
|
||
symbolScale?: number;
|
||
playFrom?: PlayFrom;
|
||
whilePlaying?: WhilePlaying;
|
||
gameLinkZoom?: GameLinkZoom;
|
||
} {
|
||
const name = after.name.trim() || before.name;
|
||
const symbolScale = clampSymbolScale(after.symbolScale);
|
||
return {
|
||
...(name === before.name ? {} : { name }),
|
||
...(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 }),
|
||
...(after.gameLinkZoom === before.gameLinkZoom ? {} : { gameLinkZoom: after.gameLinkZoom }),
|
||
};
|
||
}
|
||
|
||
const PLAY_FROM_LABEL: Record<PlayFrom, string> = {
|
||
start: 'Start scene',
|
||
active: 'Active scene',
|
||
};
|
||
|
||
const WHILE_PLAYING_LABEL: Record<WhilePlaying, string> = {
|
||
live: 'Live',
|
||
balanced: 'Balanced',
|
||
minimal: 'Minimal',
|
||
};
|
||
|
||
const GAME_LINK_ZOOM_LABEL: Record<GameLinkZoom, string> = {
|
||
auto: 'Auto',
|
||
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, 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 symbolScale: number;
|
||
private readonly nameField: TextField;
|
||
private readonly playFromButton: Button;
|
||
private readonly whilePlayingButton: Button;
|
||
private readonly gameLinkZoomButton: Button;
|
||
private readonly symbolScaleField: NumberField;
|
||
private readonly collisionLayers: LayerNameRows;
|
||
private readonly renderLayers: LayerNameRows;
|
||
|
||
constructor(settings: ProjectSettings, onApply: (settings: ProjectSettings) => void) {
|
||
super(340, 'Project settings');
|
||
this.projectName = settings.name;
|
||
this.playFrom = settings.playFrom;
|
||
this.whilePlaying = settings.whilePlaying;
|
||
this.gameLinkZoom = settings.gameLinkZoom;
|
||
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',
|
||
// Kept as typed, blank included: Apply is what decides, and it falls back to the old name.
|
||
onChange: (value) => {
|
||
this.projectName = String(value);
|
||
},
|
||
});
|
||
this.content.add(this.labelledRow('Name', this.nameField));
|
||
|
||
this.playFromButton = new Button(PLAY_FROM_LABEL[this.playFrom], {
|
||
width: 150,
|
||
align: 'left',
|
||
onClick: () => this.openPlayFromMenu(),
|
||
});
|
||
this.content.add(this.formRow(this.fieldLabel('Play starts'), this.playFromButton));
|
||
this.content.add(
|
||
new Label('"Start scene" is the scene marked in Assets, "Active scene" whatever is open.', {
|
||
style: 'muted',
|
||
}),
|
||
);
|
||
|
||
this.whilePlayingButton = new Button(WHILE_PLAYING_LABEL[this.whilePlaying], {
|
||
width: 150,
|
||
align: 'left',
|
||
onClick: () => this.openWhilePlayingMenu(),
|
||
});
|
||
this.content.add(this.formRow(this.fieldLabel('While playing'), this.whilePlayingButton));
|
||
this.content.add(
|
||
new Label('How often the panels follow a game: every frame, 4 times a second, or on pause.', {
|
||
style: 'muted',
|
||
}),
|
||
);
|
||
|
||
this.gameLinkZoomButton = new Button(GAME_LINK_ZOOM_LABEL[this.gameLinkZoom], {
|
||
width: 150,
|
||
align: 'left',
|
||
onClick: () => this.openGameLinkZoomMenu(),
|
||
});
|
||
this.content.add(this.formRow(this.fieldLabel('Link zoom'), this.gameLinkZoomButton));
|
||
this.content.add(
|
||
new Label('The released game at the largest whole step that fits, or filling the window.', {
|
||
style: 'muted',
|
||
}),
|
||
);
|
||
|
||
this.symbolScaleField = new NumberField(this.symbolScale, {
|
||
width: 150,
|
||
min: SYMBOL_SCALE_MIN,
|
||
max: SYMBOL_SCALE_MAX,
|
||
step: 0.01,
|
||
decimals: 2,
|
||
onInput: (value) => {
|
||
this.symbolScale = value;
|
||
},
|
||
onChange: (value) => {
|
||
this.symbolScale = value;
|
||
},
|
||
});
|
||
this.content.add(this.formRow(this.fieldLabel('Symbol scale'), this.symbolScaleField));
|
||
this.content.add(
|
||
new Label(`Size of the icons in the scene view, ${SYMBOL_SCALE_MIN} to ${SYMBOL_SCALE_MAX}.`, {
|
||
style: 'muted',
|
||
}),
|
||
);
|
||
|
||
this.content.add(new Label('Collision layers', { style: 'bodyBold' }));
|
||
this.content.add(
|
||
new Label('Two colliders meet only when each one names the layer of the other.', {
|
||
style: 'muted',
|
||
}),
|
||
);
|
||
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({
|
||
name: this.projectName,
|
||
playFrom: this.playFrom,
|
||
whilePlaying: this.whilePlaying,
|
||
gameLinkZoom: this.gameLinkZoom,
|
||
collisionLayers: this.collisionLayers.names,
|
||
renderLayers: this.renderLayers.names,
|
||
symbolScale: this.symbolScale,
|
||
}),
|
||
);
|
||
this.collisionLayers.build();
|
||
this.renderLayers.build();
|
||
}
|
||
|
||
/** The collision layer names as the fields stand (test hook). */
|
||
layerNames(): string[] {
|
||
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). */
|
||
values(): {
|
||
name: string;
|
||
playFrom: PlayFrom;
|
||
whilePlaying: WhilePlaying;
|
||
gameLinkZoom: GameLinkZoom;
|
||
symbolScale: number;
|
||
} {
|
||
return {
|
||
name: this.projectName,
|
||
playFrom: this.playFrom,
|
||
whilePlaying: this.whilePlaying,
|
||
gameLinkZoom: this.gameLinkZoom,
|
||
symbolScale: this.symbolScale,
|
||
};
|
||
}
|
||
|
||
/** The start-scene switch itself, so a check can click it. */
|
||
get playFromControl(): Button {
|
||
return this.playFromButton;
|
||
}
|
||
|
||
/** The panel preset switch, so a check can click it. */
|
||
get whilePlayingControl(): Button {
|
||
return this.whilePlayingButton;
|
||
}
|
||
|
||
/** The game link zoom switch, so a check can click it. */
|
||
get gameLinkZoomControl(): Button {
|
||
return this.gameLinkZoomButton;
|
||
}
|
||
|
||
/** The symbol scale field, so a check can type into it. */
|
||
get symbolScaleControl(): NumberField {
|
||
return this.symbolScaleField;
|
||
}
|
||
|
||
/** A label and a field that takes the rest of the row – the layer rows are built the same way. */
|
||
private labelledRow(text: string, field: TextField): HStack {
|
||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||
row.minHeight = Theme.rowHeight;
|
||
row.add(this.fieldLabel(text));
|
||
row.add(field, 1);
|
||
return row;
|
||
}
|
||
|
||
private openPlayFromMenu(): void {
|
||
const entry = (from: PlayFrom) => ({
|
||
label: PLAY_FROM_LABEL[from],
|
||
current: this.playFrom === from,
|
||
onSelect: () => {
|
||
this.playFrom = from;
|
||
this.playFromButton.setText(PLAY_FROM_LABEL[from]);
|
||
},
|
||
});
|
||
new Menu([entry('start'), entry('active')]).openBelow(this.playFromButton);
|
||
}
|
||
|
||
private openWhilePlayingMenu(): void {
|
||
new Menu(
|
||
WHILE_PLAYING.map((mode) => ({
|
||
label: WHILE_PLAYING_LABEL[mode],
|
||
current: this.whilePlaying === mode,
|
||
onSelect: () => {
|
||
this.whilePlaying = mode;
|
||
this.whilePlayingButton.setText(WHILE_PLAYING_LABEL[mode]);
|
||
},
|
||
})),
|
||
).openBelow(this.whilePlayingButton);
|
||
}
|
||
|
||
private openGameLinkZoomMenu(): void {
|
||
new Menu(
|
||
GAME_LINK_ZOOMS.map((zoom) => ({
|
||
label: GAME_LINK_ZOOM_LABEL[zoom],
|
||
current: this.gameLinkZoom === zoom,
|
||
onSelect: () => {
|
||
this.gameLinkZoom = zoom;
|
||
this.gameLinkZoomButton.setText(GAME_LINK_ZOOM_LABEL[zoom]);
|
||
},
|
||
})),
|
||
).openBelow(this.gameLinkZoomButton);
|
||
}
|
||
}
|