Some checks failed
Deploy to Pages / build (pull_request) Failing after 1m56s
437 lines
13 KiB
TypeScript
437 lines
13 KiB
TypeScript
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<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
|
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<string>(
|
|
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<string, unknown> =>
|
|
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<ColorReactionKey, HTMLSelectElement>();
|
|
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<string, string>,
|
|
})
|
|
.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<string, PaneContainer>();
|
|
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<string, unknown>,
|
|
[]
|
|
);
|
|
}
|
|
|
|
private addObjectBindings(
|
|
container: PaneContainer,
|
|
source: Record<string, unknown>,
|
|
path: Array<string>
|
|
): 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<string, unknown>,
|
|
`${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<string, unknown>,
|
|
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);
|
|
}
|
|
}
|