Phase 4: dynamic vision, walls & doors

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 15:02:49 +02:00
parent 36f0ea66b3
commit f61fabadb5
8 changed files with 226 additions and 6 deletions
+18
View File
@@ -53,6 +53,24 @@ test('maps: import a Universal VTT (.dd2vtt) map', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Export .uvtt' })).toBeVisible(); 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 }) => { test('maps: zoom controls scale the canvas and fit resets', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click(); await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Zoom'); await page.locator('input[data-autofocus]').fill('Zoom');
+18 -2
View File
@@ -38,6 +38,8 @@ export interface CanvasView {
drawings: CanvasDrawing[]; drawings: CanvasDrawing[];
/** sight-blocking walls, drawn faintly for the GM (not sent to players) */ /** sight-blocking walls, drawn faintly for the GM (not sent to players) */
walls?: { points: Point[] }[] | undefined; walls?: { points: Point[] }[] | undefined;
/** doors (GM only): amber = closed/blocking, green = open */
doors?: { a: Point; b: Point; open: boolean }[] | undefined;
} }
export interface Overlay { export interface Overlay {
@@ -139,7 +141,7 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
drawScene(canvasRef.current, imgRef.current, size.w, size.h, { drawScene(canvasRef.current, imgRef.current, size.w, size.h, {
vp, W, H, gridSize, cols, rows, vp, W, H, gridSize, cols, rows,
showGrid: view.showGrid, fogEnabled: view.fogEnabled, revealed: view.revealed, 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); return () => cancelAnimationFrame(raf);
@@ -230,7 +232,8 @@ interface SceneParams {
vp: Viewport; vp: Viewport;
W: number; H: number; gridSize: number; cols: number; rows: number; W: number; H: number; gridSize: number; cols: number; rows: number;
showGrid: boolean; fogEnabled: boolean; revealed: string[]; playerFog: boolean; 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. */ /** 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) // Overlay (AoE/measure/draft/poly/brush)
const o = p.overlay; const o = p.overlay;
if (o) { if (o) {
+64 -4
View File
@@ -7,6 +7,7 @@ import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
import { import {
brushCells, rectCells, polygonCells, circleCells, coneCells, lineCells, squareCells, brushCells, rectCells, polygonCells, circleCells, coneCells, lineCells, squareCells,
gridDistance, toFeet, applyReveal, applyHide, revealAll, hideAll, worldToCell, gridDistance, toFeet, applyReveal, applyHide, revealAll, hideAll, worldToCell,
blockingSegments, computeVisibleCells,
type Point, type Point,
} from '@/lib/map'; } from '@/lib/map';
import { useCharacters } from '@/features/characters/hooks'; import { useCharacters } from '@/features/characters/hooks';
@@ -20,7 +21,16 @@ import { MapCanvas, type CanvasToken, type Overlay } from './MapCanvas';
import { TokenPalette, type TokenSpec } from './TokenPalette'; import { TokenPalette, type TokenSpec } from './TokenPalette';
const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000']; 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 FogShape = 'brush' | 'rect' | 'poly';
type AoeShape = 'circle' | 'cone' | 'line' | 'square'; type AoeShape = 'circle' | 'cone' | 'line' | 'square';
type DrawKind = MapDrawing['kind']; 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 (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')) { } else if (tool === 'aoe' && (aoeShape === 'circle' || aoeShape === 'square')) {
setOverlay({ cells: aoeShape === 'circle' ? circleCells(p.point, aoeFeet, gridSpec()) : squareCells(p.point, aoeFeet, gridSpec()) }); 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; 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({}); }; 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. // Clear transient hover previews when the cursor leaves, but keep an in-progress polygon.
const onLeave = () => setOverlay(poly.length ? { poly: { points: poly } } : {}); 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 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 addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); };
const patchToken = (id: string, patch: Partial<MapToken>) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) }); const patchToken = (id: string, patch: Partial<MapToken>) => 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 removeToken = (id: string) => update({ tokens: m.tokens.filter((t) => t.id !== id) });
const onTokenIcon = async (id: string, file: File) => { const onTokenIcon = async (id: string, file: File) => {
const thumb = await squareThumbnail(await fileToDataUrl(file), 160); const thumb = await squareThumbnail(await fileToDataUrl(file), 160);
@@ -178,7 +227,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-sm"> <div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
{!showPalette && <Button size="sm" variant="ghost" onClick={() => setShowPalette(true)}>Tokens </Button>} {!showPalette && <Button size="sm" variant="ghost" onClick={() => setShowPalette(true)}>Tokens </Button>}
<Input className="h-8 max-w-36" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" /> <Input className="h-8 max-w-36" value={m.name} onChange={(e) => 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) => (
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t} <Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t}
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">{t}</Button> onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">{t}</Button>
))} ))}
@@ -188,6 +237,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-xs text-muted"> <div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-xs text-muted">
<label className="flex items-center gap-1"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label> <label className="flex items-center gap-1"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label>
<label className="flex items-center gap-1"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label> <label className="flex items-center gap-1"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label>
<label className="flex items-center gap-1" title="Auto-reveal fog from party line of sight (walls block sight)"><input type="checkbox" checked={m.dynamicVision} onChange={(e) => update(e.target.checked ? { dynamicVision: true, fogEnabled: true, revealed: visionReveal(m.tokens, m.revealed) } : { dynamicVision: false })} /> Vision</label>
<label className="flex items-center gap-1">Cell px<NumberField className="w-16" value={m.gridSize} min={10} max={400} onChange={(gridSize) => update({ gridSize })} aria-label="Grid cell size" /></label> <label className="flex items-center gap-1">Cell px<NumberField className="w-16" value={m.gridSize} min={10} max={400} onChange={(gridSize) => update({ gridSize })} aria-label="Grid cell size" /></label>
<label className="flex items-center gap-1">Feet/cell<NumberField className="w-14" value={m.gridUnit.feet} min={1} max={100} onChange={(feet) => update({ gridUnit: { feet } })} aria-label="Feet per cell" /></label> <label className="flex items-center gap-1">Feet/cell<NumberField className="w-14" value={m.gridUnit.feet} min={1} max={100} onChange={(feet) => update({ gridUnit: { feet } })} aria-label="Feet per cell" /></label>
@@ -225,17 +275,27 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
<Button size="sm" variant="ghost" disabled={m.drawings.length === 0} onClick={() => update({ drawings: [] })}>Clear drawings</Button> <Button size="sm" variant="ghost" disabled={m.drawings.length === 0} onClick={() => update({ drawings: [] })}>Clear drawings</Button>
</> </>
)} )}
{tool === 'walls' && (
<>
<span className="mx-1 h-4 w-px bg-line" />
<span className="text-[11px]">Click to add wall points; click a door to open/close.</span>
<Button size="sm" variant="secondary" disabled={poly.length < 2} onClick={finishWall}>Finish wall ({poly.length})</Button>
<Button size="sm" variant="ghost" disabled={m.walls.length === 0} onClick={() => update({ walls: [] })}>Clear walls</Button>
<label className="flex items-center gap-1">Sight ft<NumberField className="w-14" value={m.sightRadiusFeet} min={0} max={500} onChange={(v) => update({ sightRadiusFeet: v })} aria-label="Sight radius feet" /></label>
<Button size="sm" variant="secondary" onClick={revealFromParty}>Reveal from party</Button>
</>
)}
<span className="mx-1 h-4 w-px bg-line" /> <span className="mx-1 h-4 w-px bg-line" />
<Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button> <Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button>
</div> </div>
<MapCanvas <MapCanvas
viewportHeight="calc(100vh - 17rem)" viewportHeight="calc(100vh - 17rem)"
view={{ image: m.image, gridSize: m.gridSize, showGrid: m.showGrid, fogEnabled: m.fogEnabled, revealed: m.revealed, tokens: canvasTokens, drawings: m.drawings, walls: m.walls }} view={{ image: m.image, gridSize: m.gridSize, showGrid: m.showGrid, fogEnabled: m.fogEnabled, revealed: m.revealed, tokens: canvasTokens, drawings: m.drawings, walls: m.walls, doors: m.doors }}
overlay={overlay} overlay={overlay}
tokensDraggable={tool === 'move'} tokensDraggable={tool === 'move'}
{...(tool !== 'move' ? { onPointer, onLeave } : {})} {...(tool !== 'move' ? { onPointer, onLeave } : {})}
onTokenMove={(id, col, row) => patchToken(id, { col, row })} onTokenMove={moveToken}
onTokenClick={(id) => setEditToken(id)} onTokenClick={(id) => setEditToken(id)}
onReady={setDims} onReady={setDims}
/> />
+8
View File
@@ -114,6 +114,14 @@ export class TtrpgDatabase extends Dexie {
if (m.lights === undefined) m.lights = []; 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<string, unknown>) => {
if (m.dynamicVision === undefined) m.dynamicVision = false;
if (m.sightRadiusFeet === undefined) m.sightRadiusFeet = 60;
});
});
} }
} }
+1
View File
@@ -5,3 +5,4 @@ export * from './shapes';
export * from './fog'; export * from './fog';
export * from './projection'; export * from './projection';
export * from './viewport'; export * from './viewport';
export * from './vision';
+36
View File
@@ -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);
});
});
+77
View File
@@ -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<string> {
const visible = new Set<string>();
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;
}
+4
View File
@@ -172,6 +172,10 @@ export const battleMapSchema = z.object({
doors: z.array(mapDoorSchema).default([]), doors: z.array(mapDoorSchema).default([]),
/** light sources */ /** light sources */
lights: z.array(mapLightSchema).default([]), 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 */ /** real-world feet per grid cell, for measurement + AoE labels */
gridUnit: z.object({ feet: int.min(1).max(100).default(5) }).default({ feet: 5 }), gridUnit: z.object({ feet: int.min(1).max(100).default(5) }).default({ feet: 5 }),
gridType: z.enum(['square', 'hex']).default('square'), gridType: z.enum(['square', 'hex']).default('square'),