From fb459ad92cd4bc67ae815aea36849442e0ba0b62 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 10:31:26 +0200 Subject: [PATCH] Phase 17: map & fog VTT overhaul + player-facing projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- e2e/maps.spec.ts | 22 ++- src/features/player/PlayerMapView.tsx | 18 ++ src/features/player/PlayerViewPage.tsx | 25 ++- src/features/world/MapsPage.tsx | 231 +++------------------- src/features/world/map/MapCanvas.tsx | 260 +++++++++++++++++++++++++ src/features/world/map/MapEditor.tsx | 225 +++++++++++++++++++++ src/lib/combat/playerProjection.ts | 52 +++++ src/lib/db/db.ts | 22 +++ src/lib/db/repositories.ts | 11 ++ src/lib/map/distance.ts | 21 ++ src/lib/map/fog.ts | 22 +++ src/lib/map/grid.ts | 38 ++++ src/lib/map/index.ts | 6 + src/lib/map/map.test.ts | 72 +++++++ src/lib/map/projection.ts | 38 ++++ src/lib/map/shapes.ts | 103 ++++++++++ src/lib/map/types.ts | 59 ++++++ src/lib/schemas/world.ts | 36 +++- src/stores/uiStore.ts | 7 +- tsconfig.app.tsbuildinfo | 2 +- 20 files changed, 1047 insertions(+), 223 deletions(-) create mode 100644 src/features/player/PlayerMapView.tsx create mode 100644 src/features/world/map/MapCanvas.tsx create mode 100644 src/features/world/map/MapEditor.tsx create mode 100644 src/lib/combat/playerProjection.ts create mode 100644 src/lib/map/distance.ts create mode 100644 src/lib/map/fog.ts create mode 100644 src/lib/map/grid.ts create mode 100644 src/lib/map/index.ts create mode 100644 src/lib/map/map.test.ts create mode 100644 src/lib/map/projection.ts create mode 100644 src/lib/map/shapes.ts create mode 100644 src/lib/map/types.ts diff --git a/e2e/maps.spec.ts b/e2e/maps.spec.ts index ed40d12..76abb15 100644 --- a/e2e/maps.spec.ts +++ b/e2e/maps.spec.ts @@ -9,24 +9,30 @@ test.beforeEach(async ({ page }) => { 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.locator('input[data-autofocus]').fill('VTT'); await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('link', { name: 'Dashboard' }).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'); - // Editor loads (Move tool button present) - await expect(page.getByRole('button', { name: 'Move' })).toBeVisible(); + // Editor loads (Move tool present) + await expect(page.getByRole('button', { name: 'move', exact: true })).toBeVisible(); // Add a token 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) - await page.getByText('Fog', { exact: true }).locator('input').check(); - await expect(page.getByRole('button', { name: 'Reveal all' })).toBeVisible(); + // Reveal tool exposes the contextual fog controls; reveal all + await page.getByRole('button', { name: 'reveal', exact: true }).click(); + 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); }); diff --git a/src/features/player/PlayerMapView.tsx b/src/features/player/PlayerMapView.tsx new file mode 100644 index 0000000..5ab971d --- /dev/null +++ b/src/features/player/PlayerMapView.tsx @@ -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 ( + + ); +} diff --git a/src/features/player/PlayerViewPage.tsx b/src/features/player/PlayerViewPage.tsx index 047be8c..64228ac 100644 --- a/src/features/player/PlayerViewPage.tsx +++ b/src/features/player/PlayerViewPage.tsx @@ -1,31 +1,29 @@ -import type { Campaign, Combatant } from '@/lib/schemas'; +import type { Campaign } from '@/lib/schemas'; import { getSystem } from '@/lib/rules'; import { localSync } from '@/lib/sync'; +import { enemyStatus } from '@/lib/combat/playerProjection'; +import { toPlayerProjection } from '@/lib/map'; import { useUiStore } from '@/stores/uiStore'; import { useCharacters } from '@/features/characters/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 { Button } from '@/components/ui/Button'; import { cn } from '@/lib/cn'; +import { PlayerMapView } from './PlayerMapView'; export function PlayerViewPage() { return {(c) => }; } -/** 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 }) { const characters = useCharacters(campaign.id); const encounters = useEncounters(campaign.id); const calendar = useCalendar(campaign.id); + const maps = useMaps(campaign.id); 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 pcs = characters.filter((c) => c.kind === 'pc'); @@ -48,6 +46,13 @@ function PlayerView({ campaign }: { campaign: Campaign }) { + {activeMap && ( +
+

Battle map

+ +
+ )} +
{/* Party */}
diff --git a/src/features/world/MapsPage.tsx b/src/features/world/MapsPage.tsx index cc41e03..7c10d5a 100644 --- a/src/features/world/MapsPage.tsx +++ b/src/features/world/MapsPage.tsx @@ -1,17 +1,15 @@ -import { useEffect, useRef, useState } from 'react'; -import type { BattleMap, Campaign, MapToken } from '@/lib/schemas'; +import { useRef, useState } from 'react'; +import { Link } from '@tanstack/react-router'; +import type { Campaign } from '@/lib/schemas'; import { mapsRepo } from '@/lib/db/repositories'; -import { newId } from '@/lib/ids'; -import { useDebouncedCallback } from '@/lib/useDebouncedCallback'; +import { useUiStore } from '@/stores/uiStore'; import { useMaps } from './hooks'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; -import { NumberField } from '@/components/ui/NumberField'; import { cn } from '@/lib/cn'; +import { MapEditor } from './map/MapEditor'; const MAX_IMAGE_BYTES = 8 * 1024 * 1024; -const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726']; export function MapsPage() { return {(c) => }; @@ -22,6 +20,8 @@ function Maps({ campaign }: { campaign: Campaign }) { const [selectedId, setSelectedId] = useState(null); const [error, setError] = useState(null); const fileRef = useRef(null); + const activeMapId = useUiStore((s) => s.activeMapId); + const setActiveMap = useUiStore((s) => s.setActiveMap); const selected = maps.find((m) => m.id === selectedId) ?? null; const upload = (file: File) => { @@ -44,210 +44,39 @@ function Maps({ campaign }: { campaign: Campaign }) { subtitle={campaign.name} actions={} /> - { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }} - /> + { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }} /> {error &&

{error}

} {maps.length === 0 ? ( - fileRef.current?.click()}>+ Upload map} /> + fileRef.current?.click()}>+ Upload map} /> ) : ( -
+
    - {maps.map((m) => ( -
  • - - + {maps.map((mp) => ( +
  • + +
  • ))}
- {selected ? :
Select a map.
} + {selected ? ( +
+
+ + Open player view ↗ +
+ +
+ ) : ( +
Select a map.
+ )}
)} ); } - -type Tool = 'move' | 'reveal' | 'hide'; - -function MapEditor({ map }: { map: BattleMap }) { - const [m, setM] = useState(map); - const [tool, setTool] = useState('move'); - const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); - const fogRef = useRef(null); - const painting = useRef(false); - - const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400); - const update = (patch: Partial) => 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) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...p } : t)) }); - - return ( -
- {/* Toolbar */} -
- update({ name: e.target.value })} aria-label="Map name" /> -
- {(['move', 'reveal', 'hide'] as Tool[]).map((t) => ( - - ))} -
- - - - - - -
- - {!natural ? ( -

Loading map…

- ) : ( -
-
- {m.name} - {m.showGrid && ( -
- )} - {/* Fog + paint surface (also catches clicks in reveal/hide mode) */} - { painting.current = true; paint(e); }} - onPointerMove={(e) => { if (painting.current) paint(e); }} - /> - {/* Tokens */} - {m.tokens.map((t) => ( - patchToken(t.id, { col, row })} - onRemove={() => update({ tokens: m.tokens.filter((x) => x.id !== t.id) })} - /> - ))} -
-
- )} -

