Minor improvements
This commit is contained in:
parent
0fddad6b45
commit
4e24df1511
9 changed files with 175 additions and 100 deletions
12
index.html
12
index.html
|
|
@ -4,14 +4,14 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover"
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#10151f" />
|
<meta name="theme-color" content="#10151f" />
|
||||||
<meta name="robots" content="index,follow" />
|
<meta name="robots" content="index,follow" />
|
||||||
<meta name="author" content="Andras Schmelczer" />
|
<meta name="author" content="Andras Schmelczer" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden."
|
content="Plant colours in your fleeting garden and watch them flow as time passes. Immerse yourself into shaping nature and nature shaping your work."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<link rel="canonical" href="https://schmelczer.dev/fleeting/" />
|
<link rel="canonical" href="https://schmelczer.dev/fleeting/" />
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
<meta property="og:locale" content="en_US" />
|
<meta property="og:locale" content="en_US" />
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden."
|
content="Plant colours in your fleeting garden and watch them flow as time passes. Immerse yourself into shaping nature and nature shaping your work."
|
||||||
/>
|
/>
|
||||||
<meta property="og:url" content="https://schmelczer.dev/fleeting/" />
|
<meta property="og:url" content="https://schmelczer.dev/fleeting/" />
|
||||||
<meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
<meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
<meta name="twitter:title" content="Fleeting Garden" />
|
<meta name="twitter:title" content="Fleeting Garden" />
|
||||||
<meta
|
<meta
|
||||||
name="twitter:description"
|
name="twitter:description"
|
||||||
content="Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden."
|
content="Plant colours in your fleeting garden and watch them flow as time passes. Immerse yourself into shaping nature and nature shaping your work."
|
||||||
/>
|
/>
|
||||||
<meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
<meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
||||||
<meta name="twitter:image:alt" content="Fleeting Garden social preview image." />
|
<meta name="twitter:image:alt" content="Fleeting Garden social preview image." />
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
"@type": "WebApplication",
|
"@type": "WebApplication",
|
||||||
"name": "Fleeting Garden",
|
"name": "Fleeting Garden",
|
||||||
"url": "https://schmelczer.dev/fleeting/",
|
"url": "https://schmelczer.dev/fleeting/",
|
||||||
"description": "Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden.",
|
"description": "Plant colours in your fleeting garden and watch them flow as time passes. Immerse yourself into shaping nature and nature shaping your work.",
|
||||||
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
|
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
|
||||||
"applicationCategory": "DesignApplication",
|
"applicationCategory": "DesignApplication",
|
||||||
"operatingSystem": "Any",
|
"operatingSystem": "Any",
|
||||||
|
|
@ -91,7 +91,7 @@
|
||||||
<div class="splash" data-visible="true">
|
<div class="splash" data-visible="true">
|
||||||
<h1 class="splash-title">Fleeting Garden</h1>
|
<h1 class="splash-title">Fleeting Garden</h1>
|
||||||
<p class="splash-description">
|
<p class="splash-description">
|
||||||
Tend it while you can. The garden returns to weather either way.
|
Plant colours in your fleeting garden and watch them flow as time passes. Immerse yourself into shaping nature and nature shaping your work.
|
||||||
</p>
|
</p>
|
||||||
<button class="start-button" type="button" disabled>Start</button>
|
<button class="start-button" type="button" disabled>Start</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,10 @@ const graphTuning = {
|
||||||
latencyHint: 'interactive',
|
latencyHint: 'interactive',
|
||||||
outputFilterType: 'highpass',
|
outputFilterType: 'highpass',
|
||||||
compressor: {
|
compressor: {
|
||||||
thresholdDb: -18,
|
thresholdDb: -22,
|
||||||
kneeDb: 18,
|
kneeDb: 12,
|
||||||
ratio: 2.1,
|
ratio: 4.5,
|
||||||
attackSeconds: 0.018,
|
attackSeconds: 0.006,
|
||||||
releaseSeconds: 0.18,
|
releaseSeconds: 0.18,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
@ -87,10 +87,12 @@ export class GardenAudioGraph {
|
||||||
const context = new AudioContextConstructor({
|
const context = new AudioContextConstructor({
|
||||||
latencyHint: graphTuning.latencyHint,
|
latencyHint: graphTuning.latencyHint,
|
||||||
});
|
});
|
||||||
|
const outputBus = context.createGain();
|
||||||
const masterGain = context.createGain();
|
const masterGain = context.createGain();
|
||||||
const highPass = context.createBiquadFilter();
|
const highPass = context.createBiquadFilter();
|
||||||
const compressor = context.createDynamicsCompressor();
|
const compressor = context.createDynamicsCompressor();
|
||||||
|
|
||||||
|
outputBus.gain.value = 1;
|
||||||
masterGain.gain.value = 0;
|
masterGain.gain.value = 0;
|
||||||
highPass.type = graphTuning.outputFilterType;
|
highPass.type = graphTuning.outputFilterType;
|
||||||
highPass.frequency.value = outputHighPassFrequencyHz;
|
highPass.frequency.value = outputHighPassFrequencyHz;
|
||||||
|
|
@ -100,15 +102,17 @@ export class GardenAudioGraph {
|
||||||
compressor.attack.value = graphTuning.compressor.attackSeconds;
|
compressor.attack.value = graphTuning.compressor.attackSeconds;
|
||||||
compressor.release.value = graphTuning.compressor.releaseSeconds;
|
compressor.release.value = graphTuning.compressor.releaseSeconds;
|
||||||
|
|
||||||
masterGain.connect(highPass);
|
// Keep peak control independent from the user's volume slider.
|
||||||
|
outputBus.connect(highPass);
|
||||||
highPass.connect(compressor);
|
highPass.connect(compressor);
|
||||||
compressor.connect(context.destination);
|
compressor.connect(masterGain);
|
||||||
|
masterGain.connect(context.destination);
|
||||||
|
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.masterGain = masterGain;
|
this.masterGain = masterGain;
|
||||||
this.noiseBuffer = this.createNoiseBuffer(context);
|
this.noiseBuffer = this.createNoiseBuffer(context);
|
||||||
this.createDelay(context, masterGain);
|
this.createDelay(context, outputBus);
|
||||||
this.createBuses(context, masterGain);
|
this.createBuses(context, outputBus);
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
@ -224,7 +228,7 @@ export class GardenAudioGraph {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
private createDelay(context: AudioContext, outputBus: GainNode): void {
|
||||||
const delayInput = context.createGain();
|
const delayInput = context.createGain();
|
||||||
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
|
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
|
||||||
const delayFeedback = context.createGain();
|
const delayFeedback = context.createGain();
|
||||||
|
|
@ -250,7 +254,7 @@ export class GardenAudioGraph {
|
||||||
delayFeedback.connect(delayNode);
|
delayFeedback.connect(delayNode);
|
||||||
delayNode.connect(returnLowPass);
|
delayNode.connect(returnLowPass);
|
||||||
returnLowPass.connect(delayOutput);
|
returnLowPass.connect(delayOutput);
|
||||||
delayOutput.connect(masterGain);
|
delayOutput.connect(outputBus);
|
||||||
|
|
||||||
this.delayInput = delayInput;
|
this.delayInput = delayInput;
|
||||||
this.delayNode = delayNode;
|
this.delayNode = delayNode;
|
||||||
|
|
@ -258,10 +262,10 @@ export class GardenAudioGraph {
|
||||||
this.delayOutput = delayOutput;
|
this.delayOutput = delayOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
private createBuses(context: AudioContext, outputBus: GainNode): void {
|
||||||
const eventBus = context.createGain();
|
const eventBus = context.createGain();
|
||||||
eventBus.gain.value = graphTuning.eventBusGain;
|
eventBus.gain.value = graphTuning.eventBusGain;
|
||||||
eventBus.connect(masterGain);
|
eventBus.connect(outputBus);
|
||||||
this.eventBus = eventBus;
|
this.eventBus = eventBus;
|
||||||
this.pianoBuses.clear();
|
this.pianoBuses.clear();
|
||||||
|
|
||||||
|
|
|
||||||
84
src/config/eraser-size.ts
Normal file
84
src/config/eraser-size.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
export interface CssPixelSize {
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ERASER_SIZE_MIN = 24;
|
||||||
|
export const ERASER_SIZE_MAX = 480;
|
||||||
|
|
||||||
|
const ERASER_MAX_SHORT_SIDE_RATIO = 0.55;
|
||||||
|
|
||||||
|
const getNormalizedEraserSizeMax = (maxSize: number): number => {
|
||||||
|
const safeMaxSize = Number.isFinite(maxSize) ? Math.floor(maxSize) : ERASER_SIZE_MAX;
|
||||||
|
return Math.max(ERASER_SIZE_MIN, Math.min(ERASER_SIZE_MAX, safeMaxSize));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getElementCssPixelSize = (element: Element): CssPixelSize => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const { clientHeight = 0, clientWidth = 0 } = element as HTMLElement;
|
||||||
|
return {
|
||||||
|
height: rect.height || clientHeight,
|
||||||
|
width: rect.width || clientWidth,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEraserSizeMaxForCssSize = ({ height, width }: CssPixelSize): number => {
|
||||||
|
const shortestSide = Math.min(height, width);
|
||||||
|
if (!Number.isFinite(shortestSide) || shortestSide <= 0) {
|
||||||
|
return ERASER_SIZE_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getNormalizedEraserSizeMax(
|
||||||
|
Math.floor(shortestSide * ERASER_MAX_SHORT_SIDE_RATIO)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clampEraserSize = (
|
||||||
|
value: number,
|
||||||
|
maxSize = ERASER_SIZE_MAX,
|
||||||
|
fallbackSize = ERASER_SIZE_MIN
|
||||||
|
): number => {
|
||||||
|
const max = getNormalizedEraserSizeMax(maxSize);
|
||||||
|
const fallback = Number.isFinite(fallbackSize) ? fallbackSize : ERASER_SIZE_MIN;
|
||||||
|
const safeValue = Number.isFinite(value) ? value : fallback;
|
||||||
|
return Math.min(max, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEffectiveEraserSize = (
|
||||||
|
size: number,
|
||||||
|
cssSize: CssPixelSize,
|
||||||
|
fallbackSize = ERASER_SIZE_MIN
|
||||||
|
): number => clampEraserSize(size, getEraserSizeMaxForCssSize(cssSize), fallbackSize);
|
||||||
|
|
||||||
|
export const getEraserSizeRatio = (size: number, maxSize = ERASER_SIZE_MAX): number => {
|
||||||
|
const max = getNormalizedEraserSizeMax(maxSize);
|
||||||
|
if (max === ERASER_SIZE_MIN) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (clampEraserSize(size, max) - ERASER_SIZE_MIN) / (max - ERASER_SIZE_MIN);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ERASER_SLIDER_MIN = 0;
|
||||||
|
const ERASER_SLIDER_MAX = 1;
|
||||||
|
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEraserSizeFromSliderRatio = (
|
||||||
|
sliderRatio: number,
|
||||||
|
maxSize = ERASER_SIZE_MAX
|
||||||
|
): number => {
|
||||||
|
const max = getNormalizedEraserSizeMax(maxSize);
|
||||||
|
return clampEraserSize(
|
||||||
|
ERASER_SIZE_MIN + (max - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2,
|
||||||
|
max
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEraserSliderRatioFromSize = (
|
||||||
|
size: number,
|
||||||
|
maxSize = ERASER_SIZE_MAX
|
||||||
|
): number => Math.sqrt(getEraserSizeRatio(size, maxSize));
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { getEffectiveEraserSize } from '../config/eraser-size';
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
|
|
||||||
export class EraserPreview {
|
export class EraserPreview {
|
||||||
|
|
@ -49,9 +50,15 @@ export class EraserPreview {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.previousSize !== settings.eraserSize) {
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
|
const size = getEffectiveEraserSize(settings.eraserSize, {
|
||||||
this.previousSize = settings.eraserSize;
|
height: rect.height || this.canvas.clientHeight,
|
||||||
|
width: rect.width || this.canvas.clientWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.previousSize !== size) {
|
||||||
|
this.element.style.setProperty('--eraser-preview-size', `${size}px`);
|
||||||
|
this.previousSize = size;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -63,7 +70,6 @@ export class EraserPreview {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = this.canvas.getBoundingClientRect();
|
|
||||||
const left = `${this.previewClientPosition.x - rect.left}px`;
|
const left = `${this.previewClientPosition.x - rect.left}px`;
|
||||||
const top = `${this.previewClientPosition.y - rect.top}px`;
|
const top = `${this.previewClientPosition.y - rect.top}px`;
|
||||||
if (this.previousLeft !== left) {
|
if (this.previousLeft !== left) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { GardenAudio } from '../audio/garden-audio';
|
import { GardenAudio } from '../audio/garden-audio';
|
||||||
import { createGardenAudioConfig } from '../audio/garden-audio-config';
|
import { createGardenAudioConfig } from '../audio/garden-audio-config';
|
||||||
|
import { getEffectiveEraserSize, getElementCssPixelSize } from '../config/eraser-size';
|
||||||
import { activeVibe, settings } from '../settings';
|
import { activeVibe, settings } from '../settings';
|
||||||
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
||||||
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
|
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
|
||||||
|
|
@ -209,7 +210,11 @@ export default class GameLoop {
|
||||||
const runtimeSettings = { ...settings };
|
const runtimeSettings = { ...settings };
|
||||||
const introProgress = this.introPrompt.progress;
|
const introProgress = this.introPrompt.progress;
|
||||||
const canvasPixelRatio = this.canvasPixelRatio;
|
const canvasPixelRatio = this.canvasPixelRatio;
|
||||||
const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio;
|
const eraserCssSize = getEffectiveEraserSize(
|
||||||
|
runtimeSettings.eraserSize,
|
||||||
|
getElementCssPixelSize(this.canvas)
|
||||||
|
);
|
||||||
|
const eraserPixelSize = eraserCssSize * canvasPixelRatio;
|
||||||
const isErasing = this.pointerInput.isEraseMode;
|
const isErasing = this.pointerInput.isEraseMode;
|
||||||
const accentColor =
|
const accentColor =
|
||||||
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
|
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
ERASER_SIZE_MAX,
|
ERASER_SIZE_MAX,
|
||||||
ERASER_SIZE_MIN,
|
ERASER_SIZE_MIN,
|
||||||
|
getEffectiveEraserSize,
|
||||||
getEraserSizeFromSliderRatio,
|
getEraserSizeFromSliderRatio,
|
||||||
|
getEraserSizeMaxForCssSize,
|
||||||
getEraserSliderRatioFromSize,
|
getEraserSliderRatioFromSize,
|
||||||
} from './eraser-size-control';
|
} from '../config/eraser-size';
|
||||||
|
|
||||||
describe('eraser size slider mapping', () => {
|
describe('eraser size slider mapping', () => {
|
||||||
it('maps slider position quadratically to eraser size', () => {
|
it('maps slider position quadratically to eraser size', () => {
|
||||||
|
|
@ -23,4 +25,15 @@ describe('eraser size slider mapping', () => {
|
||||||
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
|
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
|
||||||
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX)).toBe(1);
|
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses a responsive max size on small canvases', () => {
|
||||||
|
const mobileMax = getEraserSizeMaxForCssSize({ height: 640, width: 390 });
|
||||||
|
|
||||||
|
expect(mobileMax).toBeLessThan(ERASER_SIZE_MAX);
|
||||||
|
expect(getEraserSizeFromSliderRatio(1, mobileMax)).toBe(mobileMax);
|
||||||
|
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX, mobileMax)).toBe(1);
|
||||||
|
expect(getEffectiveEraserSize(ERASER_SIZE_MAX, { height: 640, width: 390 })).toBe(
|
||||||
|
mobileMax
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,25 @@
|
||||||
|
import {
|
||||||
|
clampEraserSize,
|
||||||
|
ERASER_SIZE_MAX,
|
||||||
|
getElementCssPixelSize,
|
||||||
|
getEraserSizeFromSliderRatio,
|
||||||
|
getEraserSizeMaxForCssSize,
|
||||||
|
getEraserSizeRatio,
|
||||||
|
getEraserSliderRatioFromSize,
|
||||||
|
} from '../config/eraser-size';
|
||||||
import type GameLoop from '../game-loop/game-loop';
|
import type GameLoop from '../game-loop/game-loop';
|
||||||
import { DEFAULT_ERASER_SIZE, settings } from '../settings';
|
import { DEFAULT_ERASER_SIZE, settings } from '../settings';
|
||||||
import { queryRequiredElement } from '../utils/dom';
|
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_MIN = 0.74;
|
||||||
const ERASER_CONTROL_SCALE_MAX = 1.34;
|
const ERASER_CONTROL_SCALE_MAX = 1.34;
|
||||||
|
|
||||||
const clampEraserSize = (value: number): number => {
|
|
||||||
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;
|
const ERASER_SLIDER_MIN = 0;
|
||||||
const ERASER_SLIDER_MAX = 1;
|
const ERASER_SLIDER_MAX = 1;
|
||||||
const ERASER_SLIDER_STEP = 0.001;
|
const ERASER_SLIDER_STEP = 0.001;
|
||||||
|
|
||||||
const clampSliderRatio = (value: number): number => {
|
const clampStoredEraserSize = (value: number): number =>
|
||||||
const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN;
|
clampEraserSize(value, ERASER_SIZE_MAX, DEFAULT_ERASER_SIZE);
|
||||||
return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEraserSizeRatio = (size: number): number => {
|
|
||||||
return (clampEraserSize(size) - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
|
|
||||||
return clampEraserSize(
|
|
||||||
ERASER_SIZE_MIN +
|
|
||||||
(ERASER_SIZE_MAX - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEraserSliderRatioFromSize = (size: number): number =>
|
|
||||||
Math.sqrt(getEraserSizeRatio(size));
|
|
||||||
|
|
||||||
interface EraserSizeControlOptions {
|
interface EraserSizeControlOptions {
|
||||||
getGame: () => GameLoop | null;
|
getGame: () => GameLoop | null;
|
||||||
|
|
@ -48,6 +33,7 @@ export class EraserSizeControl {
|
||||||
HTMLLabelElement
|
HTMLLabelElement
|
||||||
);
|
);
|
||||||
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
|
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
|
||||||
|
private readonly canvas = queryRequiredElement('canvas', HTMLCanvasElement);
|
||||||
private isActive = false;
|
private isActive = false;
|
||||||
|
|
||||||
public constructor(private readonly options: EraserSizeControlOptions) {
|
public constructor(private readonly options: EraserSizeControlOptions) {
|
||||||
|
|
@ -55,7 +41,10 @@ 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 = getEraserSizeFromSliderRatio(Number(this.slider.value));
|
settings.eraserSize = getEraserSizeFromSliderRatio(
|
||||||
|
Number(this.slider.value),
|
||||||
|
this.getResponsiveMaxSize()
|
||||||
|
);
|
||||||
this.activate();
|
this.activate();
|
||||||
this.render();
|
this.render();
|
||||||
this.options.onChange();
|
this.options.onChange();
|
||||||
|
|
@ -63,19 +52,21 @@ export class EraserSizeControl {
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): void {
|
public render(): void {
|
||||||
const size = clampEraserSize(settings.eraserSize);
|
const maxSize = this.getResponsiveMaxSize();
|
||||||
if (settings.eraserSize !== size) {
|
const storedSize = clampStoredEraserSize(settings.eraserSize);
|
||||||
settings.eraserSize = size;
|
if (settings.eraserSize !== storedSize) {
|
||||||
|
settings.eraserSize = storedSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sliderRatio = getEraserSliderRatioFromSize(size);
|
const size = clampEraserSize(storedSize, maxSize, DEFAULT_ERASER_SIZE);
|
||||||
|
const sliderRatio = getEraserSliderRatioFromSize(size, maxSize);
|
||||||
this.slider.min = ERASER_SLIDER_MIN.toString();
|
this.slider.min = ERASER_SLIDER_MIN.toString();
|
||||||
this.slider.max = ERASER_SLIDER_MAX.toString();
|
this.slider.max = ERASER_SLIDER_MAX.toString();
|
||||||
this.slider.step = ERASER_SLIDER_STEP.toString();
|
this.slider.step = ERASER_SLIDER_STEP.toString();
|
||||||
this.slider.value = sliderRatio.toString();
|
this.slider.value = sliderRatio.toString();
|
||||||
this.slider.setAttribute('aria-valuetext', `${size}px`);
|
this.slider.setAttribute('aria-valuetext', `${size}px`);
|
||||||
|
|
||||||
const sizeRatio = getEraserSizeRatio(size);
|
const sizeRatio = getEraserSizeRatio(size, maxSize);
|
||||||
const scale =
|
const scale =
|
||||||
ERASER_CONTROL_SCALE_MIN +
|
ERASER_CONTROL_SCALE_MIN +
|
||||||
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
|
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
|
||||||
|
|
@ -95,6 +86,10 @@ export class EraserSizeControl {
|
||||||
this.options.onActivate();
|
this.options.onActivate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private getResponsiveMaxSize(): number {
|
||||||
|
return getEraserSizeMaxForCssSize(getElementCssPixelSize(this.canvas));
|
||||||
|
}
|
||||||
|
|
||||||
private syncActiveState(): void {
|
private syncActiveState(): void {
|
||||||
this.control.classList.toggle('active', this.isActive);
|
this.control.classList.toggle('active', this.isActive);
|
||||||
this.slider.setAttribute(
|
this.slider.setAttribute(
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,10 @@
|
||||||
|
|
||||||
html > body > aside.control-dock > .info-page {
|
html > body > aside.control-dock > .info-page {
|
||||||
width: min(100%, 520px);
|
width: min(100%, 520px);
|
||||||
max-height: min(62vh, 480px);
|
max-height: 200vh;
|
||||||
max-height: min(62dvh, 480px);
|
max-height: 200dvh;
|
||||||
margin: 0 auto 10px;
|
margin: 0 auto 10px;
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
touch-action: pan-y;
|
|
||||||
border: 1px solid rgb(255 255 255 / 46%);
|
border: 1px solid rgb(255 255 255 / 46%);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background:
|
background:
|
||||||
|
|
@ -20,26 +17,12 @@ html > body > aside.control-dock > .info-page {
|
||||||
0 16px 42px rgb(0 0 0 / 30%),
|
0 16px 42px rgb(0 0 0 / 30%),
|
||||||
0 2px 10px rgb(0 0 0 / 18%);
|
0 2px 10px rgb(0 0 0 / 18%);
|
||||||
backdrop-filter: blur(16px) saturate(118%);
|
backdrop-filter: blur(16px) saturate(118%);
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgb(69 98 88 / 62%) transparent;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
transition:
|
transition:
|
||||||
max-height var(--transition-time-long),
|
max-height var(--transition-time-long),
|
||||||
opacity var(--transition-time-long),
|
opacity var(--transition-time-long),
|
||||||
transform var(--transition-time-long),
|
transform var(--transition-time-long),
|
||||||
margin-bottom var(--transition-time-long);
|
margin-bottom var(--transition-time-long);
|
||||||
|
|
||||||
&::-webkit-scrollbar-track,
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
background-color: transparent;
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background-color: rgb(69 98 88 / 62%);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid rgb(17 56 45);
|
outline: 2px solid rgb(17 56 45);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
|
|
@ -225,8 +208,6 @@ html > body > aside.control-dock > .info-page {
|
||||||
|
|
||||||
@include on-small-screen {
|
@include on-small-screen {
|
||||||
width: min(100%, 520px);
|
width: min(100%, 520px);
|
||||||
max-height: min(58vh, 500px);
|
|
||||||
max-height: min(58dvh, 500px);
|
|
||||||
|
|
||||||
.info-page__content {
|
.info-page__content {
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|
@ -244,26 +225,4 @@ html > body > aside.control-dock > .info-page {
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-height: 420px) {
|
|
||||||
max-height: min(
|
|
||||||
58vh,
|
|
||||||
max(
|
|
||||||
10rem,
|
|
||||||
calc(
|
|
||||||
100vh - 168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
max-height: min(
|
|
||||||
58dvh,
|
|
||||||
max(
|
|
||||||
10rem,
|
|
||||||
calc(
|
|
||||||
100dvh -
|
|
||||||
168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,24 @@
|
||||||
|
|
||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overscroll-behavior: none;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
|
user-select: none;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Open Sans', sans-serif;
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
overscroll-behavior: none;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue