diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts index e07a259..e6b8a70 100644 --- a/src/config/runtime-controls.ts +++ b/src/config/runtime-controls.ts @@ -67,7 +67,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { folder: 'Movement', label: 'Travel Speed', min: 10, - max: 500, + max: 250, step: 1, }, turnSpeed: { @@ -116,6 +116,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { clarity: { folder: 'Look', + inverted: true, label: 'Sharpness', min: 0.00001, max: 1, diff --git a/src/config/types.ts b/src/config/types.ts index 456dec8..6f9aa29 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -12,6 +12,7 @@ export interface NumberControlConfig { format?: (value: number) => string; folder: string; integer?: boolean; + inverted?: boolean; label?: string; max?: number; min?: number; diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index 3e6c397..8bb7f28 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -81,6 +81,16 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => { return params; }; +const getInvertedNumberControlValue = ( + value: number, + config: NumberControlConfig +): number => { + if (!config.inverted || config.min === undefined || config.max === undefined) { + return value; + } + return config.min + config.max - value; +}; + export class ConfigPane { private readonly container: HTMLDivElement; private readonly closeButton: HTMLButtonElement; @@ -264,9 +274,10 @@ export class ConfigPane { } settings[key] = normalizeNumberControlValue(settings[key], config); + const bindingTarget = this.getRuntimeBindingTarget(key, config); container - .addBinding(settings, key, getNumberBindingParams(config)) + .addBinding(bindingTarget, key, getNumberBindingParams(config)) .on('change', () => { const nextValue = normalizeNumberControlValue(settings[key], config); if (nextValue !== settings[key]) { @@ -277,6 +288,25 @@ export class ConfigPane { }); } + private getRuntimeBindingTarget( + key: RuntimeControlKey, + config: NumberControlConfig + ): typeof settings | Record { + if (!config.inverted) { + return settings; + } + + const bindingTarget = {} as Record; + Object.defineProperty(bindingTarget, key, { + enumerable: true, + get: () => getInvertedNumberControlValue(settings[key], config), + set: (value: number) => { + settings[key] = getInvertedNumberControlValue(value, config); + }, + }); + return bindingTarget; + } + private getRuntimeControlConfig( key: RuntimeControlKey ): NumberControlConfig | undefined { diff --git a/src/page/eraser-size-control.test.ts b/src/page/eraser-size-control.test.ts new file mode 100644 index 0000000..6b2bbe8 --- /dev/null +++ b/src/page/eraser-size-control.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { appConfig } from '../config'; +import { + 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); + }); + + it('maps eraser size back to the inverse slider position', () => { + const { max, min } = appConfig.toolbar.eraser; + const quarterRangeSize = min + (max - min) * 0.25; + + expect(getEraserSliderRatioFromSize(min)).toBe(0); + expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5); + expect(getEraserSliderRatioFromSize(max)).toBe(1); + }); +}); diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts index 1099821..2c0e8a8 100644 --- a/src/page/eraser-size-control.ts +++ b/src/page/eraser-size-control.ts @@ -9,11 +9,28 @@ const clampEraserSize = (value: number): number => { return Math.min(max, Math.max(min, Math.round(safeValue))); }; +const ERASER_SLIDER_MIN = 0; +const ERASER_SLIDER_MAX = 1; +const ERASER_SLIDER_STEP = 0.001; + +const clampSliderRatio = (value: number): number => { + const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN; + return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue)); +}; + const getEraserSizeRatio = (size: number): number => { const { max, min } = appConfig.toolbar.eraser; - return (size - min) / (max - min); + return (clampEraserSize(size) - min) / (max - min); }; +export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => { + const { max, min } = appConfig.toolbar.eraser; + return clampEraserSize(min + (max - min) * clampSliderRatio(sliderRatio) ** 2); +}; + +export const getEraserSliderRatioFromSize = (size: number): number => + Math.sqrt(getEraserSizeRatio(size)); + interface EraserSizeControlOptions { getGame: () => GameLoop | null; onActivate: () => void; @@ -33,7 +50,7 @@ export class EraserSizeControl { this.control.addEventListener('click', this.activate); this.slider.addEventListener('focus', this.activate); this.slider.addEventListener('input', () => { - settings.eraserSize = clampEraserSize(Number(this.slider.value)); + settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value)); this.activate(); this.render(); this.options.onChange(); @@ -46,19 +63,20 @@ export class EraserSizeControl { settings.eraserSize = size; } - this.slider.min = appConfig.toolbar.eraser.min.toString(); - this.slider.max = appConfig.toolbar.eraser.max.toString(); - this.slider.step = appConfig.toolbar.eraser.step.toString(); - this.slider.value = size.toString(); + const sliderRatio = getEraserSliderRatioFromSize(size); + this.slider.min = ERASER_SLIDER_MIN.toString(); + this.slider.max = ERASER_SLIDER_MAX.toString(); + this.slider.step = ERASER_SLIDER_STEP.toString(); + this.slider.value = sliderRatio.toString(); this.slider.setAttribute('aria-valuetext', `${size}px`); - const ratio = getEraserSizeRatio(size); + const sizeRatio = getEraserSizeRatio(size); const scale = appConfig.toolbar.eraser.controlScaleMin + (appConfig.toolbar.eraser.controlScaleMax - appConfig.toolbar.eraser.controlScaleMin) * - ratio; - this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`); + sizeRatio; + this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`); this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3)); this.syncActiveState(); this.options.getGame()?.updateEraserPreview(); diff --git a/src/utils/graphics/initialize-gpu.test.ts b/src/utils/graphics/initialize-gpu.test.ts new file mode 100644 index 0000000..44bbd91 --- /dev/null +++ b/src/utils/graphics/initialize-gpu.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { initializeGpu } from './initialize-gpu'; + +const limits = { + maxBufferSize: 1024, + maxComputeWorkgroupsPerDimension: 16, + maxStorageBufferBindingSize: 1024, +} as unknown as GPUSupportedLimits; + +const createDevice = (): GPUDevice => + ({ + addEventListener: vi.fn(), + lost: new Promise(() => undefined), + }) as unknown as GPUDevice; + +const createAdapter = (features: Array = []): GPUAdapter => { + const device = createDevice(); + return { + features: new Set(features), + info: {}, + limits, + requestDevice: vi.fn().mockResolvedValue(device), + } as unknown as GPUAdapter; +}; + +const stubSecureWebGpu = (requestAdapter: GPU['requestAdapter']): void => { + vi.stubGlobal('window', { isSecureContext: true }); + vi.stubGlobal('navigator', { + gpu: { + requestAdapter, + }, + }); +}; + +describe('initializeGpu', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('starts with the least demanding compatibility adapter request', async () => { + const adapter = createAdapter(); + const requestAdapter = vi.fn().mockResolvedValue(adapter); + stubSecureWebGpu(requestAdapter as GPU['requestAdapter']); + + await initializeGpu(); + + expect(requestAdapter).toHaveBeenNthCalledWith(1, { + featureLevel: 'compatibility', + }); + expect(requestAdapter).toHaveBeenCalledTimes(1); + expect(adapter.requestDevice).toHaveBeenCalled(); + }); + + it('continues trying adapters if one request throws', async () => { + const adapter = createAdapter(); + const requestAdapter = vi + .fn() + .mockRejectedValueOnce(new Error('adapter request failed')) + .mockResolvedValueOnce(adapter); + stubSecureWebGpu(requestAdapter as GPU['requestAdapter']); + + await expect(initializeGpu()).resolves.toBeDefined(); + expect(requestAdapter).toHaveBeenCalledTimes(2); + }); + + it('falls back through core and high-performance adapter requests', async () => { + const adapter = createAdapter(); + const requestAdapter = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(adapter); + stubSecureWebGpu(requestAdapter as GPU['requestAdapter']); + + await initializeGpu(); + + expect(requestAdapter).toHaveBeenNthCalledWith(1, { + featureLevel: 'compatibility', + }); + expect(requestAdapter).toHaveBeenNthCalledWith(2, undefined); + expect(requestAdapter).toHaveBeenNthCalledWith(3, { + featureLevel: 'compatibility', + powerPreference: 'high-performance', + }); + expect(adapter.requestDevice).toHaveBeenCalled(); + }); + + it('requests only the core feature when the adapter exposes optional features', async () => { + const adapter = createAdapter(['core-features-and-limits', 'timestamp-query']); + const requestAdapter = vi.fn().mockResolvedValue(adapter); + stubSecureWebGpu(requestAdapter as GPU['requestAdapter']); + + await initializeGpu(); + + expect(adapter.requestDevice).toHaveBeenCalledWith({ + requiredFeatures: ['core-features-and-limits'], + }); + }); +}); diff --git a/src/vibe-uri.test.ts b/src/vibe-uri.test.ts index fe55d5e..72c04f2 100644 --- a/src/vibe-uri.test.ts +++ b/src/vibe-uri.test.ts @@ -11,9 +11,9 @@ describe('vibe URI handling', () => { expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe( VibeId.AuroraMycelium ); - expect(getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')).toBe( - VibeId.VelvetObservatory - ); + expect( + getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy') + ).toBe(VibeId.VelvetObservatory); }); it('uses query values before path or hash fallbacks', () => {