Tip: use Reveal/Hide to paint fog; Move to drag tokens.

-
- ); -} - -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(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 ( -
- {token.label} -
- ); -} diff --git a/src/features/world/map/MapCanvas.tsx b/src/features/world/map/MapCanvas.tsx new file mode 100644 index 0000000..104b7d8 --- /dev/null +++ b/src/features/world/map/MapCanvas.tsx @@ -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(null); + const drawRef = useRef(null); + const overlayRef = useRef(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

No image.

; + if (!natural) return

Loading map…

; + + const interactive = !readOnly && !!onPointer; + return ( +
+
+ + {view.showGrid && ( +
+ )} + + + + {interactive && ( +
{ (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) => ( + + ))} +
+
+ ); +} + +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(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 ( +
+ {frac !== null && ( + + + 0.5 ? '#66bb6a' : frac > 0 ? '#ffa726' : '#ef5350'} + strokeWidth="2.5" strokeDasharray={`${frac * 106.8} 106.8`} transform="rotate(-90 18 18)" strokeLinecap="round" /> + + )} + {token.label} + {token.conditions.length > 0 && ( + c.name).join(', ')}> + {token.conditions.length} + + )} +
+ ); +} diff --git a/src/features/world/map/MapEditor.tsx b/src/features/world/map/MapEditor.tsx new file mode 100644 index 0000000..6c70252 --- /dev/null +++ b/src/features/world/map/MapEditor.tsx @@ -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(map); + const characters = useCharacters(campaign.id); + const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400); + const update = (patch: Partial) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; }); + + const [tool, setTool] = useState('move'); + const [fogShape, setFogShape] = useState('brush'); + const [brush, setBrush] = useState(0); + const [aoeShape, setAoeShape] = useState('circle'); + const [aoeFeet, setAoeFeet] = useState(20); + const [drawKind, setDrawKind] = useState('freehand'); + const [drawColor, setDrawColor] = useState('#d4af37'); + const [gmDraw, setGmDraw] = useState(true); + const [dims, setDims] = useState({ cols: 0, rows: 0 }); + const [editToken, setEditToken] = useState(null); + + // transient interaction state + const [overlay, setOverlay] = useState({}); + const anchor = useRef(null); + const [poly, setPoly] = useState([]); + const draftPts = useRef([]); + + 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) => 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 ( +
+
+ update({ name: e.target.value })} aria-label="Map name" /> + {(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping'] as Tool[]).map((t) => ( + + ))} +
+ + {/* Contextual sub-toolbar */} +
+ + + + + + {(tool === 'reveal' || tool === 'hide') && ( + <> + + {(['brush', 'rect', 'poly'] as FogShape[]).map((s) => ( + + ))} + {fogShape === 'brush' && } + {fogShape === 'poly' && } + + + + )} + {tool === 'aoe' && ( + <> + + {(['circle', 'cone', 'line', 'square'] as AoeShape[]).map((s) => ( + + ))} + + + + )} + {tool === 'draw' && ( + <> + + + setDrawColor(e.target.value)} aria-label="Draw color" className="h-7 w-9" /> + + + + + )} + + +
+ + patchToken(id, { col, row })} + onTokenClick={(id) => setEditToken(id)} + onReady={setDims} + /> +

