From f61fabadb5205020fbb388293101158ad00f5e74 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 15:02:49 +0200 Subject: [PATCH] Phase 4: dynamic vision, walls & doors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/lib/map/vision.ts (pure, tested): blockingSegments (walls + closed doors), segment intersection, computeVisibleCells (raycast viewer→cell-centre, sight radius). - BattleMap += dynamicVision + sightRadiusFeet (Dexie v12, backfilled). - MapEditor: new "Walls" tool (draw wall polylines; click a door to open/close), a "Vision" toggle (auto-enables fog and reveals from the party), sight-radius field, and "Reveal from party". Moving a PC token accumulates revealed cells via LoS when Vision is on. Doors/walls render for the GM (amber=closed, green=open). - Networked players need no new protocol: revealed cells already travel in the snapshot. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/maps.spec.ts | 18 +++++++ src/features/world/map/MapCanvas.tsx | 20 +++++++- src/features/world/map/MapEditor.tsx | 68 ++++++++++++++++++++++-- src/lib/db/db.ts | 8 +++ src/lib/map/index.ts | 1 + src/lib/map/vision.test.ts | 36 +++++++++++++ src/lib/map/vision.ts | 77 ++++++++++++++++++++++++++++ src/lib/schemas/world.ts | 4 ++ 8 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 src/lib/map/vision.test.ts create mode 100644 src/lib/map/vision.ts diff --git a/e2e/maps.spec.ts b/e2e/maps.spec.ts index 55785df..6725354 100644 --- a/e2e/maps.spec.ts +++ b/e2e/maps.spec.ts @@ -53,6 +53,24 @@ test('maps: import a Universal VTT (.dd2vtt) map', async ({ page }) => { await expect(page.getByRole('button', { name: 'Export .uvtt' })).toBeVisible(); }); +test('maps: walls tool + dynamic vision controls', async ({ page }) => { + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('Vision'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByRole('link', { name: 'Dashboard' }).click(); + await page.getByRole('link', { name: 'Maps' }).click(); + await page.locator('input[type="file"]').setInputFiles('e2e/fixtures/sample.dd2vtt'); + await expect(page.getByTestId('map-viewport')).toBeVisible(); + + await page.getByRole('button', { name: 'walls', exact: true }).click(); + await expect(page.getByRole('button', { name: /Reveal from party/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /Clear walls/ })).toBeVisible(); + + const vision = page.locator('label', { hasText: 'Vision' }).locator('input[type=checkbox]'); + await vision.check(); + await expect(vision).toBeChecked(); +}); + test('maps: zoom controls scale the canvas and fit resets', async ({ page }) => { await page.getByRole('button', { name: '+ New campaign' }).first().click(); await page.locator('input[data-autofocus]').fill('Zoom'); diff --git a/src/features/world/map/MapCanvas.tsx b/src/features/world/map/MapCanvas.tsx index 8a52b4e..5bf661b 100644 --- a/src/features/world/map/MapCanvas.tsx +++ b/src/features/world/map/MapCanvas.tsx @@ -38,6 +38,8 @@ export interface CanvasView { drawings: CanvasDrawing[]; /** sight-blocking walls, drawn faintly for the GM (not sent to players) */ walls?: { points: Point[] }[] | undefined; + /** doors (GM only): amber = closed/blocking, green = open */ + doors?: { a: Point; b: Point; open: boolean }[] | undefined; } export interface Overlay { @@ -139,7 +141,7 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, drawScene(canvasRef.current, imgRef.current, size.w, size.h, { vp, W, H, gridSize, cols, rows, showGrid: view.showGrid, fogEnabled: view.fogEnabled, revealed: view.revealed, - playerFog: !!playerFog, drawings: view.drawings, overlay, walls: view.walls, + playerFog: !!playerFog, drawings: view.drawings, overlay, walls: view.walls, doors: view.doors, }); }); return () => cancelAnimationFrame(raf); @@ -230,7 +232,8 @@ interface SceneParams { vp: Viewport; W: number; H: number; gridSize: number; cols: number; rows: number; showGrid: boolean; fogEnabled: boolean; revealed: string[]; playerFog: boolean; - drawings: CanvasDrawing[]; overlay?: Overlay | undefined; walls?: { points: Point[] }[] | undefined; + drawings: CanvasDrawing[]; overlay?: Overlay | undefined; + walls?: { points: Point[] }[] | undefined; doors?: { a: Point; b: Point; open: boolean }[] | undefined; } /** Draw the whole scene at device resolution with a camera transform. */ @@ -308,6 +311,19 @@ function drawScene(canvas: HTMLCanvasElement | null, img: HTMLImageElement | nul } } + // Doors (GM only): amber = closed/blocking, green dashed = open + if (p.doors?.length) { + cam(); + ctx.lineWidth = 4 / zoom; + ctx.lineCap = 'round'; + for (const d of p.doors) { + ctx.strokeStyle = d.open ? 'rgba(102,187,106,0.9)' : 'rgba(255,167,38,0.95)'; + ctx.setLineDash(d.open ? [8 / zoom, 6 / zoom] : []); + ctx.beginPath(); ctx.moveTo(d.a.x, d.a.y); ctx.lineTo(d.b.x, d.b.y); ctx.stroke(); + } + ctx.setLineDash([]); + } + // Overlay (AoE/measure/draft/poly/brush) const o = p.overlay; if (o) { diff --git a/src/features/world/map/MapEditor.tsx b/src/features/world/map/MapEditor.tsx index 4c8ec6b..7c6c4cf 100644 --- a/src/features/world/map/MapEditor.tsx +++ b/src/features/world/map/MapEditor.tsx @@ -7,6 +7,7 @@ import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize'; import { brushCells, rectCells, polygonCells, circleCells, coneCells, lineCells, squareCells, gridDistance, toFeet, applyReveal, applyHide, revealAll, hideAll, worldToCell, + blockingSegments, computeVisibleCells, type Point, } from '@/lib/map'; import { useCharacters } from '@/features/characters/hooks'; @@ -20,7 +21,16 @@ import { MapCanvas, type CanvasToken, type Overlay } from './MapCanvas'; import { TokenPalette, type TokenSpec } from './TokenPalette'; const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000']; -type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping'; +type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping' | 'walls'; + +/** Distance from point p to segment a-b (image px). */ +function distToSegment(p: Point, a: Point, b: Point): number { + const dx = b.x - a.x, dy = b.y - a.y; + const len2 = dx * dx + dy * dy; + const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2)); + const cx = a.x + t * dx, cy = a.y + t * dy; + return Math.hypot(p.x - cx, p.y - cy); +} type FogShape = 'brush' | 'rect' | 'poly'; type AoeShape = 'circle' | 'cone' | 'line' | 'square'; type DrawKind = MapDrawing['kind']; @@ -79,6 +89,17 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig else if (fogShape === 'poly') setOverlay({ poly: { points: poly, cursor: p.point }, ...(poly.length >= 2 ? { cells: polygonCells([...poly, p.point], gridSpec()) } : {}) }); } else if (tool === 'aoe' && (aoeShape === 'circle' || aoeShape === 'square')) { setOverlay({ cells: aoeShape === 'circle' ? circleCells(p.point, aoeFeet, gridSpec()) : squareCells(p.point, aoeFeet, gridSpec()) }); + } else if (tool === 'walls') { + setOverlay({ poly: { points: poly, cursor: p.point } }); + } + return; + } + if (tool === 'walls') { + if (phase === 'down') { + if (toggleDoorNear(p.point)) return; // click a door to open/close it + const next = [...poly, p.point]; + setPoly(next); + setOverlay({ poly: { points: next, cursor: p.point } }); } return; } @@ -151,6 +172,27 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig const finishPoly = () => { if (poly.length >= 3) commitReveal(polygonCells(poly, gridSpec()), tool === 'reveal'); setPoly([]); setOverlay({}); }; + // --- walls & dynamic vision --- + const finishWall = () => { if (poly.length >= 2) update({ walls: [...m.walls, { id: newId(), points: poly }] }); setPoly([]); setOverlay({}); }; + const toggleDoorNear = (p: Point): boolean => { + const thresh = m.gridSize * 0.5; + let bestI = -1, best = thresh; + m.doors.forEach((d, i) => { const dist = distToSegment(p, d.a, d.b); if (dist < best) { best = dist; bestI = i; } }); + if (bestI < 0) return false; + update({ doors: m.doors.map((d, i) => (i === bestI ? { ...d, open: !d.open } : d)) }); + return true; + }; + /** Cells visible to the party (pc tokens), honouring walls + closed doors. */ + const visionReveal = (tokens: MapToken[], revealed: string[]): string[] => { + const viewers = tokens.filter((t) => t.kind === 'pc').map((t) => ({ x: (t.col + t.size / 2) * m.gridSize, y: (t.row + t.size / 2) * m.gridSize })); + if (viewers.length === 0 || dims.cols === 0) return revealed; + const segments = blockingSegments({ walls: m.walls, doors: m.doors }); + const radiusPx = m.sightRadiusFeet > 0 ? (m.sightRadiusFeet / m.gridUnit.feet) * m.gridSize : 0; + const vis = computeVisibleCells({ viewers, segments, gridSize: m.gridSize, cols: dims.cols, rows: dims.rows, radiusPx }); + return applyReveal(revealed, [...vis]); + }; + const revealFromParty = () => update({ fogEnabled: true, revealed: visionReveal(m.tokens, m.revealed) }); + // Clear transient hover previews when the cursor leaves, but keep an in-progress polygon. const onLeave = () => setOverlay(poly.length ? { poly: { points: poly } } : {}); @@ -158,6 +200,13 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig const addTokenSpec = (spec: TokenSpec) => update({ tokens: [...m.tokens, { id: newId(), ...spec }] }); const addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); }; const patchToken = (id: string, patch: Partial) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) }); + const moveToken = (id: string, col: number, row: number) => setM((prev) => { + const tokens = prev.tokens.map((t) => (t.id === id ? { ...t, col, row } : t)); + const revealed = prev.dynamicVision ? visionReveal(tokens, prev.revealed) : prev.revealed; + const next = { ...prev, tokens, revealed }; + save(next); + return next; + }); const removeToken = (id: string) => update({ tokens: m.tokens.filter((t) => t.id !== id) }); const onTokenIcon = async (id: string, file: File) => { const thumb = await squareThumbnail(await fileToDataUrl(file), 160); @@ -178,7 +227,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
{!showPalette && } update({ name: e.target.value })} aria-label="Map name" /> - {(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping'] as Tool[]).map((t) => ( + {(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping', 'walls'] as Tool[]).map((t) => ( ))} @@ -188,6 +237,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
+ @@ -225,17 +275,27 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig )} + {tool === 'walls' && ( + <> + + Click to add wall points; click a door to open/close. + + + + + + )}
patchToken(id, { col, row })} + onTokenMove={moveToken} onTokenClick={(id) => setEditToken(id)} onReady={setDims} /> diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts index 43e48ba..38529dd 100644 --- a/src/lib/db/db.ts +++ b/src/lib/db/db.ts @@ -114,6 +114,14 @@ export class TtrpgDatabase extends Dexie { if (m.lights === undefined) m.lights = []; }); }); + + // v12 — Phase 4: maps gain dynamicVision + sightRadiusFeet. + this.version(12).stores({}).upgrade(async (tx) => { + await tx.table('maps').toCollection().modify((m: Record) => { + if (m.dynamicVision === undefined) m.dynamicVision = false; + if (m.sightRadiusFeet === undefined) m.sightRadiusFeet = 60; + }); + }); } } diff --git a/src/lib/map/index.ts b/src/lib/map/index.ts index 4783ae1..84d864c 100644 --- a/src/lib/map/index.ts +++ b/src/lib/map/index.ts @@ -5,3 +5,4 @@ export * from './shapes'; export * from './fog'; export * from './projection'; export * from './viewport'; +export * from './vision'; diff --git a/src/lib/map/vision.test.ts b/src/lib/map/vision.test.ts new file mode 100644 index 0000000..6545dea --- /dev/null +++ b/src/lib/map/vision.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { blockingSegments, segmentsIntersect, pathBlocked, computeVisibleCells } from './vision'; + +describe('vision', () => { + it('detects crossing segments', () => { + expect(segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 10, y: 0 })).toBe(true); + expect(segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 0, y: 5 }, { x: 10, y: 5 })).toBe(false); + }); + + it('open doors do not block; closed doors do', () => { + const open = blockingSegments({ walls: [], doors: [{ a: { x: 0, y: 0 }, b: { x: 0, y: 10 }, open: true }] }); + const closed = blockingSegments({ walls: [], doors: [{ a: { x: 0, y: 0 }, b: { x: 0, y: 10 }, open: false }] }); + expect(open).toHaveLength(0); + expect(closed).toHaveLength(1); + }); + + it('a wall hides the cell behind it but not beside it', () => { + // vertical wall at x=100 from y=0..300; viewer at (50,150) + const segs = blockingSegments({ walls: [{ points: [{ x: 100, y: 0 }, { x: 100, y: 300 }] }] }); + const grid = { gridSize: 100, cols: 3, rows: 3 }; // cell centers at 50/150/250 + const vis = computeVisibleCells({ viewers: [{ x: 50, y: 150 }], segments: segs, ...grid }); + expect(vis.has('0,1')).toBe(true); // viewer's own cell + expect(vis.has('2,1')).toBe(false); // directly behind the wall + }); + + it('respects sight radius', () => { + const vis = computeVisibleCells({ viewers: [{ x: 50, y: 50 }], segments: [], gridSize: 100, cols: 5, rows: 1, radiusPx: 150 }); + expect(vis.has('0,0')).toBe(true); + expect(vis.has('1,0')).toBe(true); // center (150,50) dist 100 ≤ 150 + expect(vis.has('4,0')).toBe(false); // center (450,50) dist 400 > 150 + }); + + it('pathBlocked is false with no segments', () => { + expect(pathBlocked({ x: 0, y: 0 }, { x: 100, y: 100 }, [])).toBe(false); + }); +}); diff --git a/src/lib/map/vision.ts b/src/lib/map/vision.ts new file mode 100644 index 0000000..c093d6a --- /dev/null +++ b/src/lib/map/vision.ts @@ -0,0 +1,77 @@ +import type { Point } from './types'; +import { cellKey } from './grid'; + +/** Wall/door inputs (image-space px). Doors block sight only while closed. */ +export interface VisionWalls { + walls: { points: Point[] }[]; + doors?: { a: Point; b: Point; open: boolean }[]; +} + +export type Segment = [Point, Point]; + +/** Flatten walls (polylines) + closed doors into sight-blocking segments. */ +export function blockingSegments({ walls, doors = [] }: VisionWalls): Segment[] { + const segs: Segment[] = []; + for (const w of walls) { + for (let i = 1; i < w.points.length; i++) segs.push([w.points[i - 1]!, w.points[i]!]); + } + for (const d of doors) if (!d.open) segs.push([d.a, d.b]); + return segs; +} + +/** Orientation sign of the triplet (a,b,c). */ +function cross(ax: number, ay: number, bx: number, by: number): number { + return ax * by - ay * bx; +} + +/** Do segments p1p2 and p3p4 intersect (proper or touching)? */ +export function segmentsIntersect(p1: Point, p2: Point, p3: Point, p4: Point): boolean { + const d1 = cross(p4.x - p3.x, p4.y - p3.y, p1.x - p3.x, p1.y - p3.y); + const d2 = cross(p4.x - p3.x, p4.y - p3.y, p2.x - p3.x, p2.y - p3.y); + const d3 = cross(p2.x - p1.x, p2.y - p1.y, p3.x - p1.x, p3.y - p1.y); + const d4 = cross(p2.x - p1.x, p2.y - p1.y, p4.x - p1.x, p4.y - p1.y); + if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) return true; + // collinear/touching cases are treated as non-blocking (avoids walls "leaking" + // at exact endpoints); good enough for grid fog. + return false; +} + +/** Is the straight path a→b blocked by any segment? */ +export function pathBlocked(a: Point, b: Point, segments: Segment[]): boolean { + for (const [p, q] of segments) if (segmentsIntersect(a, b, p, q)) return true; + return false; +} + +export interface VisionOpts { + viewers: Point[]; // token centers (image px) + segments: Segment[]; + gridSize: number; + cols: number; + rows: number; + /** sight radius in px; 0/undefined = unlimited (within bounds) */ + radiusPx?: number; +} + +/** + * Cells (by "col,row" key) visible from any viewer: the cell centre is within the + * sight radius and the straight line from a viewer to it crosses no wall. Slightly + * nudge the viewpoint off exact grid lines to avoid corner artefacts. + */ +export function computeVisibleCells({ viewers, segments, gridSize, cols, rows, radiusPx = 0 }: VisionOpts): Set { + const visible = new Set(); + const r2 = radiusPx > 0 ? radiusPx * radiusPx : Infinity; + for (const v of viewers) { + const eye = { x: v.x + 0.01, y: v.y + 0.01 }; + for (let c = 0; c < cols; c++) { + for (let r = 0; r < rows; r++) { + const key = cellKey(c, r); + if (visible.has(key)) continue; + const center = { x: (c + 0.5) * gridSize, y: (r + 0.5) * gridSize }; + const dx = center.x - eye.x, dy = center.y - eye.y; + if (dx * dx + dy * dy > r2) continue; + if (!pathBlocked(eye, center, segments)) visible.add(key); + } + } + } + return visible; +} diff --git a/src/lib/schemas/world.ts b/src/lib/schemas/world.ts index b5aeefc..f9a8f0a 100644 --- a/src/lib/schemas/world.ts +++ b/src/lib/schemas/world.ts @@ -172,6 +172,10 @@ export const battleMapSchema = z.object({ doors: z.array(mapDoorSchema).default([]), /** light sources */ lights: z.array(mapLightSchema).default([]), + /** auto-reveal fog from party tokens' line of sight (walls block) */ + dynamicVision: z.boolean().default(false), + /** party sight radius in feet (0 = unlimited within the map) */ + sightRadiusFeet: int.min(0).max(500).default(60), /** real-world feet per grid cell, for measurement + AoE labels */ gridUnit: z.object({ feet: int.min(1).max(100).default(5) }).default({ feet: 5 }), gridType: z.enum(['square', 'hex']).default('square'),