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 { 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[]; 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[]; 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] }), ...(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 = { 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', }; /** * 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. */ 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; 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.layers = [...settings.collisionLayers]; this.symbolScale = clampSymbolScale(settings.symbolScale); 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.rows); this.addButton = new Button('Add layer', { onClick: () => this.addLayer() }); this.content.add(this.formRow(this.addButton)); this.addButtons('Apply', 'primary', () => onApply({ name: this.projectName, playFrom: this.playFrom, whilePlaying: this.whilePlaying, gameLinkZoom: this.gameLinkZoom, collisionLayers: this.layers, symbolScale: this.symbolScale, }), ); this.buildLayers(); } /** The names as the fields stand (test hook). */ layerNames(): string[] { return [...this.layers]; } /** 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); } 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); } }