import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers'; import { clampSymbolScale, SYMBOL_SCALE_MAX, SYMBOL_SCALE_MIN } from '../core/model/symbolScale'; import { clampMasterVolume } from '../core/model/master'; 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; masterVolume: number; masterFx: string; } /** A sound FX the master can be given: its id and its name. */ export interface FxChoice { id: string; name: string; } /** * 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; masterVolume?: number; masterFx?: string; } { const name = after.name.trim() || before.name; const symbolScale = clampSymbolScale(after.symbolScale); const masterVolume = clampMasterVolume(after.masterVolume); 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 }), ...(masterVolume === before.masterVolume ? {} : { masterVolume }), ...(after.masterFx === before.masterFx ? {} : { masterFx: after.masterFx }), }; } const PLAY_FROM_LABEL: Record = { start: 'Start scene', active: 'Active scene', }; const WHILE_PLAYING_LABEL: Record = { live: 'Live', balanced: 'Balanced', minimal: 'Minimal', }; const GAME_LINK_ZOOM_LABEL: Record = { 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), the render layers (#302) and the master bus * (#325). 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 masterVolume: number; private masterFx: string; private readonly nameField: TextField; private readonly playFromButton: Button; private readonly whilePlayingButton: Button; private readonly gameLinkZoomButton: Button; private readonly symbolScaleField: NumberField; private readonly masterVolumeField: NumberField; private readonly masterFxButton: Button; private readonly collisionLayers: LayerNameRows; private readonly renderLayers: LayerNameRows; constructor( settings: ProjectSettings, onApply: (settings: ProjectSettings) => void, private readonly fxChoices: readonly FxChoice[] = [], ) { 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); this.masterVolume = clampMasterVolume(settings.masterVolume); this.masterFx = settings.masterFx; // 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('Master', { style: 'bodyBold' })); this.content.add(new Label('Where every bus ends, and every sound that names none.', { style: 'muted' })); this.masterVolumeField = new NumberField(this.masterVolume, { width: 150, min: 0, max: 1, step: 0.05, decimals: 2, onInput: (value) => { this.masterVolume = value; }, onChange: (value) => { this.masterVolume = value; }, }); this.content.add(this.formRow(this.fieldLabel('Volume'), this.masterVolumeField)); this.masterFxButton = new Button(this.fxLabel(this.masterFx), { width: 150, align: 'left', onClick: () => this.openMasterFxMenu(), }); this.content.add(this.formRow(this.fieldLabel('Sound FX'), this.masterFxButton)); 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, masterVolume: this.masterVolume, masterFx: this.masterFx, }), ); 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; masterVolume: number; masterFx: string; } { return { name: this.projectName, playFrom: this.playFrom, whilePlaying: this.whilePlaying, gameLinkZoom: this.gameLinkZoom, symbolScale: this.symbolScale, masterVolume: this.masterVolume, masterFx: this.masterFx, }; } /** 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; } /** The master volume field and the master FX switch, so a check can use them. */ get masterVolumeControl(): NumberField { return this.masterVolumeField; } get masterFxControl(): Button { return this.masterFxButton; } /** 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); } /** A sound FX by its name; one that is gone shows its id, like a dangling pick in the inspector. */ private fxLabel(id: string): string { if (id === '') return 'None'; return this.fxChoices.find((choice) => choice.id === id)?.name ?? `${id}?`; } private openMasterFxMenu(): void { const entry = (id: string, label: string) => ({ label, current: this.masterFx === id, onSelect: () => { this.masterFx = id; this.masterFxButton.setText(this.fxLabel(id)); }, }); new Menu([entry('', 'None'), ...this.fxChoices.map((choice) => entry(choice.id, choice.name))]).openBelow( this.masterFxButton, ); } }