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:
+30
-201
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user