diff --git a/apps/docs/src/routes/(components)/stage/+page.svelte b/apps/docs/src/routes/(components)/stage/+page.svelte index 9ef589eb..d71896a2 100644 --- a/apps/docs/src/routes/(components)/stage/+page.svelte +++ b/apps/docs/src/routes/(components)/stage/+page.svelte @@ -229,7 +229,10 @@ e.preventDefault(); stageProps.scene.zoom = Math.max(minZoom, Math.min(stageProps.scene.zoom - scrollDelta, maxZoom)); } else if (stageProps.activeLayer === MapLayerType.FogOfWar) { - stageProps.fogOfWar.tool.size = Math.max(10, Math.min(stageProps.fogOfWar.tool.size + 500.0 * scrollDelta, 1000)); + stageProps.fogOfWar.tool.size = Math.max( + 1, + Math.min(Math.round(stageProps.fogOfWar.tool.size + 40.0 * scrollDelta), 5) + ); } } diff --git a/apps/docs/src/routes/(components)/stage/components/AnnotationsControls.svelte b/apps/docs/src/routes/(components)/stage/components/AnnotationsControls.svelte index 69acfaff..7f8973b3 100644 --- a/apps/docs/src/routes/(components)/stage/components/AnnotationsControls.svelte +++ b/apps/docs/src/routes/(components)/stage/components/AnnotationsControls.svelte @@ -85,7 +85,7 @@ - + {#each props.annotations.layers as layer} diff --git a/apps/docs/src/routes/(components)/stage/components/FogOfWarControls.svelte b/apps/docs/src/routes/(components)/stage/components/FogOfWarControls.svelte index c867d80a..95a1f342 100644 --- a/apps/docs/src/routes/(components)/stage/components/FogOfWarControls.svelte +++ b/apps/docs/src/routes/(components)/stage/components/FogOfWarControls.svelte @@ -23,7 +23,7 @@ bind:value={props.fogOfWar.tool.size} label="Brush Size" min={1} - max={500} + max={5} step={1} disabled={props.fogOfWar.tool.type !== ToolType.Brush} /> diff --git a/apps/docs/src/routes/(components)/stage/defaults.ts b/apps/docs/src/routes/(components)/stage/defaults.ts index e3acb320..8df07588 100644 --- a/apps/docs/src/routes/(components)/stage/defaults.ts +++ b/apps/docs/src/routes/(components)/stage/defaults.ts @@ -29,7 +29,7 @@ export const StageDefaultProps: StageProps = { } ], activeLayer: 'default-layer', - lineWidth: 1 + lineWidth: 0.5 }, debug: { enableStats: false, @@ -76,7 +76,7 @@ export const StageDefaultProps: StageProps = { }, tool: { type: ToolType.Brush, - size: 50, + size: 2, mode: DrawMode.Erase }, edge: { diff --git a/apps/web/src/lib/components/GameSession/AnnotationManager.svelte b/apps/web/src/lib/components/GameSession/AnnotationManager.svelte index 5e3a21b9..84bcc36f 100644 --- a/apps/web/src/lib/components/GameSession/AnnotationManager.svelte +++ b/apps/web/src/lib/components/GameSession/AnnotationManager.svelte @@ -245,10 +245,10 @@ }; const handleLineWidthChange = (value: number) => { - // Update the global state (value is now a percentage) + // Update the global state (value is in grid units) queuePropertyUpdate(stageProps, ['annotations', 'lineWidth'], value, 'control'); // Save to preferences (debounced) - setPreferenceDebounced('annotationLineWidthPercent', value); + setPreferenceDebounced('annotationLineWidthGridUnits', value); }; // Export handlers for external use (e.g., DrawingSliders) diff --git a/apps/web/src/lib/utils/buildSceneProps.ts b/apps/web/src/lib/utils/buildSceneProps.ts index cd8a44ed..72f27af8 100644 --- a/apps/web/src/lib/utils/buildSceneProps.ts +++ b/apps/web/src/lib/utils/buildSceneProps.ts @@ -159,7 +159,7 @@ export const buildSceneProps = ( }, tool: { type: ToolType.Brush, - size: 10.0, // Default brush size as percentage (5-20% range) + size: 2, // Default brush size in grid units (0.25-3 range) mode: DrawMode.Erase }, edge: { diff --git a/apps/web/src/lib/utils/gameSessionPreferences.ts b/apps/web/src/lib/utils/gameSessionPreferences.ts index b137a0d4..cba6a3e3 100644 --- a/apps/web/src/lib/utils/gameSessionPreferences.ts +++ b/apps/web/src/lib/utils/gameSessionPreferences.ts @@ -12,10 +12,8 @@ export interface PaneConfig { } export interface GameSessionPreferences { - brushSize?: number; - brushSizePercent?: number; - annotationLineWidth?: number; - annotationLineWidthPercent?: number; + brushSizeGridUnits?: number; + annotationLineWidthGridUnits?: number; annotationSmoothing?: boolean; paneLayoutDesktop?: PaneConfig[]; paneLayoutMobile?: PaneConfig[]; @@ -35,25 +33,16 @@ const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // Preference configurations // eslint-disable-next-line @typescript-eslint/no-explicit-any export const PREFERENCE_CONFIGS: Record> = { - brushSize: { - cookieName: 'tableslayer:brushSize', - defaultValue: 75, // Old pixel-based default (deprecated) - validate: (value): value is number => typeof value === 'number' && value >= 10 && value <= 1000 + brushSizeGridUnits: { + cookieName: 'tableslayer:brushSizeGridUnits', + defaultValue: 2, + validate: (value): value is number => + typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 5 }, - brushSizePercent: { - cookieName: 'tableslayer:brushSizePercent', - defaultValue: 10.0, // New percentage-based default (5-20% range) - validate: (value): value is number => typeof value === 'number' && value >= 5 && value <= 20 - }, - annotationLineWidth: { - cookieName: 'tableslayer:annotationLineWidth', - defaultValue: 50, - validate: (value): value is number => typeof value === 'number' && value >= 1 && value <= 200 - }, - annotationLineWidthPercent: { - cookieName: 'tableslayer:annotationLineWidthPercent', - defaultValue: 2.0, - validate: (value): value is number => typeof value === 'number' && value >= 0.01 && value <= 5.0 + annotationLineWidthGridUnits: { + cookieName: 'tableslayer:annotationLineWidthGridUnits', + defaultValue: 0.5, + validate: (value): value is number => typeof value === 'number' && value >= 0.25 && value <= 5 }, annotationSmoothing: { cookieName: 'tableslayer:annotationSmoothing', diff --git a/apps/web/src/lib/utils/handleStageZoom.ts b/apps/web/src/lib/utils/handleStageZoom.ts index b7f54d28..731228e0 100644 --- a/apps/web/src/lib/utils/handleStageZoom.ts +++ b/apps/web/src/lib/utils/handleStageZoom.ts @@ -1,6 +1,5 @@ -import { type StageProps, MapLayerType } from '@tableslayer/stage'; +import { type StageProps } from '@tableslayer/stage'; import { trackChecklistItem } from './checklistTracker'; -import { setPreferenceDebounced } from './gameSessionPreferences'; import { queuePropertyUpdate } from './propertyUpdateBroadcaster'; export const handleStageZoom = (e: WheelEvent, stageProps: StageProps) => { @@ -23,21 +22,5 @@ export const handleStageZoom = (e: WheelEvent, stageProps: StageProps) => { e.preventDefault(); const newSceneZoom = Math.max(minZoom, Math.min(stageProps.scene.zoom - scrollDelta, maxZoom)); queuePropertyUpdate(stageProps, ['scene', 'zoom'], newSceneZoom, 'control'); - } else if (stageProps.activeLayer === MapLayerType.FogOfWar) { - const newFogSize = Math.round(Math.max(40, Math.min(stageProps.fogOfWar.tool.size - 500.0 * scrollDelta, 200))); - queuePropertyUpdate(stageProps, ['fogOfWar', 'tool', 'size'], newFogSize, 'control'); - // Save brush size to cookie with debouncing - setPreferenceDebounced('brushSize', newFogSize); - } else if (stageProps.activeLayer === MapLayerType.Annotation) { - // Handle annotation line width adjustment - const currentLineWidth = stageProps.annotations.lineWidth || 50; - const lineWidthDelta = scrollDelta * 200; // Scale to make it more responsive - const newLineWidth = Math.round(Math.max(1, Math.min(currentLineWidth - lineWidthDelta, 200))); - - // Update annotation line width locally - queuePropertyUpdate(stageProps, ['annotations', 'lineWidth'], newLineWidth, 'control'); - - // Save preference with debouncing - setPreferenceDebounced('annotationLineWidth', newLineWidth); } }; diff --git a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.server.ts b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.server.ts index 5ebe9bb0..a5831eea 100644 --- a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.server.ts +++ b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.server.ts @@ -55,7 +55,6 @@ export const load: PageServerLoad = async ({ parent, params, url, cookies }) => const paneLayoutDesktop = getPreferenceServer(cookies, 'paneLayoutDesktop'); const paneLayoutMobile = getPreferenceServer(cookies, 'paneLayoutMobile'); - const brushSize = getPreferenceServer(cookies, 'brushSize'); const checklistState = await getUserChecklistState(user.id); const isEligibleForAutoShow = checkUserChecklistEligibility( @@ -75,7 +74,6 @@ export const load: PageServerLoad = async ({ parent, params, url, cookies }) => activeScene, paneLayoutDesktop, paneLayoutMobile, - brushSize, partykitHost, checklistState: { completedItems: checklistState.completedItems, diff --git a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte index 22ec544a..40a1175e 100644 --- a/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte +++ b/apps/web/src/routes/(app)/[party]/[gameSession]/[[selectedScene]]/+page.svelte @@ -149,15 +149,13 @@ // stageProps identical to the doc. Local-only view state is carried over. // --------------------------------------------------------------------------- - const getMinBrushSize = (gridSpacing: number, displayWidth: number) => { - if (gridSpacing && displayWidth && displayWidth > 0) { - return Math.max(0.5, (gridSpacing / displayWidth) * 100); - } - return 2; - }; - - const clampFogBrush = (value: number, props: StageProps) => - Math.max(getMinBrushSize(props.grid.spacing, props.display.size.x), Math.min(20, value)); + // Brush sizes are in grid units (number of grid squares the brush spans). + // Fog uses whole squares; annotations use quarter steps up to one square, + // then whole squares (must match BRUSH_SIZES in DrawingSliders). + const ANNOTATION_BRUSH_SIZES = [0.25, 0.5, 0.75, 1, 2, 3, 4, 5]; + const clampFogBrush = (value: number) => Math.max(1, Math.min(5, Math.round(value))); + const snapAnnotationBrush = (value: number) => + ANNOTATION_BRUSH_SIZES.reduce((best, size) => (Math.abs(size - value) < Math.abs(best - value) ? size : best)); // SSR strips masks from annotation rows (serialization) and ships them as a // separate record; merge them back so the seed props paint layers immediately @@ -176,8 +174,8 @@ data.selectedSceneLights, data.bucketUrl ); - props.fogOfWar.tool.size = clampFogBrush(getPreference('brushSizePercent') || 10.0, props); - props.annotations.lineWidth = Math.max(0.01, Math.min(5.0, getPreference('annotationLineWidthPercent') || 2.0)); + props.fogOfWar.tool.size = clampFogBrush(getPreference('brushSizeGridUnits') || 2); + props.annotations.lineWidth = snapAnnotationBrush(getPreference('annotationLineWidthGridUnits') || 0.5); props.annotations.smoothingEnabled = getPreference('annotationSmoothing') ?? true; return props; }); @@ -205,11 +203,8 @@ data.selectedSceneLights, data.bucketUrl ); - props.fogOfWar.tool.size = clampFogBrush(getPreference('brushSizePercent') || 10.0, props); - props.annotations.lineWidth = Math.max( - 0.01, - Math.min(5.0, getPreference('annotationLineWidthPercent') || 2.0) - ); + props.fogOfWar.tool.size = clampFogBrush(getPreference('brushSizeGridUnits') || 2); + props.annotations.lineWidth = snapAnnotationBrush(getPreference('annotationLineWidthGridUnits') || 0.5); props.annotations.smoothingEnabled = getPreference('annotationSmoothing') ?? true; stageProps = props; activeControl = 'none'; @@ -242,12 +237,12 @@ : { offset: prev.scene.offset, zoom: prev.scene.zoom, rotation: prev.scene.rotation }, markerPositions: drags, fogTool: isSceneSwitch - ? { size: clampFogBrush(getPreference('brushSizePercent') || 10.0, prev) } + ? { size: clampFogBrush(getPreference('brushSizeGridUnits') || 2) } : { type: prev.fogOfWar.tool.type, size: prev.fogOfWar.tool.size, mode: prev.fogOfWar.tool.mode }, annotations: isSceneSwitch ? { activeLayer: null, - lineWidth: Math.max(0.01, Math.min(5.0, getPreference('annotationLineWidthPercent') || 2.0)), + lineWidth: snapAnnotationBrush(getPreference('annotationLineWidthGridUnits') || 0.5), smoothingEnabled: getPreference('annotationSmoothing') ?? true } : { @@ -1000,26 +995,39 @@ queuePropertyUpdate(stageProps, ['scene', 'zoom'], zoom, 'control'); } + // Brush sizes step through discrete values, so accumulate wheel deltas and + // emit one step (+1 grows, -1 shrinks) per ~100 units (one wheel notch) + let brushWheelAccum = 0; + const brushWheelStep = (e: WheelEvent): number => { + brushWheelAccum += Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + if (Math.abs(brushWheelAccum) < 100) return 0; + const direction = brushWheelAccum > 0 ? -1 : 1; + brushWheelAccum = 0; + return direction; + }; + const onWheel = (e: WheelEvent) => { if (e.ctrlKey) e.preventDefault(); if (stageProps.activeLayer === MapLayerType.Annotation && !e.shiftKey && !e.ctrlKey) { e.preventDefault(); - const scrollDelta = (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY) * 0.05; - const currentLineWidth = stageProps.annotations.lineWidth || 2.0; - const newLineWidth = Math.max(0.01, Math.min(currentLineWidth - scrollDelta * 0.01, 5.0)); + const direction = brushWheelStep(e); + if (direction === 0) return; + const currentIndex = ANNOTATION_BRUSH_SIZES.indexOf(snapAnnotationBrush(stageProps.annotations.lineWidth || 0.5)); + const newLineWidth = + ANNOTATION_BRUSH_SIZES[Math.max(0, Math.min(ANNOTATION_BRUSH_SIZES.length - 1, currentIndex + direction))]; stageProps.annotations.lineWidth = newLineWidth; - setPreference('annotationLineWidthPercent', newLineWidth); + setPreference('annotationLineWidthGridUnits', newLineWidth); return; } if (stageProps.activeLayer === MapLayerType.FogOfWar && !e.shiftKey && !e.ctrlKey) { e.preventDefault(); - const scrollDelta = (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY) * 0.1; - const currentSize = stageProps.fogOfWar.tool.size || 10.0; - const newSize = clampFogBrush(currentSize - scrollDelta * 0.1, stageProps); + const direction = brushWheelStep(e); + if (direction === 0) return; + const newSize = clampFogBrush((stageProps.fogOfWar.tool.size || 2) + direction); stageProps.fogOfWar.tool.size = newSize; - setPreference('brushSizePercent', newSize); + setPreference('brushSizeGridUnits', newSize); return; } @@ -1154,7 +1162,7 @@ {#if stageProps.activeLayer === MapLayerType.Annotation && activeAnnotation && handleOpacityChange && handleBrushSizeChange && handleColorChange} { queuePropertyUpdate(stageProps, ['fogOfWar', 'tool', 'size'], size, 'control'); - setPreference('brushSizePercent', size); + setPreference('brushSizeGridUnits', size); }} /> {/if} diff --git a/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts b/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts index d7a85709..ac4abfa6 100644 --- a/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts +++ b/apps/web/src/routes/(app)/[party]/play/usePlayTools.svelte.ts @@ -49,7 +49,8 @@ const DRAW_EFFECTS: Record = { 'effect-entangle': AnnotationEffect.Entangle }; -const PLAYFIELD_FOG_BRUSH_SIZE = 7.0; +// Fog brush size in grid units (number of grid squares the brush spans) +const PLAYFIELD_FOG_BRUSH_SIZE = 2; const FOG_COMMIT_DEBOUNCE_MS = 500; const DRAWING_IDLE_RESET_MS = 3000; const PERSIST_BUTTON_HIDE_MS = 3000; @@ -71,7 +72,7 @@ export class PlayTools { // Local-only view state (never shared) activeLayer = $state(MapLayerType.None); annotationsActiveLayer = $state(null); - lineWidth = $state(1.0); + lineWidth = $state(0.25); fogTool = $state<{ mode: DrawMode; size: number }>({ mode: DrawMode.Erase, size: PLAYFIELD_FOG_BRUSH_SIZE }); measurement = $state<{ type: number; coneAngle?: number; beamWidth?: number }>({ type: MeasurementType.Line }); @@ -350,7 +351,7 @@ export class PlayTools { this.annotationsActiveLayer = this.currentTemporaryLayerId; this.activeLayer = MapLayerType.Annotation; - this.lineWidth = 1.0; + this.lineWidth = 0.25; this.resetToNoneAfterDelay(); } diff --git a/packages/stage/src/lib/components/DrawingSliders/DrawingSliders.svelte b/packages/stage/src/lib/components/DrawingSliders/DrawingSliders.svelte index 2253403d..5536f200 100644 --- a/packages/stage/src/lib/components/DrawingSliders/DrawingSliders.svelte +++ b/packages/stage/src/lib/components/DrawingSliders/DrawingSliders.svelte @@ -100,29 +100,20 @@ // Check if current selection is an effect const hasEffect = $derived(currentEffect !== AnnotationEffect.None); - // Brush size is now stored as a percentage (0.01% to 5%) - // Use quadratic curve for slider to give more precision to lower values - // Slider range: 0-100, maps to percentage range: 0.01-5.0 - // At 50% slider we want 2%, so we use: percentage = 0.0008 * slider^2 - // This gives: 10% → 0.08%, 50% → 2%, 100% → 8% (capped at 5%) - const percentageToSlider = (percentage: number): number => { - // Inverse: slider = sqrt(percentage / 0.0008) - // Clamp to minimum of 0.01 - const clampedPercentage = Math.max(0.01, percentage); - return Math.sqrt(clampedPercentage / 0.0008); - }; - - const sliderToPercentage = (slider: number): number => { - // Quadratic curve: percentage = 0.0008 * slider^2 - const percentage = 0.0008 * slider * slider; - return Math.max(0.01, Math.min(5.0, percentage)); - }; - - let brushSliderValue = $derived(percentageToSlider(brushSize)); - - const handleBrushSliderChange = (value: number) => { - const actualPercentage = sliderToPercentage(value); - onBrushSizeChange(actualPercentage); + // Brush size is stored in grid units (number of grid squares the line width + // spans on the display): quarter-square steps up to one square, then whole squares. + // The slider operates on indices into this list since the steps are non-uniform. + const BRUSH_SIZES = [0.25, 0.5, 0.75, 1, 2, 3, 4, 5]; + + const brushSliderIndex = $derived( + BRUSH_SIZES.reduce( + (best, size, i) => (Math.abs(size - brushSize) < Math.abs(BRUSH_SIZES[best] - brushSize) ? i : best), + 0 + ) + ); + + const handleBrushSliderChange = (index: number) => { + onBrushSizeChange(BRUSH_SIZES[Math.max(0, Math.min(BRUSH_SIZES.length - 1, Math.round(index)))]); }; const handleColorSelect = (selectedColor: string, close?: () => void) => { @@ -209,14 +200,15 @@ type="range" class="drawingSliders__input" min="0" - max="100" - step="0.1" - value={brushSliderValue} + max={BRUSH_SIZES.length - 1} + step="1" + value={brushSliderIndex} oninput={(e) => handleBrushSliderChange(Number(e.currentTarget.value))} ontouchstart={handleTouchStart} ontouchmove={handleTouchMove} + title="Brush size in grid squares" /> -
{brushSize.toFixed(2)}%
+
{String(Number(brushSize.toFixed(2))).replace(/^0\./, '.')} sq
('callbacks').onAnnotationUpdate; @@ -32,10 +35,11 @@ // Pre-allocated vector for display center offset to avoid GC pressure const displayCenterOffset = new THREE.Vector2(); - // Convert percentage-based lineWidth to texture pixels for outline + // Convert lineWidth (grid units, line width/diameter) to texture pixels; the + // annotation texture is display-resolution sized, so one texture pixel = one + // display pixel. The shaders treat uBrushSize as a radius, so halve it. const lineWidthPixels = $derived.by(() => { - const textureSize = Math.min(display.resolution.x, display.resolution.y); - return Math.round(textureSize * ((props.lineWidth ?? 2.0) / 100)); + return Math.max(1, Math.round(((props.lineWidth ?? 0.5) * getGridCellSize(grid, display)) / 2)); }); // Use $state.raw() for Three.js objects to prevent proxy interference with internal properties @@ -444,7 +448,7 @@ and markers - the trade-off is they receive post-processing (bloom etc.) there. bind:this={layers[index]} props={layer} {display} - lineWidth={props.lineWidth} + {lineWidthPixels} isDrawingThisLayer={() => drawing && props.activeLayer === layer.id} /> diff --git a/packages/stage/src/lib/components/Stage/components/AnnotationLayer/AnnotationMaterial.svelte b/packages/stage/src/lib/components/Stage/components/AnnotationLayer/AnnotationMaterial.svelte index e830f524..42437908 100644 --- a/packages/stage/src/lib/components/Stage/components/AnnotationLayer/AnnotationMaterial.svelte +++ b/packages/stage/src/lib/components/Stage/components/AnnotationLayer/AnnotationMaterial.svelte @@ -15,17 +15,12 @@ interface Props { props: AnnotationLayerData; display: DisplayProps; - lineWidth?: number; + lineWidthPixels: number; /** True while the user is actively drawing on this layer (skip mask re-apply) */ isDrawingThisLayer?: () => boolean; } - const { props, display, lineWidth = 2.0, isDrawingThisLayer = () => false }: Props = $props(); - - const lineWidthPixels = $derived.by(() => { - const textureSize = Math.min(display.resolution.x, display.resolution.y); - return Math.round(textureSize * (lineWidth / 100)); - }); + const { props, display, lineWidthPixels, isDrawingThisLayer = () => false }: Props = $props(); let size = $derived({ width: display.resolution.x, height: display.resolution.y }); diff --git a/packages/stage/src/lib/components/Stage/components/AnnotationLayer/types.ts b/packages/stage/src/lib/components/Stage/components/AnnotationLayer/types.ts index 79aa0e7b..8d462915 100644 --- a/packages/stage/src/lib/components/Stage/components/AnnotationLayer/types.ts +++ b/packages/stage/src/lib/components/Stage/components/AnnotationLayer/types.ts @@ -68,7 +68,9 @@ export interface AnnotationsLayerProps { activeLayer: string | null; /** - * The line width for drawing (global setting) + * The line width for drawing (global setting) in grid units + * (number of grid squares the line width spans: 0.25-1 in + * quarter steps, then whole numbers up to 5) */ lineWidth?: number; diff --git a/packages/stage/src/lib/components/Stage/components/DrawingLayer/types.ts b/packages/stage/src/lib/components/Stage/components/DrawingLayer/types.ts index 5125a37e..79e9cbed 100644 --- a/packages/stage/src/lib/components/Stage/components/DrawingLayer/types.ts +++ b/packages/stage/src/lib/components/Stage/components/DrawingLayer/types.ts @@ -54,6 +54,7 @@ export interface DrawingLayerProps { /** * When `toolType = ToolType.Brush`, setting this controls the brush size + * in grid units (number of grid squares the brush diameter spans, 1-5) */ size: number; diff --git a/packages/stage/src/lib/components/Stage/components/FogOfWarLayer/FogOfWarLayer.svelte b/packages/stage/src/lib/components/Stage/components/FogOfWarLayer/FogOfWarLayer.svelte index 1095f1bf..7dd08a47 100644 --- a/packages/stage/src/lib/components/Stage/components/FogOfWarLayer/FogOfWarLayer.svelte +++ b/packages/stage/src/lib/components/Stage/components/FogOfWarLayer/FogOfWarLayer.svelte @@ -5,7 +5,9 @@ import { ToolType } from '../DrawingLayer/types'; import { type FogOfWarLayerProps } from './types'; import type { Size } from '../../types'; - import type { Callbacks } from '../Stage/types'; + import type { Callbacks, DisplayProps } from '../Stage/types'; + import type { GridLayerProps } from '../GridLayer/types'; + import { getGridCellSize } from '../../helpers/grid'; import LayerInput from '../LayerInput/LayerInput.svelte'; import toolOutlineVertexShader from '../../shaders/default.vert?raw'; import toolOutlineFragmentShader from '../../shaders/ToolOutline.frag?raw'; @@ -16,18 +18,22 @@ props: FogOfWarLayerProps; isActive: boolean; mapSize: Size | null; + grid: GridLayerProps; + display: DisplayProps; + mapZoom: number; } - const { props, isActive, mapSize, ...meshProps }: Props = $props(); + const { props, isActive, mapSize, grid, display, mapZoom, ...meshProps }: Props = $props(); const onFogUpdate = getContext('callbacks').onFogUpdate; - // Convert percentage-based tool.size to texture pixels for outline - // This must match the conversion in FogOfWarMaterial + // Convert tool.size (grid units, brush diameter) to fog texture pixels. The map + // mesh is scaled mapSize * mapZoom in world units (1 world unit = 1 display + // pixel), so one fog texture pixel covers mapZoom display pixels. The shaders + // treat uBrushSize as a radius, so halve the diameter. const toolSizePixels = $derived.by(() => { - if (!mapSize) return props.tool.size; - const textureSize = Math.min(mapSize.width, mapSize.height); - return Math.round(textureSize * (props.tool.size / 100)); + const cellSizePixels = getGridCellSize(grid, display); + return Math.max(1, Math.round((props.tool.size * cellSizePixels) / (2 * (mapZoom || 1)))); }); // Use $state.raw() for Three.js objects to prevent proxy interference with internal properties @@ -245,6 +251,6 @@ events to be detected outside of the fog of war layer. - + diff --git a/packages/stage/src/lib/components/Stage/components/FogOfWarLayer/FogOfWarMaterial.svelte b/packages/stage/src/lib/components/Stage/components/FogOfWarLayer/FogOfWarMaterial.svelte index 0f4a339c..805d0bd2 100644 --- a/packages/stage/src/lib/components/Stage/components/FogOfWarLayer/FogOfWarMaterial.svelte +++ b/packages/stage/src/lib/components/Stage/components/FogOfWarLayer/FogOfWarMaterial.svelte @@ -14,22 +14,15 @@ interface Props { props: FogOfWarLayerProps; mapSize: Size | null; + toolSizePixels: number; } - const { props, mapSize }: Props = $props(); + const { props, mapSize, toolSizePixels }: Props = $props(); const stage = getContext<{ mode: StageMode }>('stage'); let drawMaterial: DrawingMaterial; - // Convert percentage-based tool.size to texture pixels - // tool.size is a percentage (5-20), mapSize gives texture dimensions - const toolSizePixels = $derived.by(() => { - if (!mapSize) return props.tool.size; - const textureSize = Math.min(mapSize.width, mapSize.height); - return Math.round(textureSize * (props.tool.size / 100)); - }); - - // Create derived props with converted tool size + // Create derived props with tool.size (grid units) converted to texture pixels const drawingProps = $derived({ ...props, tool: { diff --git a/packages/stage/src/lib/components/Stage/components/MapLayer/MapLayer.svelte b/packages/stage/src/lib/components/Stage/components/MapLayer/MapLayer.svelte index ae2d4388..6ab4f2ea 100644 --- a/packages/stage/src/lib/components/Stage/components/MapLayer/MapLayer.svelte +++ b/packages/stage/src/lib/components/Stage/components/MapLayer/MapLayer.svelte @@ -190,6 +190,9 @@ props={props.fogOfWar} isActive={props.activeLayer === MapLayerType.FogOfWar} {mapSize} + grid={props.grid} + display={props.display} + mapZoom={props.map.zoom} layers={[SceneLayer.Main]} renderOrder={SceneLayerOrder.FogOfWar} /> diff --git a/packages/stage/src/lib/components/Stage/components/Scene/Scene.svelte b/packages/stage/src/lib/components/Stage/components/Scene/Scene.svelte index c67ca8f1..575c3040 100644 --- a/packages/stage/src/lib/components/Stage/components/Scene/Scene.svelte +++ b/packages/stage/src/lib/components/Stage/components/Scene/Scene.svelte @@ -642,6 +642,7 @@ isActive={props.activeLayer === MapLayerType.Annotation} sceneZoom={props.scene.zoom} display={props.display} + grid={props.grid} /> void; min?: number; max?: number; - curve?: 'linear' | 'quadratic'; - displayAsPercentage?: boolean; + step?: number; + displayUnit?: string; } - let { - brushSize, - onBrushSizeChange, - min = 1, - max = 200, - curve = 'quadratic', - displayAsPercentage = false - }: Props = $props(); - - // For quadratic curve: size = coefficient * slider^2 - // We want: at slider=50%, size should be at the midpoint between min and max - // midpoint = (min + max) / 2 - // So: midpoint = coefficient * 50^2 - // coefficient = midpoint / 2500 - const midpoint = $derived((min + max) / 2); - const coefficient = $derived(midpoint / 2500); - - const brushSizeToSlider = (size: number): number => { - const clampedSize = Math.max(min, Math.min(max, size)); - - if (curve === 'linear') { - // Linear: map size range to 0-100 slider range - return ((clampedSize - min) / (max - min)) * 100; - } else { - // Quadratic: inverse of size = coefficient * slider^2 - return Math.sqrt(clampedSize / coefficient); - } - }; - - const sliderToBrushSize = (slider: number): number => { - if (curve === 'linear') { - // Linear mapping from slider (0-100) to size range (min-max) - const size = min + (slider / 100) * (max - min); - // For linear curve (used by fog), keep decimal precision - // Round to 1 decimal place - return Math.max(min, Math.min(max, Math.round(size * 10) / 10)); - } else { - // Quadratic curve (used by annotations) - const size = coefficient * slider * slider; - // For quadratic, we can round to integers as the range is small decimals (0.01-5.0) - // but we need to preserve decimal precision, so round to 2 decimal places - return Math.max(min, Math.min(max, Math.round(size * 100) / 100)); - } - }; - - let brushSliderValue = $derived(brushSizeToSlider(brushSize)); + let { brushSize, onBrushSizeChange, min = 1, max = 5, step = 1, displayUnit }: Props = $props(); const handleBrushSliderChange = (value: number) => { - const actualSize = sliderToBrushSize(value); - console.log('[BrushSizeSlider] Slider change:', { - sliderValue: value, - actualSize, - min, - max, - curve - }); - onBrushSizeChange(actualSize); + onBrushSizeChange(Math.max(min, Math.min(max, value))); }; // Touch event handlers for better mobile support @@ -85,15 +32,17 @@ id="brush-size-slider" type="range" class="brushSizeSlider__input" - min="0" - max="100" - step="0.1" - value={brushSliderValue} + {min} + {max} + {step} + value={brushSize} oninput={(e) => handleBrushSliderChange(Number(e.currentTarget.value))} ontouchstart={handleTouchStart} ontouchmove={handleTouchMove} /> -
{displayAsPercentage ? `${brushSize.toFixed(1)}%` : brushSize}
+
+ {Number(brushSize.toFixed(2))}{displayUnit ? ` ${displayUnit}` : ''} +