import { Pane, type BindingParams, type FolderApi } from 'tweakpane'; import { appConfig, type GardenRuntimeSettings, type NumberControlConfig, } from '../config'; import { activeVibe, settings } from '../settings'; import { VIBE_PRESETS } from '../vibes'; type PaneContainer = Pick; type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number]; const colorReactionRows = [ { colorIndex: 0, label: '1', keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'], }, { colorIndex: 1, label: '2', keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'], }, { colorIndex: 2, label: '3', keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'], }, ] as const; const colorReactionKeySet = new Set( colorReactionRows.flatMap((row) => [...row.keys]) ); const isColorReactionKey = (key: string): key is ColorReactionKey => colorReactionKeySet.has(key); interface ConfigPaneOptions { onConfigChange: () => void; onOpenChange?: (isOpen: boolean) => void; onRestart: () => void; onRuntimeChange: () => void; onRuntimeReset: () => void; onVibeChange: (vibeId: string) => void; settingsButton: HTMLButtonElement; } const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null; const isBindablePrimitive = (value: unknown): value is boolean | number | string => ['boolean', 'number', 'string'].includes(typeof value); const isColorString = (value: unknown): value is string => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value); const toLabel = (value: string): string => value .replace(/\[(\d+)\]/g, ' $1') .replace(/([A-Z])/g, ' $1') .replace(/[-_]/g, ' ') .replace(/\s+/g, ' ') .trim(); const normalizeNumber = (value: number, config: NumberControlConfig): number => { if (config.options) { const optionValues = Object.values(config.options); if (optionValues.includes(value)) { return value; } return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min); } const finiteValue = Number.isFinite(value) ? value : config.min; const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue)); return config.integer ? Math.round(clampedValue) : clampedValue; }; const getNumberBindingParams = ( key: keyof GardenRuntimeSettings & string, config: NumberControlConfig ): BindingParams => ({ label: config.label ?? toLabel(key), min: config.min, max: config.max, options: config.options, step: config.step, }); export class ConfigPane { private readonly container: HTMLDivElement; private readonly pane: Pane; private readonly colorReactionSelects = new Map(); private readonly colorReactionSwatches: Array<{ colorIndex: number; element: HTMLElement; }> = []; private readonly state = { activeVibeId: activeVibe.id, }; public constructor(private readonly options: ConfigPaneOptions) { this.container = document.createElement('div'); this.container.className = 'config-pane-container'; Object.assign(this.container.style, { boxSizing: 'border-box', maxHeight: 'calc(100vh - 24px)', pointerEvents: 'none', position: 'fixed', right: 'max(12px, env(safe-area-inset-right, 0px))', top: 'max(12px, env(safe-area-inset-top, 0px))', width: 'min(420px, calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)))', zIndex: '20', }); document.body.appendChild(this.container); this.pane = new Pane({ container: this.container, title: appConfig.tuningPane.title, expanded: true, }); this.pane.hidden = appConfig.tuningPane.startHidden; this.pane.element.classList.add('config-pane'); this.pane.element.style.boxSizing = 'border-box'; this.pane.element.style.maxHeight = 'calc(100vh - 24px)'; this.pane.element.style.overflowY = 'auto'; this.pane.element.style.pointerEvents = 'auto'; this.pane.element.style.width = '100%'; this.options.settingsButton.addEventListener('click', this.toggle); const tabs = this.pane.addTab({ pages: [{ title: 'Runtime' }, { title: 'Config' }], }); this.setUpRuntimeTab(tabs.pages[0]); this.setUpConfigTab(tabs.pages[1]); this.syncOpenState(); } public get isOpen(): boolean { return !this.pane.hidden; } public refresh(): void { this.state.activeVibeId = activeVibe.id; this.pane.refresh(); this.syncColorReactionMatrix(); this.syncOpenState(); } private readonly toggle = () => { this.pane.hidden = !this.pane.hidden; this.syncOpenState(); }; private setHidden(isHidden: boolean): void { this.pane.hidden = isHidden; this.syncOpenState(); } private setUpRuntimeTab(container: PaneContainer): void { container .addBinding(this.state, 'activeVibeId', { label: 'active vibe', options: Object.fromEntries( VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id]) ) as Record, }) .on('change', ({ value }) => { this.options.onVibeChange(value); this.refresh(); }); container .addButton({ title: 'Reset runtime settings', }) .on('click', () => { this.options.onRuntimeReset(); this.refresh(); }); container .addButton({ title: 'Restart simulation', }) .on('click', () => this.options.onRestart()); const folders = new Map(); let hasAddedColorReactionMatrix = false; Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => { const settingKey = key as keyof GardenRuntimeSettings & string; settings[settingKey] = normalizeNumber(settings[settingKey], config); if (isColorReactionKey(key)) { if (!hasAddedColorReactionMatrix) { this.addColorReactionMatrix(container); hasAddedColorReactionMatrix = true; } return; } const folder = folders.get(config.folder) ?? container.addFolder({ title: config.folder, expanded: config.folder !== 'Runtime', }); folders.set(config.folder, folder); folder .addBinding(settings, settingKey, getNumberBindingParams(settingKey, config)) .on('change', () => { const nextValue = normalizeNumber(settings[settingKey], config); if (nextValue !== settings[settingKey]) { settings[settingKey] = nextValue; this.pane.refresh(); } this.options.onRuntimeChange(); }); }); this.syncColorReactionMatrix(); } private addColorReactionMatrix(container: PaneContainer): void { const folder = container.addFolder({ title: 'Color Reactions', expanded: true, }); folder.element.classList.add('color-reaction-folder'); const content = Array.from(folder.element.children).find((child) => child.classList.contains('tp-fldv_c') ); if (!(content instanceof HTMLElement)) { return; } const doc = folder.element.ownerDocument; const matrix = doc.createElement('div'); matrix.className = 'color-reaction-matrix'; matrix.appendChild(this.createColorReactionCorner(doc)); colorReactionRows.forEach((row) => { matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label)); }); colorReactionRows.forEach((row) => { matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label)); row.keys.forEach((key, columnIndex) => { matrix.appendChild( this.createColorReactionCell(doc, key, row.colorIndex, columnIndex) ); }); }); content.appendChild(matrix); this.syncColorReactionMatrix(); } private createColorReactionCorner(doc: Document): HTMLDivElement { const corner = doc.createElement('div'); corner.className = 'color-reaction-matrix__corner'; corner.textContent = 'agent'; return corner; } private createColorReactionHeader( doc: Document, colorIndex: number, label: string ): HTMLDivElement { const header = doc.createElement('div'); header.className = 'color-reaction-matrix__header'; const swatch = doc.createElement('span'); swatch.className = 'color-reaction-matrix__swatch'; this.colorReactionSwatches.push({ colorIndex, element: swatch }); header.appendChild(swatch); const text = doc.createElement('span'); text.textContent = label; header.appendChild(text); return header; } private createColorReactionCell( doc: Document, key: ColorReactionKey, sourceColorIndex: number, targetColorIndex: number ): HTMLLabelElement { const cell = doc.createElement('label'); cell.className = 'color-reaction-matrix__cell'; const select = doc.createElement('select'); select.setAttribute( 'aria-label', `Color ${sourceColorIndex + 1} agents reacting to color ${targetColorIndex + 1}` ); const config = appConfig.runtimeSettings.controls[key]; Object.entries(config.options ?? {}).forEach(([label, value]) => { const option = doc.createElement('option'); option.value = String(value); option.textContent = label; select.appendChild(option); }); select.addEventListener('change', () => { settings[key] = normalizeNumber(Number(select.value), config); select.value = String(settings[key]); this.options.onRuntimeChange(); }); this.colorReactionSelects.set(key, select); cell.appendChild(select); return cell; } private syncColorReactionMatrix(): void { this.colorReactionSelects.forEach((select, key) => { const config = appConfig.runtimeSettings.controls[key]; settings[key] = normalizeNumber(settings[key], config); select.value = String(settings[key]); }); this.colorReactionSwatches.forEach(({ colorIndex, element }) => { element.style.backgroundColor = activeVibe.colors[colorIndex] ?? '#ffffff'; }); } private setUpConfigTab(container: PaneContainer): void { this.addObjectBindings( container, appConfig as unknown as Record, [] ); } private addObjectBindings( container: PaneContainer, source: Record, path: Array ): void { Object.entries(source).forEach(([key, value]) => { if (isBindablePrimitive(value)) { this.addPrimitiveBinding(container, source, key, value); return; } if (Array.isArray(value)) { const folder = container.addFolder({ title: toLabel(`${key}[]`), expanded: path.length < appConfig.tuningPane.expandedDepth, }); value.forEach((item, index) => { if (isBindablePrimitive(item)) { this.addPrimitiveBinding( folder, value as unknown as Record, `${index}`, item ); return; } if (isPlainObject(item)) { this.addObjectBindings( folder.addFolder({ title: `[${index}]`, expanded: false, }), item, [...path, key, String(index)] ); } }); return; } if (isPlainObject(value)) { this.addObjectBindings( container.addFolder({ title: toLabel(key), expanded: path.length < appConfig.tuningPane.expandedDepth, }), value, [...path, key] ); } }); } private addPrimitiveBinding( container: PaneContainer, source: Record, key: string, value: boolean | number | string ): void { const params: BindingParams = { label: toLabel(key), ...(isColorString(value) ? { color: { type: 'int' } } : {}), ...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}), }; container .addBinding(source, key, params) .on('change', () => this.options.onConfigChange()); } private syncButton(): void { this.options.settingsButton.classList.toggle('active', this.isOpen); this.options.settingsButton.setAttribute('aria-expanded', String(this.isOpen)); this.options.settingsButton.setAttribute( 'aria-label', this.isOpen ? 'Hide config overlay' : 'Show config overlay' ); this.options.settingsButton.title = this.isOpen ? 'Hide config overlay' : 'Show config overlay'; } private syncOpenState(): void { this.syncButton(); this.options.onOpenChange?.(this.isOpen); } public close(): void { this.setHidden(true); } }