Clean up configs
All checks were successful
Check & deploy / build (pull_request) Successful in 7m18s
Check & deploy / build (push) Successful in 7m6s

This commit is contained in:
Andras Schmelczer 2026-05-25 09:27:39 +01:00
commit 0fddad6b45
7 changed files with 375 additions and 652 deletions

View file

@ -1,19 +1,24 @@
import { appConfig } from '../config';
import { DEFAULT_AUDIO_VOLUME } from '../audio/garden-audio-config';
import type GameLoop from '../game-loop/game-loop';
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
import { queryRequiredElement } from '../utils/dom';
import { clamp01 } from '../utils/math';
const AUDIO_MUTED_STORAGE_KEY = 'fleeting-garden:audio-muted';
const AUDIO_VOLUME_STORAGE_KEY = 'fleeting-garden:audio-volume';
const AUDIO_VOLUME_MIN = 0;
const AUDIO_VOLUME_MAX = 1;
const AUDIO_VOLUME_STEP = 0.01;
const clampAudioVolume = (value: number): number => {
const { default: defaultVolume, max, min } = appConfig.toolbar.volume;
const safeValue = Number.isFinite(value) ? value : defaultVolume;
return Math.min(max, Math.max(min, clamp01(safeValue)));
const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
return Math.min(AUDIO_VOLUME_MAX, Math.max(AUDIO_VOLUME_MIN, clamp01(safeValue)));
};
const readInitialAudioVolume = (): number => {
const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
const storedVolume = readBrowserStorage(AUDIO_VOLUME_STORAGE_KEY);
return storedVolume === null
? appConfig.toolbar.volume.default
? DEFAULT_AUDIO_VOLUME
: clampAudioVolume(Number(storedVolume));
};
@ -45,7 +50,7 @@ export class AudioControl {
private audioVolume = readInitialAudioVolume();
private isMutedState =
readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
readBrowserStorage(AUDIO_MUTED_STORAGE_KEY) === STORED_MUTED_TRUE ||
this.audioVolume <= 0;
public constructor(private readonly options: AudioControlOptions) {
@ -85,9 +90,9 @@ export class AudioControl {
this.soundButton.setAttribute('aria-label', muteLabel);
this.soundButton.title = muteLabel;
this.volumeSlider.min = appConfig.toolbar.volume.min.toString();
this.volumeSlider.max = appConfig.toolbar.volume.max.toString();
this.volumeSlider.step = appConfig.toolbar.volume.step.toString();
this.volumeSlider.min = AUDIO_VOLUME_MIN.toString();
this.volumeSlider.max = AUDIO_VOLUME_MAX.toString();
this.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
this.volumeSlider.setAttribute(
'aria-valuetext',
@ -107,7 +112,7 @@ export class AudioControl {
private readonly onToggleMute = () => {
const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
if (shouldUnmute && this.audioVolume <= 0) {
this.audioVolume = appConfig.toolbar.volume.default;
this.audioVolume = DEFAULT_AUDIO_VOLUME;
}
this.isMutedState = !shouldUnmute;
this.persist();
@ -141,11 +146,11 @@ export class AudioControl {
private persist(): void {
writeBrowserStorage(
appConfig.storage.audioMutedKey,
AUDIO_MUTED_STORAGE_KEY,
this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
);
writeBrowserStorage(
appConfig.storage.audioVolumeKey,
AUDIO_VOLUME_STORAGE_KEY,
formatStoredAudioVolume(this.audioVolume)
);
}

View file

@ -1,6 +1,6 @@
import type { FolderApi } from '@tweakpane/core';
import { appConfig, normalizeNumberControlValue } from '../config';
import { normalizeNumberControlValue, runtimeControls } from '../config';
import { activeVibe, settings } from '../settings';
import { rgbColorToCss } from '../utils/rgb-color';
@ -128,7 +128,7 @@ export class ColorReactionMatrixControl {
const cell = document.createElement('div');
cell.className = 'color-reaction-matrix__cell';
const config = appConfig.runtimeSettings.controls[key];
const config = runtimeControls[key];
if (!config) {
return cell;
}
@ -165,7 +165,7 @@ export class ColorReactionMatrixControl {
sourceColorIndex: number,
targetColorIndex: number
): void {
const config = appConfig.runtimeSettings.controls[key];
const config = runtimeControls[key];
if (!config) {
return;
}

View file

@ -3,11 +3,12 @@ import { Pane } from 'tweakpane';
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
import {
appConfig,
normalizeNumberControlValue,
runtimeControls,
type GardenRuntimeSettings,
type NumberControlConfig,
} from '../config';
import { perfStatsOverlayState } from '../game-loop/perf-stats-overlay';
import { activeVibe, settings } from '../settings';
import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color';
import { ColorReactionMatrixControl } from './color-reaction-matrix-control';
@ -29,6 +30,8 @@ interface PaneState extends GardenAudioVibeSettings {
}
const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
const CONFIG_PANE_TITLE = 'Garden Settings';
const CONFIG_PANE_START_HIDDEN = true;
const MUSIC_CONTROLS: ReadonlyArray<{
key: VibeNumberKey;
@ -56,7 +59,7 @@ interface ConfigPaneOptions {
const getRuntimeControlKeys = (folder: string): Array<RuntimeControlKey> =>
(
Object.entries(appConfig.runtimeSettings.controls) as Array<
Object.entries(runtimeControls) as Array<
[RuntimeControlKey, NumberControlConfig | undefined]
>
)
@ -123,10 +126,10 @@ export class ConfigPane {
this.pane = new Pane({
container: this.container,
title: appConfig.tuningPane.title,
title: CONFIG_PANE_TITLE,
expanded: true,
});
this.pane.hidden = appConfig.tuningPane.startHidden;
this.pane.hidden = CONFIG_PANE_START_HIDDEN;
this.pane.element.classList.add('config-pane');
this.pane.element.id = 'config-pane';
@ -310,7 +313,7 @@ export class ConfigPane {
private getRuntimeControlConfig(
key: RuntimeControlKey
): NumberControlConfig | undefined {
const config = appConfig.runtimeSettings.controls[key];
const config = runtimeControls[key];
if (!config || key !== 'maxAgentCount') {
return config;
}
@ -323,7 +326,7 @@ export class ConfigPane {
private addFpsOverlayBinding(container: PaneContainer): void {
container
.addBinding(appConfig.tuningPane, 'showFpsOverlay', {
.addBinding(perfStatsOverlayState, 'isVisible', {
label: 'Show FPS',
})
.on('change', () => this.options.onConfigChange());

View file

@ -1,26 +1,26 @@
import { describe, expect, it } from 'vitest';
import { appConfig } from '../config';
import {
ERASER_SIZE_MAX,
ERASER_SIZE_MIN,
getEraserSizeFromSliderRatio,
getEraserSliderRatioFromSize,
} from './eraser-size-control';
describe('eraser size slider mapping', () => {
it('maps slider position quadratically to eraser size', () => {
const { max, min } = appConfig.toolbar.eraser;
expect(getEraserSizeFromSliderRatio(0)).toBe(min);
expect(getEraserSizeFromSliderRatio(0.5)).toBe(min + (max - min) * 0.25);
expect(getEraserSizeFromSliderRatio(1)).toBe(max);
expect(getEraserSizeFromSliderRatio(0)).toBe(ERASER_SIZE_MIN);
expect(getEraserSizeFromSliderRatio(0.5)).toBe(
ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25
);
expect(getEraserSizeFromSliderRatio(1)).toBe(ERASER_SIZE_MAX);
});
it('maps eraser size back to the inverse slider position', () => {
const { max, min } = appConfig.toolbar.eraser;
const quarterRangeSize = min + (max - min) * 0.25;
const quarterRangeSize = ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25;
expect(getEraserSliderRatioFromSize(min)).toBe(0);
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MIN)).toBe(0);
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
expect(getEraserSliderRatioFromSize(max)).toBe(1);
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX)).toBe(1);
});
});

View file

@ -1,12 +1,16 @@
import { appConfig } from '../config';
import type GameLoop from '../game-loop/game-loop';
import { settings } from '../settings';
import { DEFAULT_ERASER_SIZE, settings } from '../settings';
import { queryRequiredElement } from '../utils/dom';
export const ERASER_SIZE_MIN = 24;
export const ERASER_SIZE_MAX = 480;
const ERASER_CONTROL_SCALE_MIN = 0.74;
const ERASER_CONTROL_SCALE_MAX = 1.34;
const clampEraserSize = (value: number): number => {
const { default: defaultSize, max, min } = appConfig.toolbar.eraser;
const safeValue = Number.isFinite(value) ? value : defaultSize;
return Math.min(max, Math.max(min, Math.round(safeValue)));
const safeValue = Number.isFinite(value) ? value : DEFAULT_ERASER_SIZE;
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
};
const ERASER_SLIDER_MIN = 0;
@ -19,13 +23,14 @@ const clampSliderRatio = (value: number): number => {
};
const getEraserSizeRatio = (size: number): number => {
const { max, min } = appConfig.toolbar.eraser;
return (clampEraserSize(size) - min) / (max - min);
return (clampEraserSize(size) - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
};
export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
const { max, min } = appConfig.toolbar.eraser;
return clampEraserSize(min + (max - min) * clampSliderRatio(sliderRatio) ** 2);
return clampEraserSize(
ERASER_SIZE_MIN +
(ERASER_SIZE_MAX - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2
);
};
export const getEraserSliderRatioFromSize = (size: number): number =>
@ -72,10 +77,8 @@ export class EraserSizeControl {
const sizeRatio = getEraserSizeRatio(size);
const scale =
appConfig.toolbar.eraser.controlScaleMin +
(appConfig.toolbar.eraser.controlScaleMax -
appConfig.toolbar.eraser.controlScaleMin) *
sizeRatio;
ERASER_CONTROL_SCALE_MIN +
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`);
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
this.syncActiveState();

View file

@ -1,9 +1,10 @@
import { appConfig } from '../config';
const DESKTOP_AUTO_HIDE_MEDIA_QUERY =
'(min-width: 600px) and (hover: hover) and (pointer: fine)';
const HIDE_DELAY_MS = 3000;
const BOTTOM_REVEAL_DISTANCE_PX = 96;
export class MenuHider {
private readonly desktopMediaQuery = window.matchMedia(
appConfig.menuHider.desktopMediaQuery
);
private readonly desktopMediaQuery = window.matchMedia(DESKTOP_AUTO_HIDE_MEDIA_QUERY);
private hideTimeout: number | undefined;
private isHidden = false;
private pointerInside = false;
@ -95,7 +96,7 @@ export class MenuHider {
if (this.canAutoHide) {
this.hide();
}
}, appConfig.menuHider.hideDelayMs);
}, HIDE_DELAY_MS);
}
private reveal(): void {
@ -134,6 +135,6 @@ export class MenuHider {
private isNearViewportBottom(clientY: number): boolean {
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx;
return clientY >= viewportHeight - BOTTOM_REVEAL_DISTANCE_PX;
}
}

View file

@ -1,25 +1,48 @@
import { appConfig } from '../config';
import { settings } from '../settings';
import {
MAX_MIRROR_SEGMENT_COUNT,
MIN_MIRROR_SEGMENT_COUNT,
} from '../game-loop/stroke-mirroring';
import { DEFAULT_MIRROR_SEGMENT_COUNT, settings } from '../settings';
import { queryRequiredElement } from '../utils/dom';
const MIRROR_SEGMENT_STEP = 1;
const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
const MIRROR_SEGMENT_FALLBACK_NAME = 'slices';
const MIRROR_SEGMENT_NAMES = {
2: 'halves',
3: 'thirds',
4: 'quarters',
5: 'fifths',
6: 'sixths',
7: 'sevenths',
8: 'eighths',
9: 'ninths',
10: 'tenths',
11: 'elevenths',
12: 'twelfths',
} satisfies Record<number, string>;
const clampMirrorSegmentCount = (value: number): number => {
const { default: defaultCount, max, min } = appConfig.toolbar.mirror;
const safeValue = Number.isFinite(value) ? value : defaultCount;
return Math.min(max, Math.max(min, Math.round(safeValue)));
const safeValue = Number.isFinite(value) ? value : DEFAULT_MIRROR_SEGMENT_COUNT;
return Math.min(
MAX_MIRROR_SEGMENT_COUNT,
Math.max(MIN_MIRROR_SEGMENT_COUNT, Math.round(safeValue))
);
};
const getMirrorSegmentRatio = (count: number): number => {
const { max, min } = appConfig.toolbar.mirror;
return (count - min) / (max - min);
return (
(count - MIN_MIRROR_SEGMENT_COUNT) /
(MAX_MIRROR_SEGMENT_COUNT - MIN_MIRROR_SEGMENT_COUNT)
);
};
const formatMirrorSegmentCount = (count: number): string =>
count <= 1
? appConfig.toolbar.mirror.offLabel
? MIRROR_SEGMENT_OFF_LABEL
: `${count} ${
appConfig.toolbar.mirror.names[
count as keyof typeof appConfig.toolbar.mirror.names
] ?? appConfig.toolbar.mirror.fallbackSegmentName
MIRROR_SEGMENT_NAMES[count as keyof typeof MIRROR_SEGMENT_NAMES] ??
MIRROR_SEGMENT_FALLBACK_NAME
}`;
interface MirrorSegmentControlOptions {
@ -50,9 +73,9 @@ export class MirrorSegmentControl {
settings.mirrorSegmentCount = count;
}
this.slider.min = appConfig.toolbar.mirror.min.toString();
this.slider.max = appConfig.toolbar.mirror.max.toString();
this.slider.step = appConfig.toolbar.mirror.step.toString();
this.slider.min = MIN_MIRROR_SEGMENT_COUNT.toString();
this.slider.max = MAX_MIRROR_SEGMENT_COUNT.toString();
this.slider.step = MIRROR_SEGMENT_STEP.toString();
this.slider.value = count.toString();
const label = formatMirrorSegmentCount(count);