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:
@@ -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');
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user