+ Click a token to edit it. Use Reveal/Hide (brush, rectangle, polygon) for fog, + Measure/AoE for ranges, Draw for annotations, then “Open player view” to show players. +

+ + {token && ( + setEditToken(null)} title="Token" + footer={<>}> +
+
+ + + + +
+
+ {TOKEN_COLORS.map((c) =>
+ +
+
+ )} +
+ ); +} diff --git a/src/lib/combat/playerProjection.ts b/src/lib/combat/playerProjection.ts new file mode 100644 index 0000000..063f748 --- /dev/null +++ b/src/lib/combat/playerProjection.ts @@ -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): 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) }), + }; + }), + }; +} diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts index 8785dae..97473c2 100644 --- a/src/lib/db/db.ts +++ b/src/lib/db/db.ts @@ -74,6 +74,28 @@ export class TtrpgDatabase extends Dexie { // v7 — Phase 13 assistant: encounters gain optional partyLevelsSnapshot + // outcome. Both optional, so existing rows stay valid; no backfill needed. 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) => { + 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[]) : []; + 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; + } + }); + }); } } diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index c129fc0..f7e9a40 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -299,9 +299,20 @@ export const mapsRepo = { await db.maps.add(map); return map; }, + get(id: string): Promise { + return db.maps.get(id); + }, async save(map: BattleMap): Promise { 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 { + 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 { await db.maps.delete(id); }, diff --git a/src/lib/map/distance.ts b/src/lib/map/distance.ts new file mode 100644 index 0000000..94eb9cc --- /dev/null +++ b/src/lib/map/distance.ts @@ -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; +} diff --git a/src/lib/map/fog.ts b/src/lib/map/fog.ts new file mode 100644 index 0000000..b4c4b57 --- /dev/null +++ b/src/lib/map/fog.ts @@ -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 []; +} diff --git a/src/lib/map/grid.ts b/src/lib/map/grid.ts new file mode 100644 index 0000000..02c7b65 --- /dev/null +++ b/src/lib/map/grid.ts @@ -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; +} diff --git a/src/lib/map/index.ts b/src/lib/map/index.ts new file mode 100644 index 0000000..d39d791 --- /dev/null +++ b/src/lib/map/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './grid'; +export * from './distance'; +export * from './shapes'; +export * from './fog'; +export * from './projection'; diff --git a/src/lib/map/map.test.ts b/src/lib/map/map.test.ts new file mode 100644 index 0000000..91287e3 --- /dev/null +++ b/src/lib/map/map.test.ts @@ -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([]); + }); +}); diff --git a/src/lib/map/projection.ts b/src/lib/map/projection.ts new file mode 100644 index 0000000..4094715 --- /dev/null +++ b/src/lib/map/projection.ts @@ -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, + }; +} diff --git a/src/lib/map/shapes.ts b/src/lib/map/shapes.ts new file mode 100644 index 0000000..32508c6 --- /dev/null +++ b/src/lib/map/shapes.ts @@ -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; +} diff --git a/src/lib/map/types.ts b/src/lib/map/types.ts new file mode 100644 index 0000000..9c857d0 --- /dev/null +++ b/src/lib/map/types.ts @@ -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; +} diff --git a/src/lib/schemas/world.ts b/src/lib/schemas/world.ts index f7acaa3..a2faaaf 100644 --- a/src/lib/schemas/world.ts +++ b/src/lib/schemas/world.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { int, systemIdSchema } from './common'; +import { int, num, systemIdSchema, hpSchema, conditionSchema } from './common'; // ---------------- Notes / Wiki ---------------- export const noteSchema = z.object({ @@ -90,12 +90,37 @@ export const mapTokenSchema = z.object({ id: z.string(), label: z.string().max(40).default(''), color: z.string().max(20).default('#d4af37'), - /** grid cell coordinates */ + /** grid cell coordinates (top-left cell of the footprint) */ col: 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; +/** A point in image-space pixels (scale-independent, network-portable). */ +export const pointSchema = z.object({ x: num, y: num }); +export type MapPoint = z.infer; + +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; + export const battleMapSchema = z.object({ id: 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: z.array(z.string()).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(), updatedAt: z.string(), }); diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 721cb87..11049e9 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -9,10 +9,13 @@ interface UiState { activeCampaignId: string | null; /** encounter currently open in the combat tracker (so the bestiary can target it) */ activeEncounterId: string | null; + /** map currently projected to the player view */ + activeMapId: string | null; setTheme: (theme: Theme) => void; toggleTheme: () => void; setActiveCampaign: (id: string | null) => void; setActiveEncounter: (id: string | null) => void; + setActiveMap: (id: string | null) => void; } export const useUiStore = create()( @@ -21,13 +24,15 @@ export const useUiStore = create()( theme: 'dark', activeCampaignId: null, activeEncounterId: null, + activeMapId: null, setTheme: (theme) => { applyTheme(theme); set({ theme }); }, 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 }), + setActiveMap: (id) => set({ activeMapId: id }), }), { name: 'ttrpg-ui', diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 0f203a8..1f0bcfd 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file