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