Phase 17: map & fog VTT overhaul + player-facing projection
- Pure geometry library src/lib/map/* (grid, distance 5e/pf2e/euclid, AoE circle/cone/line/square, polygon rasterize + point-in-polygon, brush, fog set algebra, player projection) with unit tests. - Schema: map tokens gain size/kind/characterId/hp/conditions/gmOnly; maps gain drawings/gridUnit(feet)/gridType/fogVersion + point/drawing sub-schemas. Dexie v8 additive migration; mapsRepo.get + transactional mutate. - Editor split into a shared MapCanvas renderer + MapEditor tool state machine: fog via brush/rectangle/polygon (+ size, reveal-all/hide-all), measurement (system-aware distance in feet), AoE templates (circle/cone/line/square preview), drawing annotations (freehand/line/arrow/rect/circle/text, GM-only), pings, and richer tokens (NxN size, character link with live HP ring, condition dots, edit popover). - Player-facing projection: toPlayerProjection strips GM-only content; PlayerMapView renders it read-only with opaque fog; uiStore.activeMapId + 'Show to players'; the player view now shows the live battle map. enemyStatus extracted to src/lib/combat/playerProjection.ts (reused, and for Phase 18). 9 geometry unit tests; maps e2e covers upload→token→reveal-all→player projection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Point } from '@/lib/map';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/** Render-only drawing shape (both MapDrawing and PlayerDrawing satisfy this). */
|
||||
export interface CanvasDrawing {
|
||||
id: string;
|
||||
kind: 'freehand' | 'line' | 'rect' | 'circle' | 'arrow' | 'text';
|
||||
points: Point[];
|
||||
color: string;
|
||||
width: number;
|
||||
text?: string | undefined;
|
||||
}
|
||||
|
||||
/** Minimal token shape both the editor (MapToken) and player view (PlayerToken) satisfy. */
|
||||
export interface CanvasToken {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
col: number;
|
||||
row: number;
|
||||
size: number;
|
||||
kind: 'pc' | 'npc' | 'monster' | 'object';
|
||||
hp?: { current: number; max: number; temp: number } | undefined;
|
||||
conditions: { name: string; value?: number | undefined }[];
|
||||
}
|
||||
|
||||
export interface CanvasView {
|
||||
image?: string | undefined;
|
||||
gridSize: number;
|
||||
showGrid: boolean;
|
||||
fogEnabled: boolean;
|
||||
revealed: string[];
|
||||
tokens: CanvasToken[];
|
||||
drawings: CanvasDrawing[];
|
||||
}
|
||||
|
||||
export interface Overlay {
|
||||
cells?: string[];
|
||||
line?: { a: Point; b: Point } | undefined;
|
||||
label?: { at: Point; text: string } | undefined;
|
||||
draft?: CanvasDrawing | undefined;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
view: CanvasView;
|
||||
maxWidth?: number;
|
||||
readOnly?: boolean;
|
||||
/** dim unrevealed cells fully (player view) vs. translucent (GM editor) */
|
||||
playerFog?: boolean;
|
||||
overlay?: Overlay;
|
||||
onPointer?: (phase: 'down' | 'move' | 'up', p: { point: Point; col: number; row: number }) => void;
|
||||
onTokenMove?: (id: string, col: number, row: number) => void;
|
||||
onTokenClick?: (id: string) => void;
|
||||
tokensDraggable?: boolean;
|
||||
onReady?: (info: { cols: number; rows: number }) => void;
|
||||
}
|
||||
|
||||
/** Shared map renderer: image + grid + fog + drawings + AoE/measure overlay + tokens. */
|
||||
export function MapCanvas({ view, maxWidth = 880, readOnly, playerFog, overlay, onPointer, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) {
|
||||
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||
const fogRef = useRef<HTMLCanvasElement>(null);
|
||||
const drawRef = useRef<HTMLCanvasElement>(null);
|
||||
const overlayRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!view.image) { setNatural(null); return; }
|
||||
const img = new Image();
|
||||
img.onload = () => setNatural({ w: img.naturalWidth, h: img.naturalHeight });
|
||||
img.src = view.image;
|
||||
}, [view.image]);
|
||||
|
||||
const displayW = natural ? Math.min(natural.w, maxWidth) : 0;
|
||||
const scale = natural ? displayW / natural.w : 1;
|
||||
const displayH = natural ? natural.h * scale : 0;
|
||||
const cellPx = view.gridSize * scale;
|
||||
const cols = natural ? Math.ceil(natural.w / view.gridSize) : 0;
|
||||
const rows = natural ? Math.ceil(natural.h / view.gridSize) : 0;
|
||||
|
||||
useEffect(() => { if (natural) onReady?.({ cols, rows }); }, [cols, rows, natural, onReady]);
|
||||
|
||||
// Fog
|
||||
useEffect(() => {
|
||||
const cv = fogRef.current;
|
||||
if (!cv || !natural) return;
|
||||
cv.width = displayW; cv.height = displayH;
|
||||
const ctx = cv.getContext('2d'); if (!ctx) return;
|
||||
ctx.clearRect(0, 0, displayW, displayH);
|
||||
if (!view.fogEnabled) return;
|
||||
const revealed = new Set(view.revealed);
|
||||
ctx.fillStyle = playerFog ? 'rgba(8,8,12,1)' : 'rgba(0,0,0,0.6)';
|
||||
for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) {
|
||||
if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * cellPx, r * cellPx, cellPx + 0.5, cellPx + 0.5);
|
||||
}
|
||||
}, [view.revealed, view.fogEnabled, playerFog, displayW, displayH, cellPx, cols, rows, natural]);
|
||||
|
||||
// Drawings
|
||||
useEffect(() => {
|
||||
const cv = drawRef.current;
|
||||
if (!cv || !natural) return;
|
||||
cv.width = displayW; cv.height = displayH;
|
||||
const ctx = cv.getContext('2d'); if (!ctx) return;
|
||||
ctx.clearRect(0, 0, displayW, displayH);
|
||||
for (const d of view.drawings) drawShape(ctx, d, scale);
|
||||
}, [view.drawings, displayW, displayH, scale, natural]);
|
||||
|
||||
// Overlay (AoE/measure/draft)
|
||||
useEffect(() => {
|
||||
const cv = overlayRef.current;
|
||||
if (!cv || !natural) return;
|
||||
cv.width = displayW; cv.height = displayH;
|
||||
const ctx = cv.getContext('2d'); if (!ctx) return;
|
||||
ctx.clearRect(0, 0, displayW, displayH);
|
||||
if (overlay?.cells?.length) {
|
||||
ctx.fillStyle = 'rgba(212,175,55,0.35)';
|
||||
for (const k of overlay.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * cellPx, r! * cellPx, cellPx, cellPx); }
|
||||
}
|
||||
if (overlay?.draft) drawShape(ctx, overlay.draft, scale);
|
||||
if (overlay?.line) {
|
||||
ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(overlay.line.a.x * scale, overlay.line.a.y * scale); ctx.lineTo(overlay.line.b.x * scale, overlay.line.b.y * scale); ctx.stroke();
|
||||
}
|
||||
if (overlay?.label) {
|
||||
ctx.font = '600 13px sans-serif'; ctx.fillStyle = '#fff'; ctx.strokeStyle = 'rgba(0,0,0,0.7)'; ctx.lineWidth = 3;
|
||||
const x = overlay.label.at.x * scale + 6, y = overlay.label.at.y * scale - 6;
|
||||
ctx.strokeText(overlay.label.text, x, y); ctx.fillText(overlay.label.text, x, y);
|
||||
}
|
||||
}, [overlay, displayW, displayH, cellPx, scale, natural]);
|
||||
|
||||
const toPoint = (e: React.PointerEvent): { point: Point; col: number; row: number } => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / scale, y = (e.clientY - rect.top) / scale;
|
||||
return { point: { x, y }, col: Math.floor(x / view.gridSize), row: Math.floor(y / view.gridSize) };
|
||||
};
|
||||
|
||||
if (!view.image) return <p className="text-sm text-muted">No image.</p>;
|
||||
if (!natural) return <p className="text-sm text-muted">Loading map…</p>;
|
||||
|
||||
const interactive = !readOnly && !!onPointer;
|
||||
return (
|
||||
<div className="overflow-auto rounded-lg border border-line bg-surface p-2">
|
||||
<div className="relative" style={{ width: displayW, height: displayH }}>
|
||||
<img src={view.image} alt="" className="absolute inset-0 h-full w-full select-none" draggable={false} />
|
||||
{view.showGrid && (
|
||||
<div className="pointer-events-none absolute inset-0" style={{
|
||||
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.22) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.22) 1px, transparent 1px)',
|
||||
backgroundSize: `${cellPx}px ${cellPx}px`,
|
||||
}} />
|
||||
)}
|
||||
<canvas ref={fogRef} className="pointer-events-none absolute inset-0" style={{ width: displayW, height: displayH }} />
|
||||
<canvas ref={drawRef} className="pointer-events-none absolute inset-0" style={{ width: displayW, height: displayH }} />
|
||||
<canvas ref={overlayRef} className="pointer-events-none absolute inset-0" style={{ width: displayW, height: displayH }} />
|
||||
{interactive && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||
onPointerDown={(e) => { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); onPointer!('down', toPoint(e)); }}
|
||||
onPointerMove={(e) => { if (e.buttons) onPointer!('move', toPoint(e)); }}
|
||||
onPointerUp={(e) => onPointer!('up', toPoint(e))}
|
||||
/>
|
||||
)}
|
||||
{view.tokens.map((t) => (
|
||||
<TokenChip key={t.id} token={t} cellPx={cellPx} cols={cols} rows={rows}
|
||||
draggable={!!tokensDraggable} onMove={onTokenMove} onClick={onTokenClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing, scale: number): void {
|
||||
ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width * scale; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||||
const p = d.points.map((pt) => ({ x: pt.x * scale, y: pt.y * scale }));
|
||||
if (d.kind === 'text' && d.text && p[0]) {
|
||||
ctx.font = `${Math.max(12, d.width * 5 * scale)}px sans-serif`;
|
||||
ctx.fillText(d.text, p[0].x, p[0].y);
|
||||
return;
|
||||
}
|
||||
if (p.length < 2) return;
|
||||
const a = p[0]!, b = p[p.length - 1]!;
|
||||
ctx.beginPath();
|
||||
if (d.kind === 'rect') {
|
||||
ctx.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y));
|
||||
} else if (d.kind === 'circle') {
|
||||
ctx.arc(a.x, a.y, Math.hypot(b.x - a.x, b.y - a.y), 0, Math.PI * 2);
|
||||
} else {
|
||||
ctx.moveTo(p[0]!.x, p[0]!.y);
|
||||
for (const pt of p.slice(1)) ctx.lineTo(pt.x, pt.y);
|
||||
if (d.kind === 'arrow') {
|
||||
const ang = Math.atan2(b.y - p[p.length - 2]!.y, b.x - p[p.length - 2]!.x), h = 10 + d.width * scale;
|
||||
ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - h * Math.cos(ang - 0.4), b.y - h * Math.sin(ang - 0.4));
|
||||
ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - h * Math.cos(ang + 0.4), b.y - h * Math.sin(ang + 0.4));
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: {
|
||||
token: CanvasToken; cellPx: number; cols: number; rows: number;
|
||||
draggable: boolean; onMove?: ((id: string, col: number, row: number) => void) | undefined; onClick?: ((id: string) => void) | undefined;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ x: number; y: number; col: number; row: number; moved: boolean } | null>(null);
|
||||
const [drift, setDrift] = useState({ dx: 0, dy: 0 });
|
||||
const span = cellPx * token.size;
|
||||
const frac = token.hp && token.hp.max > 0 ? Math.max(0, Math.min(1, token.hp.current / token.hp.max)) : null;
|
||||
|
||||
const down = (e: React.PointerEvent) => {
|
||||
if (draggable) { e.stopPropagation(); ref.current?.setPointerCapture(e.pointerId); drag.current = { x: e.clientX, y: e.clientY, col: token.col, row: token.row, moved: false }; }
|
||||
};
|
||||
const move = (e: React.PointerEvent) => {
|
||||
if (!drag.current) return;
|
||||
const dx = e.clientX - drag.current.x, dy = e.clientY - drag.current.y;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) drag.current.moved = true;
|
||||
setDrift({ dx, dy });
|
||||
};
|
||||
const up = (e: React.PointerEvent) => {
|
||||
if (!drag.current) return;
|
||||
ref.current?.releasePointerCapture(e.pointerId);
|
||||
const moved = drag.current.moved;
|
||||
if (moved && onMove) {
|
||||
const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / cellPx)));
|
||||
const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / cellPx)));
|
||||
onMove(token.id, col, row);
|
||||
} else if (!moved && onClick) onClick(token.id);
|
||||
drag.current = null; setDrift({ dx: 0, dy: 0 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onPointerDown={down} onPointerMove={move} onPointerUp={up}
|
||||
title={token.label}
|
||||
data-testid="map-token"
|
||||
className={cn('absolute grid place-items-center rounded-full border-2 border-black/50 text-[11px] font-bold text-black shadow', frac !== null && frac <= 0 && 'opacity-50')}
|
||||
style={{
|
||||
width: span * 0.92, height: span * 0.92,
|
||||
left: token.col * cellPx + span * 0.04 + drift.dx,
|
||||
top: token.row * cellPx + span * 0.04 + drift.dy,
|
||||
background: token.color,
|
||||
cursor: draggable ? 'grab' : onClick ? 'pointer' : 'default',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
{frac !== null && (
|
||||
<svg className="pointer-events-none absolute inset-0" viewBox="0 0 36 36" aria-hidden>
|
||||
<circle cx="18" cy="18" r="17" fill="none" stroke="rgba(0,0,0,0.35)" strokeWidth="2" />
|
||||
<circle cx="18" cy="18" r="17" fill="none" stroke={frac > 0.5 ? '#66bb6a' : frac > 0 ? '#ffa726' : '#ef5350'}
|
||||
strokeWidth="2.5" strokeDasharray={`${frac * 106.8} 106.8`} transform="rotate(-90 18 18)" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="z-10 truncate px-0.5">{token.label}</span>
|
||||
{token.conditions.length > 0 && (
|
||||
<span className="absolute -right-1 -top-1 z-10 grid h-4 w-4 place-items-center rounded-full bg-danger text-[9px] text-white" title={token.conditions.map((c) => c.name).join(', ')}>
|
||||
{token.conditions.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user