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 -2
View File
@@ -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) {
+64 -4
View File
@@ -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<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 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
<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>}
<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}
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">
<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" 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">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>
</>
)}
{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" />
<Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button>
</div>
<MapCanvas
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}
tokensDraggable={tool === 'move'}
{...(tool !== 'move' ? { onPointer, onLeave } : {})}
onTokenMove={(id, col, row) => patchToken(id, { col, row })}
onTokenMove={moveToken}
onTokenClick={(id) => setEditToken(id)}
onReady={setDims}
/>