import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers'; import { Button, HStack, Label, Menu, TextField, Theme, VStack } from '../ui'; import type { PlayFrom } from './settings'; import { FormDialog } from './FormDialog'; /** What the dialog hands back when Apply is pressed. */ export interface ProjectSettings { name: string; playFrom: PlayFrom; collisionLayers: 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: a rename and a change of the layers are one command each, while * the start-scene switch is a preference of this browser and no command at all. * * 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[]; playFrom: PlayFrom } { const name = after.name.trim() || before.name; return { ...(name === before.name ? {} : { name }), ...(JSON.stringify(after.collisionLayers) === JSON.stringify(before.collisionLayers) ? {} : { collisionLayers: [...after.collisionLayers] }), playFrom: after.playFrom, }; } const PLAY_FROM_LABEL: Record = { start: 'Start scene', active: 'Active scene', }; /** * Everything about the project that is not an asset (#183): its name, which scene Play starts, and * the collision layers (#152). The top bar carries icons only, so this is the one place the three * 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 layers: string[]; private readonly nameField: TextField; private readonly playFromButton: Button; 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.layers = [...settings.collisionLayers]; 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.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, collisionLayers: this.layers, }), ); this.buildLayers(); } /** The names as the fields stand (test hook). */ layerNames(): string[] { return [...this.layers]; } /** Name and start-scene switch as they stand (test hook). */ values(): { name: string; playFrom: PlayFrom } { return { name: this.projectName, playFrom: this.playFrom }; } /** The start-scene switch itself, so a check can click it. */ get playFromControl(): Button { return this.playFromButton; } /** 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: this.playFrom === from ? `● ${PLAY_FROM_LABEL[from]}` : PLAY_FROM_LABEL[from], onSelect: () => { this.playFrom = from; this.playFromButton.setText(PLAY_FROM_LABEL[from]); }, }); new Menu([entry('start'), entry('active')]).openBelow(this.playFromButton); } 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, 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); } }