Improve settings
This commit is contained in:
parent
39e19a3c64
commit
7f628180f0
7 changed files with 190 additions and 14 deletions
|
|
@ -67,7 +67,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
folder: 'Movement',
|
folder: 'Movement',
|
||||||
label: 'Travel Speed',
|
label: 'Travel Speed',
|
||||||
min: 10,
|
min: 10,
|
||||||
max: 500,
|
max: 250,
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
turnSpeed: {
|
turnSpeed: {
|
||||||
|
|
@ -116,6 +116,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
|
|
||||||
clarity: {
|
clarity: {
|
||||||
folder: 'Look',
|
folder: 'Look',
|
||||||
|
inverted: true,
|
||||||
label: 'Sharpness',
|
label: 'Sharpness',
|
||||||
min: 0.00001,
|
min: 0.00001,
|
||||||
max: 1,
|
max: 1,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export interface NumberControlConfig {
|
||||||
format?: (value: number) => string;
|
format?: (value: number) => string;
|
||||||
folder: string;
|
folder: string;
|
||||||
integer?: boolean;
|
integer?: boolean;
|
||||||
|
inverted?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
max?: number;
|
max?: number;
|
||||||
min?: number;
|
min?: number;
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,16 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
|
||||||
return params;
|
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 {
|
export class ConfigPane {
|
||||||
private readonly container: HTMLDivElement;
|
private readonly container: HTMLDivElement;
|
||||||
private readonly closeButton: HTMLButtonElement;
|
private readonly closeButton: HTMLButtonElement;
|
||||||
|
|
@ -264,9 +274,10 @@ export class ConfigPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
settings[key] = normalizeNumberControlValue(settings[key], config);
|
settings[key] = normalizeNumberControlValue(settings[key], config);
|
||||||
|
const bindingTarget = this.getRuntimeBindingTarget(key, config);
|
||||||
|
|
||||||
container
|
container
|
||||||
.addBinding(settings, key, getNumberBindingParams(config))
|
.addBinding(bindingTarget, key, getNumberBindingParams(config))
|
||||||
.on('change', () => {
|
.on('change', () => {
|
||||||
const nextValue = normalizeNumberControlValue(settings[key], config);
|
const nextValue = normalizeNumberControlValue(settings[key], config);
|
||||||
if (nextValue !== settings[key]) {
|
if (nextValue !== settings[key]) {
|
||||||
|
|
@ -277,6 +288,25 @@ export class ConfigPane {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getRuntimeBindingTarget(
|
||||||
|
key: RuntimeControlKey,
|
||||||
|
config: NumberControlConfig
|
||||||
|
): typeof settings | Record<RuntimeControlKey, number> {
|
||||||
|
if (!config.inverted) {
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindingTarget = {} as Record<RuntimeControlKey, number>;
|
||||||
|
Object.defineProperty(bindingTarget, key, {
|
||||||
|
enumerable: true,
|
||||||
|
get: () => getInvertedNumberControlValue(settings[key], config),
|
||||||
|
set: (value: number) => {
|
||||||
|
settings[key] = getInvertedNumberControlValue(value, config);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return bindingTarget;
|
||||||
|
}
|
||||||
|
|
||||||
private getRuntimeControlConfig(
|
private getRuntimeControlConfig(
|
||||||
key: RuntimeControlKey
|
key: RuntimeControlKey
|
||||||
): NumberControlConfig | undefined {
|
): NumberControlConfig | undefined {
|
||||||
|
|
|
||||||
26
src/page/eraser-size-control.test.ts
Normal file
26
src/page/eraser-size-control.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,11 +9,28 @@ const clampEraserSize = (value: number): number => {
|
||||||
return Math.min(max, Math.max(min, Math.round(safeValue)));
|
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 getEraserSizeRatio = (size: number): number => {
|
||||||
const { max, min } = appConfig.toolbar.eraser;
|
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 {
|
interface EraserSizeControlOptions {
|
||||||
getGame: () => GameLoop | null;
|
getGame: () => GameLoop | null;
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
|
|
@ -33,7 +50,7 @@ export class EraserSizeControl {
|
||||||
this.control.addEventListener('click', this.activate);
|
this.control.addEventListener('click', this.activate);
|
||||||
this.slider.addEventListener('focus', this.activate);
|
this.slider.addEventListener('focus', this.activate);
|
||||||
this.slider.addEventListener('input', () => {
|
this.slider.addEventListener('input', () => {
|
||||||
settings.eraserSize = clampEraserSize(Number(this.slider.value));
|
settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value));
|
||||||
this.activate();
|
this.activate();
|
||||||
this.render();
|
this.render();
|
||||||
this.options.onChange();
|
this.options.onChange();
|
||||||
|
|
@ -46,19 +63,20 @@ export class EraserSizeControl {
|
||||||
settings.eraserSize = size;
|
settings.eraserSize = size;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.slider.min = appConfig.toolbar.eraser.min.toString();
|
const sliderRatio = getEraserSliderRatioFromSize(size);
|
||||||
this.slider.max = appConfig.toolbar.eraser.max.toString();
|
this.slider.min = ERASER_SLIDER_MIN.toString();
|
||||||
this.slider.step = appConfig.toolbar.eraser.step.toString();
|
this.slider.max = ERASER_SLIDER_MAX.toString();
|
||||||
this.slider.value = size.toString();
|
this.slider.step = ERASER_SLIDER_STEP.toString();
|
||||||
|
this.slider.value = sliderRatio.toString();
|
||||||
this.slider.setAttribute('aria-valuetext', `${size}px`);
|
this.slider.setAttribute('aria-valuetext', `${size}px`);
|
||||||
|
|
||||||
const ratio = getEraserSizeRatio(size);
|
const sizeRatio = getEraserSizeRatio(size);
|
||||||
const scale =
|
const scale =
|
||||||
appConfig.toolbar.eraser.controlScaleMin +
|
appConfig.toolbar.eraser.controlScaleMin +
|
||||||
(appConfig.toolbar.eraser.controlScaleMax -
|
(appConfig.toolbar.eraser.controlScaleMax -
|
||||||
appConfig.toolbar.eraser.controlScaleMin) *
|
appConfig.toolbar.eraser.controlScaleMin) *
|
||||||
ratio;
|
sizeRatio;
|
||||||
this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
|
this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`);
|
||||||
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
|
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
|
||||||
this.syncActiveState();
|
this.syncActiveState();
|
||||||
this.options.getGame()?.updateEraserPreview();
|
this.options.getGame()?.updateEraserPreview();
|
||||||
|
|
|
||||||
100
src/utils/graphics/initialize-gpu.test.ts
Normal file
100
src/utils/graphics/initialize-gpu.test.ts
Normal file
|
|
@ -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<GPUFeatureName> = []): 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'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,9 +11,9 @@ describe('vibe URI handling', () => {
|
||||||
expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe(
|
expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe(
|
||||||
VibeId.AuroraMycelium
|
VibeId.AuroraMycelium
|
||||||
);
|
);
|
||||||
expect(getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')).toBe(
|
expect(
|
||||||
VibeId.VelvetObservatory
|
getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')
|
||||||
);
|
).toBe(VibeId.VelvetObservatory);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses query values before path or hash fallbacks', () => {
|
it('uses query values before path or hash fallbacks', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue