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
+14 -8
View File
@@ -9,24 +9,30 @@ test.beforeEach(async ({ page }) => {
await page.reload(); await page.reload();
}); });
test('maps: upload, place a token, toggle fog', async ({ page }) => { test('maps: upload, place token, reveal all, show to players', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click(); await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('VTT'); await page.locator('input[data-autofocus]').fill('VTT');
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Dashboard' }).click(); await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Maps' }).click(); await page.getByRole('link', { name: 'Maps' }).click();
// Upload an image into the hidden file input
await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png'); await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png');
// Editor loads (Move tool button present) // Editor loads (Move tool present)
await expect(page.getByRole('button', { name: 'Move' })).toBeVisible(); await expect(page.getByRole('button', { name: 'move', exact: true })).toBeVisible();
// Add a token // Add a token
await page.getByRole('button', { name: '+ Token' }).click(); await page.getByRole('button', { name: '+ Token' }).click();
await expect(page.getByTitle(/double-click to remove/)).toBeVisible(); await expect(page.getByTestId('map-token')).toHaveCount(1);
// Toggle fog on (no crash) // Reveal tool exposes the contextual fog controls; reveal all
await page.getByText('Fog', { exact: true }).locator('input').check(); await page.getByRole('button', { name: 'reveal', exact: true }).click();
await expect(page.getByRole('button', { name: 'Reveal all' })).toBeVisible(); await page.getByText('Fog', { exact: true }).locator('input[type=checkbox]').check();
await page.getByRole('button', { name: 'Reveal all' }).click();
// Show to players, then the player view renders the battle map
await page.getByRole('button', { name: 'Show to players' }).click();
await page.getByRole('link', { name: 'Open player view ↗' }).click();
await expect(page.getByRole('heading', { name: 'Battle map' })).toBeVisible();
await expect(page.getByTestId('map-token')).toHaveCount(1);
}); });
+18
View File
@@ -0,0 +1,18 @@
import type { PlayerMap } from '@/lib/map';
import { MapCanvas, type CanvasToken } from '@/features/world/map/MapCanvas';
/** Read-only render of a player-facing map projection + its image. */
export function PlayerMapView({ map, image, maxWidth }: { map: PlayerMap; image?: string; maxWidth?: number }) {
const tokens: CanvasToken[] = map.tokens.map((t) => ({
id: t.id, label: t.label, color: t.color, col: t.col, row: t.row,
size: t.size, kind: t.kind, hp: t.hp, conditions: t.conditions,
}));
return (
<MapCanvas
readOnly
playerFog
{...(maxWidth !== undefined ? { maxWidth } : {})}
view={{ image, gridSize: map.gridSize, showGrid: map.showGrid, fogEnabled: map.fogEnabled, revealed: map.revealed, tokens, drawings: map.drawings }}
/>
);
}
+15 -10
View File
@@ -1,31 +1,29 @@
import type { Campaign, Combatant } from '@/lib/schemas'; import type { Campaign } from '@/lib/schemas';
import { getSystem } from '@/lib/rules'; import { getSystem } from '@/lib/rules';
import { localSync } from '@/lib/sync'; import { localSync } from '@/lib/sync';
import { enemyStatus } from '@/lib/combat/playerProjection';
import { toPlayerProjection } from '@/lib/map';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { useCharacters } from '@/features/characters/hooks'; import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks'; import { useEncounters } from '@/features/combat/hooks';
import { useCalendar } from '@/features/world/hooks'; import { useCalendar, useMaps } from '@/features/world/hooks';
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { PlayerMapView } from './PlayerMapView';
export function PlayerViewPage() { export function PlayerViewPage() {
return <RequireCampaign>{(c) => <PlayerView campaign={c} />}</RequireCampaign>; return <RequireCampaign>{(c) => <PlayerView campaign={c} />}</RequireCampaign>;
} }
/** Hide exact enemy HP from players — show a status band instead. */
function enemyStatus(c: Combatant): { label: string; cls: string } {
if (c.hp.current <= 0) return { label: 'Down', cls: 'text-danger' };
const frac = c.hp.max > 0 ? c.hp.current / c.hp.max : 1;
if (frac <= 0.5) return { label: 'Bloodied', cls: 'text-warning' };
return { label: 'Healthy', cls: 'text-success' };
}
function PlayerView({ campaign }: { campaign: Campaign }) { function PlayerView({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id); const characters = useCharacters(campaign.id);
const encounters = useEncounters(campaign.id); const encounters = useEncounters(campaign.id);
const calendar = useCalendar(campaign.id); const calendar = useCalendar(campaign.id);
const maps = useMaps(campaign.id);
const activeEncounterId = useUiStore((s) => s.activeEncounterId); const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const activeMapId = useUiStore((s) => s.activeMapId);
const activeMap = maps.find((mp) => mp.id === activeMapId) ?? null;
const sys = getSystem(campaign.system); const sys = getSystem(campaign.system);
const pcs = characters.filter((c) => c.kind === 'pc'); const pcs = characters.filter((c) => c.kind === 'pc');
@@ -48,6 +46,13 @@ function PlayerView({ campaign }: { campaign: Campaign }) {
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"> Fullscreen</Button> <Button variant="secondary" onClick={enterFullscreen} className="print:hidden"> Fullscreen</Button>
</div> </div>
{activeMap && (
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2>
<PlayerMapView map={toPlayerProjection(activeMap)} image={activeMap.image} maxWidth={1100} />
</section>
)}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
{/* Party */} {/* Party */}
<section> <section>
+30 -201
View File
@@ -1,17 +1,15 @@
import { useEffect, useRef, useState } from 'react'; import { useRef, useState } from 'react';
import type { BattleMap, Campaign, MapToken } from '@/lib/schemas'; import { Link } from '@tanstack/react-router';
import type { Campaign } from '@/lib/schemas';
import { mapsRepo } from '@/lib/db/repositories'; import { mapsRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids'; import { useUiStore } from '@/stores/uiStore';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useMaps } from './hooks'; import { useMaps } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { MapEditor } from './map/MapEditor';
const MAX_IMAGE_BYTES = 8 * 1024 * 1024; const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726'];
export function MapsPage() { export function MapsPage() {
return <RequireCampaign>{(c) => <Maps campaign={c} />}</RequireCampaign>; return <RequireCampaign>{(c) => <Maps campaign={c} />}</RequireCampaign>;
@@ -22,6 +20,8 @@ function Maps({ campaign }: { campaign: Campaign }) {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(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 selected = maps.find((m) => m.id === selectedId) ?? null;
const upload = (file: File) => { const upload = (file: File) => {
@@ -44,210 +44,39 @@ function Maps({ campaign }: { campaign: Campaign }) {
subtitle={campaign.name} subtitle={campaign.name}
actions={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Upload map</Button>} actions={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Upload map</Button>}
/> />
<input <input ref={fileRef} type="file" accept="image/*" className="hidden"
ref={fileRef} onChange={(e) => { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }} />
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>} {error && <p className="mb-3 text-sm text-danger">{error}</p>}
{maps.length === 0 ? ( {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"> <ul className="space-y-1">
{maps.map((m) => ( {maps.map((mp) => (
<li key={m.id} className="group flex items-center gap-1 rounded-md border border-line"> <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 === m.id ? 'text-accent' : 'text-ink')} onClick={() => setSelectedId(m.id)}>{m.name}</button> <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)}>
<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> {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> </li>
))} ))}
</ul> </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> </div>
)} )}
</Page> </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>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { Combatant, Encounter } from '@/lib/schemas';
export interface EnemyStatus {
label: 'Healthy' | 'Bloodied' | 'Down';
cls: string;
}
/** Hide exact enemy HP from players — a status band instead. */
export function enemyStatus(c: Pick<Combatant, 'hp'>): EnemyStatus {
if (c.hp.current <= 0) return { label: 'Down', cls: 'text-danger' };
const frac = c.hp.max > 0 ? c.hp.current / c.hp.max : 1;
if (frac <= 0.5) return { label: 'Bloodied', cls: 'text-warning' };
return { label: 'Healthy', cls: 'text-success' };
}
export interface PlayerCombatant {
id: string;
name: string;
kind: Combatant['kind'];
initiative: number;
isCurrent: boolean;
/** exact HP for PCs; undefined for hidden enemies */
hp?: { current: number; max: number; temp: number };
status?: EnemyStatus;
}
export interface PlayerEncounter {
id: string;
name: string;
round: number;
combatants: PlayerCombatant[];
}
/** Build the player-safe view of an encounter (enemy HP masked). */
export function toPlayerEncounter(e: Encounter): PlayerEncounter {
return {
id: e.id,
name: e.name,
round: e.round,
combatants: e.combatants.map((c, idx) => {
const isPC = c.kind === 'pc';
return {
id: c.id,
name: c.name,
kind: c.kind,
initiative: c.initiative,
isCurrent: idx === e.turnIndex,
...(isPC ? { hp: c.hp } : { status: enemyStatus(c) }),
};
}),
};
}
+22
View File
@@ -74,6 +74,28 @@ export class TtrpgDatabase extends Dexie {
// v7 — Phase 13 assistant: encounters gain optional partyLevelsSnapshot + // v7 — Phase 13 assistant: encounters gain optional partyLevelsSnapshot +
// outcome. Both optional, so existing rows stay valid; no backfill needed. // outcome. Both optional, so existing rows stay valid; no backfill needed.
this.version(7).stores({}); this.version(7).stores({});
// v8 — Phase 17 VTT overhaul: maps gain drawings/gridUnit/gridType/fogVersion;
// tokens gain size/kind/conditions/gmOnly. All defaulted, so backfill is
// belt-and-suspenders (repo re-parses on save too).
this.version(8).stores({}).upgrade(async (tx) => {
await tx
.table('maps')
.toCollection()
.modify((m: Record<string, unknown>) => {
if (m.drawings === undefined) m.drawings = [];
if (m.gridUnit === undefined) m.gridUnit = { feet: 5 };
if (m.gridType === undefined) m.gridType = 'square';
if (m.fogVersion === undefined) m.fogVersion = 1;
const tokens = Array.isArray(m.tokens) ? (m.tokens as Record<string, unknown>[]) : [];
for (const t of tokens) {
if (t.size === undefined) t.size = 1;
if (t.kind === undefined) t.kind = 'npc';
if (t.conditions === undefined) t.conditions = [];
if (t.gmOnly === undefined) t.gmOnly = false;
}
});
});
} }
} }
+11
View File
@@ -299,9 +299,20 @@ export const mapsRepo = {
await db.maps.add(map); await db.maps.add(map);
return map; return map;
}, },
get(id: string): Promise<BattleMap | undefined> {
return db.maps.get(id);
},
async save(map: BattleMap): Promise<void> { async save(map: BattleMap): Promise<void> {
await db.maps.put(battleMapSchema.parse({ ...map, updatedAt: now() })); await db.maps.put(battleMapSchema.parse({ ...map, updatedAt: now() }));
}, },
/** Transactional read-modify-write (mirrors encountersRepo.mutate). */
async mutate(id: string, fn: (m: BattleMap) => BattleMap): Promise<void> {
await db.transaction('rw', db.maps, async () => {
const current = await db.maps.get(id);
if (!current) return;
await db.maps.put(battleMapSchema.parse({ ...fn(current), updatedAt: now() }));
});
},
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
await db.maps.delete(id); await db.maps.delete(id);
}, },
+21
View File
@@ -0,0 +1,21 @@
import type { Cell, DistanceMode } from './types';
/**
* Distance between two cells, in cells.
* - 5e: Chebyshev (diagonals cost 1).
* - pf2e: diagonals alternate 1,2,1,2 (the 5-10-5 rule).
* - euclid: straight-line, rounded.
*/
export function gridDistance(a: Cell, b: Cell, mode: DistanceMode = '5e'): number {
const dx = Math.abs(a.col - b.col);
const dy = Math.abs(a.row - b.row);
const diag = Math.min(dx, dy);
const straight = Math.max(dx, dy) - diag;
if (mode === 'euclid') return Math.round(Math.hypot(dx, dy));
if (mode === 'pf2e') return straight + diag + Math.floor(diag / 2);
return straight + diag; // 5e Chebyshev
}
export function toFeet(cells: number, feetPerCell: number): number {
return cells * feetPerCell;
}
+22
View File
@@ -0,0 +1,22 @@
import { cellKey } from './grid';
/** Immutable fog set algebra over the `revealed` cell-key array. */
export function applyReveal(revealed: string[], cells: string[]): string[] {
return [...new Set([...revealed, ...cells])];
}
export function applyHide(revealed: string[], cells: string[]): string[] {
const drop = new Set(cells);
return revealed.filter((k) => !drop.has(k));
}
export function revealAll(cols: number, rows: number): string[] {
const all: string[] = [];
for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) all.push(cellKey(c, r));
return all;
}
export function hideAll(): string[] {
return [];
}
+38
View File
@@ -0,0 +1,38 @@
import type { Cell, Point } from './types';
export function cellKey(col: number, row: number): string {
return `${col},${row}`;
}
export function parseCellKey(key: string): Cell {
const [c, r] = key.split(',');
return { col: Number(c), row: Number(r) };
}
/** Image-space pixel → grid cell. */
export function worldToCell(pt: Point, gridSize: number): Cell {
return { col: Math.floor(pt.x / gridSize), row: Math.floor(pt.y / gridSize) };
}
/** Center of a cell in image-space pixels. */
export function cellCenter(col: number, row: number, gridSize: number): Point {
return { x: (col + 0.5) * gridSize, y: (row + 0.5) * gridSize };
}
export function gridDims(naturalW: number, naturalH: number, gridSize: number): { cols: number; rows: number } {
return { cols: Math.max(0, Math.ceil(naturalW / gridSize)), rows: Math.max(0, Math.ceil(naturalH / gridSize)) };
}
export function inBounds(col: number, row: number, cols: number, rows: number): boolean {
return col >= 0 && row >= 0 && col < cols && row < rows;
}
/** Cells covered by an NxN token whose top-left is (col,row). */
export function tokenCells(token: { col: number; row: number; size: number }): string[] {
const out: string[] = [];
const n = Math.max(1, token.size);
for (let c = token.col; c < token.col + n; c++) {
for (let r = token.row; r < token.row + n; r++) out.push(cellKey(c, r));
}
return out;
}
+6
View File
@@ -0,0 +1,6 @@
export * from './types';
export * from './grid';
export * from './distance';
export * from './shapes';
export * from './fog';
export * from './projection';
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import {
cellKey, parseCellKey, worldToCell, gridDims, tokenCells,
gridDistance, toFeet,
brushCells, rectCells, pointInPolygon, polygonCells, circleCells, coneCells, lineCells, squareCells,
applyReveal, applyHide, revealAll, hideAll,
type GridSpec,
} from './index';
const grid: GridSpec = { gridSize: 50, feetPerCell: 5, cols: 10, rows: 10 };
describe('grid', () => {
it('round-trips cell keys and converts world→cell', () => {
expect(cellKey(2, 3)).toBe('2,3');
expect(parseCellKey('4,7')).toEqual({ col: 4, row: 7 });
expect(worldToCell({ x: 120, y: 40 }, 50)).toEqual({ col: 2, row: 0 });
});
it('computes grid dims and NxN token footprints', () => {
expect(gridDims(500, 300, 50)).toEqual({ cols: 10, rows: 6 });
expect(tokenCells({ col: 1, row: 1, size: 2 }).sort()).toEqual(['1,1', '1,2', '2,1', '2,2'].sort());
});
});
describe('distance', () => {
it('5e Chebyshev, pf2e 5-10-5, euclidean', () => {
const a = { col: 0, row: 0 };
expect(gridDistance(a, { col: 3, row: 0 }, '5e')).toBe(3);
expect(gridDistance(a, { col: 3, row: 3 }, '5e')).toBe(3); // diagonals free
expect(gridDistance(a, { col: 2, row: 2 }, 'pf2e')).toBe(3); // 1 + 2 (second diagonal costs 2)
expect(gridDistance(a, { col: 3, row: 4 }, 'euclid')).toBe(5);
expect(toFeet(3, 5)).toBe(15);
});
});
describe('reveal/hide tools', () => {
it('brush covers a square block, clamped to bounds', () => {
expect(brushCells(0, 0, 1, 10, 10).sort()).toEqual(['0,0', '0,1', '1,0', '1,1'].sort());
});
it('rect is inclusive', () => {
expect(rectCells({ col: 0, row: 0 }, { col: 1, row: 1 }, 10, 10).sort())
.toEqual(['0,0', '0,1', '1,0', '1,1'].sort());
});
it('polygon point-in + rasterize', () => {
const square = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }];
expect(pointInPolygon({ x: 50, y: 50 }, square)).toBe(true);
expect(pointInPolygon({ x: 150, y: 50 }, square)).toBe(false);
// cells whose centers (25,25 / 75,25 / 25,75 / 75,75) fall inside
expect(polygonCells(square, grid).sort()).toEqual(['0,0', '0,1', '1,0', '1,1'].sort());
});
});
describe('AoE templates', () => {
it('circle covers cells within radius', () => {
const cells = circleCells({ x: 25, y: 25 }, 5, grid); // 5ft = 1 cell radius
expect(cells).toContain('0,0');
expect(cells).not.toContain('5,5');
});
it('square, cone, line return non-empty within bounds', () => {
expect(squareCells({ x: 100, y: 100 }, 15, grid).length).toBeGreaterThan(0);
expect(coneCells({ x: 0, y: 0 }, { x: 100, y: 0 }, 15, 53, grid)).toContain('1,0');
expect(lineCells({ x: 0, y: 25 }, { x: 250, y: 25 }, 5, grid)).toContain('0,0');
});
});
describe('fog set algebra', () => {
it('reveal dedupes, hide removes, all/none', () => {
expect(applyReveal(['0,0'], ['0,0', '1,1']).sort()).toEqual(['0,0', '1,1'].sort());
expect(applyHide(['0,0', '1,1'], ['0,0'])).toEqual(['1,1']);
expect(revealAll(2, 2).sort()).toEqual(['0,0', '0,1', '1,0', '1,1'].sort());
expect(hideAll()).toEqual([]);
});
});
+38
View File
@@ -0,0 +1,38 @@
import type { BattleMap } from '@/lib/schemas';
import type { PlayerDrawing, PlayerMap, PlayerToken } from './types';
/**
* Reduce a full BattleMap to what players may see: GM-only tokens/drawings are
* removed; the image data URL is excluded (it travels separately / from local
* Dexie). Revealed cells pass through (the player renderer fogs the rest).
*/
export function toPlayerProjection(map: BattleMap): PlayerMap {
const tokens: PlayerToken[] = map.tokens
.filter((t) => !t.gmOnly)
.map((t) => ({
id: t.id, label: t.label, color: t.color, col: t.col, row: t.row, size: t.size, kind: t.kind,
...(t.hp ? { hp: t.hp } : {}),
conditions: t.conditions.map((c) => ({ name: c.name, ...(c.value !== undefined ? { value: c.value } : {}) })),
}));
const drawings: PlayerDrawing[] = map.drawings
.filter((d) => !d.gmOnly)
.map((d) => ({
id: d.id, kind: d.kind, points: d.points, color: d.color, width: d.width,
...(d.text !== undefined ? { text: d.text } : {}),
}));
return {
id: map.id,
name: map.name,
gridSize: map.gridSize,
showGrid: map.showGrid,
fogEnabled: map.fogEnabled,
revealed: map.revealed,
gridType: map.gridType,
gridUnit: map.gridUnit,
tokens,
drawings,
hasImage: !!map.image,
};
}
+103
View File
@@ -0,0 +1,103 @@
import type { GridSpec, Point } from './types';
import { cellCenter, cellKey, inBounds } from './grid';
// ---- Reveal/hide authoring tools → cell keys ----
/** Square brush of radius `r` cells centered on (col,row): a (2r+1)² block. */
export function brushCells(col: number, row: number, r: number, cols: number, rows: number): string[] {
const out: string[] = [];
const rr = Math.max(0, Math.floor(r));
for (let c = col - rr; c <= col + rr; c++) {
for (let rw = row - rr; rw <= row + rr; rw++) {
if (inBounds(c, rw, cols, rows)) out.push(cellKey(c, rw));
}
}
return out;
}
/** Inclusive rectangle between two cells. */
export function rectCells(a: { col: number; row: number }, b: { col: number; row: number }, cols: number, rows: number): string[] {
const out: string[] = [];
const c0 = Math.min(a.col, b.col), c1 = Math.max(a.col, b.col);
const r0 = Math.min(a.row, b.row), r1 = Math.max(a.row, b.row);
for (let c = c0; c <= c1; c++) for (let r = r0; r <= r1; r++) if (inBounds(c, r, cols, rows)) out.push(cellKey(c, r));
return out;
}
/** Ray-casting point-in-polygon (verts in any units consistent with `pt`). */
export function pointInPolygon(pt: Point, verts: Point[]): boolean {
let inside = false;
for (let i = 0, j = verts.length - 1; i < verts.length; j = i++) {
const a = verts[i]!, b = verts[j]!;
const intersect = a.y > pt.y !== b.y > pt.y && pt.x < ((b.x - a.x) * (pt.y - a.y)) / (b.y - a.y) + a.x;
if (intersect) inside = !inside;
}
return inside;
}
/** Rasterize a polygon (image-space px verts) to covered cell keys. */
export function polygonCells(verts: Point[], grid: GridSpec): string[] {
if (verts.length < 3) return [];
const out: string[] = [];
for (let c = 0; c < grid.cols; c++) {
for (let r = 0; r < grid.rows; r++) {
if (pointInPolygon(cellCenter(c, r, grid.gridSize), verts)) out.push(cellKey(c, r));
}
}
return out;
}
// ---- AoE templates (image-space px) → covered cell keys ----
function feetToPx(feet: number, grid: GridSpec): number {
return (feet / grid.feetPerCell) * grid.gridSize;
}
function forEachCellCenter(grid: GridSpec, fn: (c: number, r: number, center: Point) => void): void {
for (let c = 0; c < grid.cols; c++) for (let r = 0; r < grid.rows; r++) fn(c, r, cellCenter(c, r, grid.gridSize));
}
export function circleCells(center: Point, radiusFeet: number, grid: GridSpec): string[] {
const rPx = feetToPx(radiusFeet, grid);
const out: string[] = [];
forEachCellCenter(grid, (c, r, p) => { if (Math.hypot(p.x - center.x, p.y - center.y) <= rPx) out.push(cellKey(c, r)); });
return out;
}
export function squareCells(center: Point, sideFeet: number, grid: GridSpec): string[] {
const half = feetToPx(sideFeet, grid) / 2;
const out: string[] = [];
forEachCellCenter(grid, (c, r, p) => {
if (Math.abs(p.x - center.x) <= half && Math.abs(p.y - center.y) <= half) out.push(cellKey(c, r));
});
return out;
}
export function coneCells(origin: Point, target: Point, lengthFeet: number, angleDeg: number, grid: GridSpec): string[] {
const lenPx = feetToPx(lengthFeet, grid);
const dir = Math.atan2(target.y - origin.y, target.x - origin.x);
const halfAngle = (angleDeg * Math.PI) / 180 / 2;
const out: string[] = [];
forEachCellCenter(grid, (c, r, p) => {
const vx = p.x - origin.x, vy = p.y - origin.y;
const dist = Math.hypot(vx, vy);
if (dist === 0 || dist > lenPx) return;
let da = Math.abs(Math.atan2(vy, vx) - dir);
if (da > Math.PI) da = 2 * Math.PI - da;
if (da <= halfAngle) out.push(cellKey(c, r));
});
return out;
}
export function lineCells(a: Point, b: Point, widthFeet: number, grid: GridSpec): string[] {
const halfW = feetToPx(widthFeet, grid) / 2;
const out: string[] = [];
const dx = b.x - a.x, dy = b.y - a.y;
const len2 = dx * dx + dy * dy;
forEachCellCenter(grid, (c, r, p) => {
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
const px = a.x + t * dx, py = a.y + t * dy;
if (Math.hypot(p.x - px, p.y - py) <= halfW) out.push(cellKey(c, r));
});
return out;
}
+59
View File
@@ -0,0 +1,59 @@
/** Shared, dependency-free map geometry + projection types (client + server). */
export interface Cell {
col: number;
row: number;
}
export interface Point {
x: number;
y: number;
}
/** Everything geometry needs about the current grid, in natural image pixels. */
export interface GridSpec {
gridSize: number; // px per cell (natural image space)
feetPerCell: number;
cols: number;
rows: number;
}
export type DistanceMode = '5e' | 'pf2e' | 'euclid';
// ---- Player-facing projection (what a player may see) ----
export interface PlayerToken {
id: string;
label: string;
color: string;
col: number;
row: number;
size: number;
kind: 'pc' | 'npc' | 'monster' | 'object';
hp?: { current: number; max: number; temp: number };
conditions: { name: string; value?: number }[];
}
export interface PlayerDrawing {
id: string;
kind: 'freehand' | 'line' | 'rect' | 'circle' | 'arrow' | 'text';
points: Point[];
color: string;
width: number;
text?: string;
}
export interface PlayerMap {
id: string;
name: string;
gridSize: number;
showGrid: boolean;
fogEnabled: boolean;
revealed: string[];
gridType: 'square' | 'hex';
gridUnit: { feet: number };
tokens: PlayerToken[];
drawings: PlayerDrawing[];
/** whether an image exists (the data URL itself travels separately) */
hasImage: boolean;
}
+34 -2
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { int, systemIdSchema } from './common'; import { int, num, systemIdSchema, hpSchema, conditionSchema } from './common';
// ---------------- Notes / Wiki ---------------- // ---------------- Notes / Wiki ----------------
export const noteSchema = z.object({ export const noteSchema = z.object({
@@ -90,12 +90,37 @@ export const mapTokenSchema = z.object({
id: z.string(), id: z.string(),
label: z.string().max(40).default(''), label: z.string().max(40).default(''),
color: z.string().max(20).default('#d4af37'), color: z.string().max(20).default('#d4af37'),
/** grid cell coordinates */ /** grid cell coordinates (top-left cell of the footprint) */
col: int, col: int,
row: int, row: int,
/** NxN footprint (1 = one cell) */
size: int.min(1).max(4).default(1),
kind: z.enum(['pc', 'npc', 'monster', 'object']).default('npc'),
/** optional link to a Character for a live HP ring / conditions */
characterId: z.string().optional(),
/** denormalized HP snapshot (used when no live combatant drives the ring) */
hp: hpSchema.optional(),
conditions: z.array(conditionSchema).default([]),
/** hidden from the player-facing projection */
gmOnly: z.boolean().default(false),
}); });
export type MapToken = z.infer<typeof mapTokenSchema>; export type MapToken = z.infer<typeof mapTokenSchema>;
/** A point in image-space pixels (scale-independent, network-portable). */
export const pointSchema = z.object({ x: num, y: num });
export type MapPoint = z.infer<typeof pointSchema>;
export const drawingSchema = z.object({
id: z.string(),
kind: z.enum(['freehand', 'line', 'rect', 'circle', 'arrow', 'text']),
points: z.array(pointSchema).default([]),
color: z.string().max(20).default('#d4af37'),
width: num.min(1).max(40).default(3),
text: z.string().max(200).optional(),
gmOnly: z.boolean().default(false),
});
export type MapDrawing = z.infer<typeof drawingSchema>;
export const battleMapSchema = z.object({ export const battleMapSchema = z.object({
id: z.string(), id: z.string(),
campaignId: z.string(), campaignId: z.string(),
@@ -108,6 +133,13 @@ export const battleMapSchema = z.object({
/** revealed cell keys "col,row"; everything else is fogged when enabled */ /** revealed cell keys "col,row"; everything else is fogged when enabled */
revealed: z.array(z.string()).default([]), revealed: z.array(z.string()).default([]),
tokens: z.array(mapTokenSchema).default([]), tokens: z.array(mapTokenSchema).default([]),
/** GM annotation layer (some entries may be gmOnly) */
drawings: z.array(drawingSchema).default([]),
/** real-world feet per grid cell, for measurement + AoE labels */
gridUnit: z.object({ feet: int.min(1).max(100).default(5) }).default({ feet: 5 }),
gridType: z.enum(['square', 'hex']).default('square'),
/** fog model version, for future migrations */
fogVersion: int.default(1),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
}); });
+6 -1
View File
@@ -9,10 +9,13 @@ interface UiState {
activeCampaignId: string | null; activeCampaignId: string | null;
/** encounter currently open in the combat tracker (so the bestiary can target it) */ /** encounter currently open in the combat tracker (so the bestiary can target it) */
activeEncounterId: string | null; activeEncounterId: string | null;
/** map currently projected to the player view */
activeMapId: string | null;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
toggleTheme: () => void; toggleTheme: () => void;
setActiveCampaign: (id: string | null) => void; setActiveCampaign: (id: string | null) => void;
setActiveEncounter: (id: string | null) => void; setActiveEncounter: (id: string | null) => void;
setActiveMap: (id: string | null) => void;
} }
export const useUiStore = create<UiState>()( export const useUiStore = create<UiState>()(
@@ -21,13 +24,15 @@ export const useUiStore = create<UiState>()(
theme: 'dark', theme: 'dark',
activeCampaignId: null, activeCampaignId: null,
activeEncounterId: null, activeEncounterId: null,
activeMapId: null,
setTheme: (theme) => { setTheme: (theme) => {
applyTheme(theme); applyTheme(theme);
set({ theme }); set({ theme });
}, },
toggleTheme: () => get().setTheme(get().theme === 'dark' ? 'light' : 'dark'), toggleTheme: () => get().setTheme(get().theme === 'dark' ? 'light' : 'dark'),
setActiveCampaign: (id) => set({ activeCampaignId: id, activeEncounterId: null }), setActiveCampaign: (id) => set({ activeCampaignId: id, activeEncounterId: null, activeMapId: null }),
setActiveEncounter: (id) => set({ activeEncounterId: id }), setActiveEncounter: (id) => set({ activeEncounterId: id }),
setActiveMap: (id) => set({ activeMapId: id }),
}), }),
{ {
name: 'ttrpg-ui', name: 'ttrpg-ui',
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} {"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}