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:
2026-06-08 10:31:26 +02:00
parent ffe24495b6
commit fb459ad92c
20 changed files with 1047 additions and 223 deletions
+30 -201
View File
@@ -1,17 +1,15 @@
import { useEffect, useRef, useState } from 'react';
import type { BattleMap, Campaign, MapToken } from '@/lib/schemas';
import { useRef, useState } from 'react';
import { Link } from '@tanstack/react-router';
import type { Campaign } from '@/lib/schemas';
import { mapsRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useUiStore } from '@/stores/uiStore';
import { useMaps } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { cn } from '@/lib/cn';
import { MapEditor } from './map/MapEditor';
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726'];
export function MapsPage() {
return <RequireCampaign>{(c) => <Maps campaign={c} />}</RequireCampaign>;
@@ -22,6 +20,8 @@ function Maps({ campaign }: { campaign: Campaign }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const activeMapId = useUiStore((s) => s.activeMapId);
const setActiveMap = useUiStore((s) => s.setActiveMap);
const selected = maps.find((m) => m.id === selectedId) ?? null;
const upload = (file: File) => {
@@ -44,210 +44,39 @@ function Maps({ campaign }: { campaign: Campaign }) {
subtitle={campaign.name}
actions={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Upload map</Button>}
/>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }}
/>
<input ref={fileRef} type="file" accept="image/*" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }} />
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
{maps.length === 0 ? (
<EmptyState title="No maps yet" hint="Upload a battle map image, add a grid, drop tokens, and reveal it with fog of war." action={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Upload map</Button>} />
<EmptyState title="No maps yet" hint="Upload a battle map, add a grid, paint fog of war, drop linked tokens, measure ranges — then show it to players." action={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Upload map</Button>} />
) : (
<div className="grid gap-4 lg:grid-cols-[200px_1fr]">
<div className="grid gap-4 lg:grid-cols-[210px_1fr]">
<ul className="space-y-1">
{maps.map((m) => (
<li key={m.id} className="group flex items-center gap-1 rounded-md border border-line">
<button className={cn('flex-1 truncate px-3 py-2 text-left text-sm', selected?.id === m.id ? 'text-accent' : 'text-ink')} onClick={() => setSelectedId(m.id)}>{m.name}</button>
<button className="px-2 text-muted opacity-0 hover:text-danger group-hover:opacity-100" onClick={async () => { await mapsRepo.remove(m.id); if (selectedId === m.id) setSelectedId(null); }} aria-label={`Delete ${m.name}`}></button>
{maps.map((mp) => (
<li key={mp.id} className="group flex items-center gap-1 rounded-md border border-line">
<button className={cn('flex-1 truncate px-3 py-2 text-left text-sm', selected?.id === mp.id ? 'text-accent' : 'text-ink')} onClick={() => setSelectedId(mp.id)}>
{mp.name}{activeMapId === mp.id ? ' 📺' : ''}
</button>
<button className="px-2 text-muted opacity-0 hover:text-danger group-hover:opacity-100" onClick={async () => { await mapsRepo.remove(mp.id); if (selectedId === mp.id) setSelectedId(null); if (activeMapId === mp.id) setActiveMap(null); }} aria-label={`Delete ${mp.name}`}></button>
</li>
))}
</ul>
{selected ? <MapEditor key={selected.id} map={selected} /> : <div className="rounded-lg border border-line bg-panel p-5 text-sm text-muted">Select a map.</div>}
{selected ? (
<div>
<div className="mb-2 flex items-center gap-2">
<Button size="sm" variant={activeMapId === selected.id ? 'primary' : 'secondary'} onClick={() => setActiveMap(activeMapId === selected.id ? null : selected.id)}>
{activeMapId === selected.id ? '📺 Showing to players' : 'Show to players'}
</Button>
<Link to="/play" className="text-sm text-accent hover:underline">Open player view </Link>
</div>
<MapEditor key={selected.id} map={selected} campaign={campaign} />
</div>
) : (
<div className="rounded-lg border border-line bg-panel p-5 text-sm text-muted">Select a map.</div>
)}
</div>
)}
</Page>
);
}
type Tool = 'move' | 'reveal' | 'hide';
function MapEditor({ map }: { map: BattleMap }) {
const [m, setM] = useState<BattleMap>(map);
const [tool, setTool] = useState<Tool>('move');
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
const fogRef = useRef<HTMLCanvasElement>(null);
const painting = useRef(false);
const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400);
const update = (patch: Partial<BattleMap>) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
// Load natural image size.
useEffect(() => {
if (!m.image) return;
const img = new Image();
img.onload = () => setNatural({ w: img.naturalWidth, h: img.naturalHeight });
img.src = m.image;
}, [m.image]);
const displayW = natural ? Math.min(natural.w, 880) : 0;
const scale = natural ? displayW / natural.w : 1;
const displayH = natural ? natural.h * scale : 0;
const cellPx = m.gridSize * scale;
const cols = natural ? Math.ceil(natural.w / m.gridSize) : 0;
const rows = natural ? Math.ceil(natural.h / m.gridSize) : 0;
// Redraw fog whenever it changes.
useEffect(() => {
const canvas = fogRef.current;
if (!canvas || !natural) return;
canvas.width = displayW;
canvas.height = displayH;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, displayW, displayH);
if (!m.fogEnabled) return;
const revealed = new Set(m.revealed);
ctx.fillStyle = '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, cellPx);
}
}
}, [m.revealed, m.fogEnabled, displayW, displayH, cellPx, cols, rows, natural]);
// Stop painting on global pointer up.
useEffect(() => {
const stop = () => { painting.current = false; };
window.addEventListener('pointerup', stop);
return () => window.removeEventListener('pointerup', stop);
}, []);
const cellAt = (e: React.PointerEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
return { col: Math.floor((e.clientX - rect.left) / cellPx), row: Math.floor((e.clientY - rect.top) / cellPx) };
};
const paint = (e: React.PointerEvent) => {
if (tool === 'move') return;
const { col, row } = cellAt(e);
if (col < 0 || row < 0 || col >= cols || row >= rows) return;
const key = `${col},${row}`;
const set = new Set(m.revealed);
if (tool === 'reveal') set.add(key); else set.delete(key);
update({ revealed: [...set] });
};
const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0 }] });
const patchToken = (id: string, p: Partial<MapToken>) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...p } : t)) });
return (
<div>
{/* Toolbar */}
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
<Input className="h-8 max-w-40" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
<div className="flex gap-1">
{(['move', 'reveal', 'hide'] as Tool[]).map((t) => (
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} onClick={() => setTool(t)} aria-pressed={tool === t}>
{t === 'move' ? 'Move' : t === 'reveal' ? 'Reveal' : 'Hide'}
</Button>
))}
</div>
<label className="flex items-center gap-1 text-xs text-muted"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label>
<label className="flex items-center gap-1 text-xs text-muted">Cell<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 text-xs text-muted"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label>
<Button size="sm" variant="ghost" onClick={() => update({ revealed: [] })}>Hide all</Button>
<Button size="sm" variant="ghost" onClick={() => { const all: string[] = []; for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) all.push(`${c},${r}`); update({ revealed: all }); }}>Reveal all</Button>
<Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button>
</div>
{!natural ? (
<p className="text-sm text-muted">Loading map</p>
) : (
<div className="overflow-auto rounded-lg border border-line bg-surface p-2">
<div className="relative" style={{ width: displayW, height: displayH }}>
<img src={m.image} alt={m.name} className="absolute inset-0 h-full w-full select-none" draggable={false} />
{m.showGrid && (
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage:
'linear-gradient(to right, rgba(255,255,255,0.25) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.25) 1px, transparent 1px)',
backgroundSize: `${cellPx}px ${cellPx}px`,
}}
/>
)}
{/* Fog + paint surface (also catches clicks in reveal/hide mode) */}
<canvas
ref={fogRef}
className="absolute inset-0"
style={{ width: displayW, height: displayH, pointerEvents: tool === 'move' ? 'none' : 'auto', cursor: tool === 'move' ? 'default' : 'crosshair' }}
onPointerDown={(e) => { painting.current = true; paint(e); }}
onPointerMove={(e) => { if (painting.current) paint(e); }}
/>
{/* Tokens */}
{m.tokens.map((t) => (
<TokenChip key={t.id} token={t} cellPx={cellPx} draggable={tool === 'move'} cols={cols} rows={rows}
onMove={(col, row) => patchToken(t.id, { col, row })}
onRemove={() => update({ tokens: m.tokens.filter((x) => x.id !== t.id) })}
/>
))}
</div>
</div>
)}
<p className="mt-2 text-xs text-muted">Tip: use <strong>Reveal</strong>/<strong>Hide</strong> to paint fog; <strong>Move</strong> to drag tokens.</p>
</div>
);
}
function TokenChip({ token, cellPx, draggable, cols, rows, onMove, onRemove }: {
token: MapToken; cellPx: number; draggable: boolean; cols: number; rows: number;
onMove: (col: number, row: number) => void; onRemove: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const drag = useRef<{ startX: number; startY: number; col: number; row: number } | null>(null);
const [drift, setDrift] = useState<{ dx: number; dy: number }>({ dx: 0, dy: 0 });
const onDown = (e: React.PointerEvent) => {
if (!draggable) return;
e.stopPropagation();
ref.current?.setPointerCapture(e.pointerId);
drag.current = { startX: e.clientX, startY: e.clientY, col: token.col, row: token.row };
};
const onMovePtr = (e: React.PointerEvent) => {
if (!drag.current) return;
setDrift({ dx: e.clientX - drag.current.startX, dy: e.clientY - drag.current.startY });
};
const onUp = (e: React.PointerEvent) => {
if (!drag.current) return;
ref.current?.releasePointerCapture(e.pointerId);
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)));
drag.current = null;
setDrift({ dx: 0, dy: 0 });
onMove(col, row);
};
return (
<div
ref={ref}
onPointerDown={onDown}
onPointerMove={onMovePtr}
onPointerUp={onUp}
onDoubleClick={onRemove}
title={`${token.label} (double-click to remove)`}
className="absolute grid place-items-center rounded-full border-2 border-black/40 text-xs font-bold text-black shadow"
style={{
width: cellPx * 0.9, height: cellPx * 0.9,
left: token.col * cellPx + cellPx * 0.05 + drift.dx,
top: token.row * cellPx + cellPx * 0.05 + drift.dy,
background: token.color,
cursor: draggable ? 'grab' : 'default',
touchAction: 'none',
}}
>
{token.label}
</div>
);
}
+260
View File
@@ -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>
);
}
+225
View File
@@ -0,0 +1,225 @@
import { useMemo, useRef, useState } from 'react';
import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
import { mapsRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import {
brushCells, rectCells, polygonCells, circleCells, coneCells, lineCells, squareCells,
gridDistance, toFeet, applyReveal, applyHide, revealAll, hideAll, worldToCell,
type Point,
} from '@/lib/map';
import { useCharacters } from '@/features/characters/hooks';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { Modal } from '@/components/ui/Modal';
import { cn } from '@/lib/cn';
import { MapCanvas, type CanvasToken, type Overlay } from './MapCanvas';
const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000'];
type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping';
type FogShape = 'brush' | 'rect' | 'poly';
type AoeShape = 'circle' | 'cone' | 'line' | 'square';
type DrawKind = MapDrawing['kind'];
export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaign }) {
const [m, setM] = useState<BattleMap>(map);
const characters = useCharacters(campaign.id);
const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400);
const update = (patch: Partial<BattleMap>) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
const [tool, setTool] = useState<Tool>('move');
const [fogShape, setFogShape] = useState<FogShape>('brush');
const [brush, setBrush] = useState(0);
const [aoeShape, setAoeShape] = useState<AoeShape>('circle');
const [aoeFeet, setAoeFeet] = useState(20);
const [drawKind, setDrawKind] = useState<DrawKind>('freehand');
const [drawColor, setDrawColor] = useState('#d4af37');
const [gmDraw, setGmDraw] = useState(true);
const [dims, setDims] = useState({ cols: 0, rows: 0 });
const [editToken, setEditToken] = useState<string | null>(null);
// transient interaction state
const [overlay, setOverlay] = useState<Overlay>({});
const anchor = useRef<Point | null>(null);
const [poly, setPoly] = useState<Point[]>([]);
const draftPts = useRef<Point[]>([]);
const distMode = campaign.system === 'pf2e' ? 'pf2e' : '5e';
// Live HP from a linked character drives the ring.
const canvasTokens: CanvasToken[] = useMemo(() => m.tokens.map((t) => {
const linked = t.characterId ? characters.find((c) => c.id === t.characterId) : undefined;
return {
id: t.id, label: t.label || (linked?.name ?? ''), color: t.color, col: t.col, row: t.row,
size: t.size, kind: t.kind, hp: linked?.hp ?? t.hp, conditions: t.conditions,
};
}), [m.tokens, characters]);
const commitReveal = (cells: string[], reveal: boolean) =>
update({ revealed: reveal ? applyReveal(m.revealed, cells) : applyHide(m.revealed, cells) });
const onPointer = (phase: 'down' | 'move' | 'up', p: { point: Point; col: number; row: number }) => {
const reveal = tool === 'reveal';
if (tool === 'reveal' || tool === 'hide') {
if (fogShape === 'brush') {
if (phase !== 'up') commitReveal(brushCells(p.col, p.row, brush, dims.cols, dims.rows), reveal);
} else if (fogShape === 'rect') {
if (phase === 'down') anchor.current = p.point;
else if (anchor.current) {
const a = worldToCell(anchor.current, m.gridSize);
const cells = rectCells(a, { col: p.col, row: p.row }, dims.cols, dims.rows);
if (phase === 'move') setOverlay({ cells });
else { commitReveal(cells, reveal); anchor.current = null; setOverlay({}); }
}
} else if (fogShape === 'poly') {
if (phase === 'down') { const next = [...poly, p.point]; setPoly(next); setOverlay({ cells: polygonCells(next, gridSpec()) }); }
}
return;
}
if (tool === 'measure') {
if (phase === 'down') anchor.current = p.point;
else if (anchor.current) {
const a = worldToCell(anchor.current, m.gridSize);
const cells = gridDistance(a, { col: p.col, row: p.row }, distMode);
setOverlay({ line: { a: anchor.current, b: p.point }, label: { at: p.point, text: `${toFeet(cells, m.gridUnit.feet)} ft` } });
if (phase === 'up') anchor.current = null;
}
return;
}
if (tool === 'aoe') {
if (phase === 'down') anchor.current = p.point;
const origin = anchor.current ?? p.point;
let cells: string[] = [];
if (aoeShape === 'circle') cells = circleCells(p.point, aoeFeet, gridSpec());
else if (aoeShape === 'square') cells = squareCells(p.point, aoeFeet, gridSpec());
else if (aoeShape === 'cone') cells = coneCells(origin, p.point, aoeFeet, 53, gridSpec());
else cells = lineCells(origin, p.point, 5, gridSpec());
setOverlay({ cells });
if (phase === 'up' && aoeShape !== 'circle' && aoeShape !== 'square') anchor.current = null;
return;
}
if (tool === 'draw') {
if (drawKind === 'text') {
if (phase === 'down') {
const text = window.prompt('Label text:')?.trim();
if (text) update({ drawings: [...m.drawings, { id: newId(), kind: 'text', points: [p.point], color: drawColor, width: 4, text, gmOnly: gmDraw }] });
}
return;
}
if (phase === 'down') { draftPts.current = [p.point]; }
else if (phase === 'move') {
draftPts.current = drawKind === 'freehand' ? [...draftPts.current, p.point] : [draftPts.current[0]!, p.point];
setOverlay({ draft: { id: 'draft', kind: drawKind, points: draftPts.current, color: drawColor, width: 3 } });
} else {
if (draftPts.current.length >= 2) update({ drawings: [...m.drawings, { id: newId(), kind: drawKind, points: draftPts.current, color: drawColor, width: 3, gmOnly: gmDraw }] });
draftPts.current = []; setOverlay({});
}
return;
}
if (tool === 'ping' && phase === 'down') {
const at = p.point;
setOverlay({ cells: [`${p.col},${p.row}`] });
window.setTimeout(() => setOverlay((o) => (o.cells?.[0] === `${p.col},${p.row}` ? {} : o)), 1500);
void at;
}
};
const gridSpec = () => ({ gridSize: m.gridSize, feetPerCell: m.gridUnit.feet, cols: dims.cols, rows: dims.rows });
const finishPoly = () => { if (poly.length >= 3) commitReveal(polygonCells(poly, gridSpec()), tool === 'reveal'); setPoly([]); setOverlay({}); };
const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false }] });
const patchToken = (id: string, patch: Partial<MapToken>) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) });
const removeToken = (id: string) => update({ tokens: m.tokens.filter((t) => t.id !== id) });
const token = m.tokens.find((t) => t.id === editToken) ?? null;
return (
<div>
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
<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) => (
<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>
))}
</div>
{/* Contextual sub-toolbar */}
<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">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>
{(tool === 'reveal' || tool === 'hide') && (
<>
<span className="mx-1 h-4 w-px bg-line" />
{(['brush', 'rect', 'poly'] as FogShape[]).map((s) => (
<Button key={s} size="sm" variant={fogShape === s ? 'primary' : 'ghost'} onClick={() => { setFogShape(s); setPoly([]); setOverlay({}); }} className="capitalize">{s}</Button>
))}
{fogShape === 'brush' && <label className="flex items-center gap-1">Size<NumberField className="w-12" value={brush} min={0} max={5} onChange={setBrush} aria-label="Brush size" /></label>}
{fogShape === 'poly' && <Button size="sm" variant="secondary" disabled={poly.length < 3} onClick={finishPoly}>Finish polygon ({poly.length})</Button>}
<Button size="sm" variant="ghost" onClick={() => update({ revealed: revealAll(dims.cols, dims.rows) })}>Reveal all</Button>
<Button size="sm" variant="ghost" onClick={() => update({ revealed: hideAll() })}>Hide all</Button>
</>
)}
{tool === 'aoe' && (
<>
<span className="mx-1 h-4 w-px bg-line" />
{(['circle', 'cone', 'line', 'square'] as AoeShape[]).map((s) => (
<Button key={s} size="sm" variant={aoeShape === s ? 'primary' : 'ghost'} onClick={() => { setAoeShape(s); setOverlay({}); }} className="capitalize">{s}</Button>
))}
<label className="flex items-center gap-1">Feet<NumberField className="w-14" value={aoeFeet} min={5} max={120} onChange={setAoeFeet} aria-label="AoE size feet" /></label>
<Button size="sm" variant="ghost" onClick={() => setOverlay({})}>Clear</Button>
</>
)}
{tool === 'draw' && (
<>
<span className="mx-1 h-4 w-px bg-line" />
<Select className="h-8 w-28" value={drawKind} onChange={(e) => setDrawKind(e.target.value as DrawKind)} aria-label="Draw kind">
{(['freehand', 'line', 'arrow', 'rect', 'circle', 'text'] as DrawKind[]).map((k) => <option key={k} value={k}>{k}</option>)}
</Select>
<input type="color" value={drawColor} onChange={(e) => setDrawColor(e.target.value)} aria-label="Draw color" className="h-7 w-9" />
<label className="flex items-center gap-1"><input type="checkbox" checked={gmDraw} onChange={(e) => setGmDraw(e.target.checked)} /> GM only</label>
<Button size="sm" variant="ghost" disabled={m.drawings.length === 0} onClick={() => update({ drawings: m.drawings.slice(0, -1) })}>Undo draw</Button>
<Button size="sm" variant="ghost" disabled={m.drawings.length === 0} onClick={() => update({ drawings: [] })}>Clear drawings</Button>
</>
)}
<span className="mx-1 h-4 w-px bg-line" />
<Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button>
</div>
<MapCanvas
view={{ image: m.image, gridSize: m.gridSize, showGrid: m.showGrid, fogEnabled: m.fogEnabled, revealed: m.revealed, tokens: canvasTokens, drawings: m.drawings }}
overlay={overlay}
tokensDraggable={tool === 'move'}
{...(tool !== 'move' ? { onPointer } : {})}
onTokenMove={(id, col, row) => patchToken(id, { col, row })}
onTokenClick={(id) => setEditToken(id)}
onReady={setDims}
/>
<p className="mt-2 text-xs text-muted">
Click a token to edit it. Use <strong>Reveal/Hide</strong> (brush, rectangle, polygon) for fog,
<strong> Measure</strong>/<strong>AoE</strong> for ranges, <strong>Draw</strong> for annotations, then Open player view to show players.
</p>
{token && (
<Modal open onClose={() => setEditToken(null)} title="Token"
footer={<><Button variant="ghost" onClick={() => setEditToken(null)}>Done</Button><Button variant="danger" onClick={() => { removeToken(token.id); setEditToken(null); }}>Delete</Button></>}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<label className="block text-xs text-muted">Label<Input value={token.label} onChange={(e) => patchToken(token.id, { label: e.target.value })} /></label>
<label className="block text-xs text-muted">Size<Select value={token.size} onChange={(e) => patchToken(token.id, { size: Number(e.target.value) })}>{[1, 2, 3, 4].map((s) => <option key={s} value={s}>{s}×{s}</option>)}</Select></label>
<label className="block text-xs text-muted">Kind<Select value={token.kind} onChange={(e) => patchToken(token.id, { kind: e.target.value as MapToken['kind'] })}>{['pc', 'npc', 'monster', 'object'].map((k) => <option key={k} value={k}>{k}</option>)}</Select></label>
<label className="block text-xs text-muted">Link character<Select value={token.characterId ?? ''} onChange={(e) => patchToken(token.id, e.target.value ? { characterId: e.target.value } : { characterId: undefined })}><option value=""> none </option>{characters.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></label>
</div>
<div className="flex flex-wrap items-center gap-1">
{TOKEN_COLORS.map((c) => <button key={c} aria-label={`color ${c}`} onClick={() => patchToken(token.id, { color: c })} className={cn('h-6 w-6 rounded-full border', token.color === c ? 'border-accent' : 'border-line')} style={{ background: c }} />)}
</div>
<label className="flex items-center gap-2 text-sm text-ink"><input type="checkbox" checked={token.gmOnly} onChange={(e) => patchToken(token.id, { gmOnly: e.target.checked })} /> Hidden from players (GM-only)</label>
</div>
</Modal>
)}
</div>
);
}