Files
feuerwerk/src/editor/ProjectSettingsDialog.ts
T
adminandClaude Opus 5.5 b2326085ed
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m26s
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>
2026-10-03 15:32:52 +02:00

363 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}