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:
+14
-8
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) }),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './grid';
|
||||||
|
export * from './distance';
|
||||||
|
export * from './shapes';
|
||||||
|
export * from './fog';
|
||||||
|
export * from './projection';
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
Reference in New Issue
Block a user