diff --git a/e2e/maps.spec.ts b/e2e/maps.spec.ts index 76abb15..8c63629 100644 --- a/e2e/maps.spec.ts +++ b/e2e/maps.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { createCharacter } from './helpers'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -36,3 +37,48 @@ test('maps: upload, place token, reveal all, show to players', async ({ page }) await expect(page.getByRole('heading', { name: 'Battle map' })).toBeVisible(); await expect(page.getByTestId('map-token')).toHaveCount(1); }); + +test('maps: zoom controls scale the canvas and fit resets', async ({ page }) => { + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('Zoom'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByRole('link', { name: 'Dashboard' }).click(); + await page.getByRole('link', { name: 'Maps' }).click(); + await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png'); + + const world = page.getByTestId('map-world'); + await expect(world).toBeVisible(); + // World layer is sized to the image's natural pixels (not shrunk to a cap). + await expect(world).toHaveCSS('width', '512px'); + + const scaleOf = async () => { + const t = await world.evaluate((el) => getComputedStyle(el).transform); + const m = /matrix\(([^,]+)/.exec(t); + return m ? Number(m[1]) : 1; + }; + const before = await scaleOf(); + await page.getByRole('button', { name: 'Zoom in' }).click(); + await page.getByRole('button', { name: 'Zoom in' }).click(); + expect(await scaleOf()).toBeGreaterThan(before); + + await page.getByRole('button', { name: 'Fit map to view' }).click(); + expect(await scaleOf()).toBeCloseTo(before, 1); +}); + +test('maps: place a party token from the palette', async ({ page }) => { + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('Party'); + await page.getByRole('button', { name: 'Create' }).click(); + + await page.getByRole('link', { name: 'Characters' }).click(); + await createCharacter(page, 'Aria'); + + await page.getByRole('link', { name: 'Dashboard' }).click(); + await page.getByRole('link', { name: 'Maps' }).click(); + await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png'); + + // Palette lists the PC; clicking it drops a linked token. + await page.getByRole('button', { name: /Aria/ }).click(); + await expect(page.getByTestId('map-token')).toHaveCount(1); + await expect(page.getByTestId('map-token')).toContainText('Aria'); +}); diff --git a/src/features/player/PlayerBoards.tsx b/src/features/player/PlayerBoards.tsx index e19c82f..212f36d 100644 --- a/src/features/player/PlayerBoards.tsx +++ b/src/features/player/PlayerBoards.tsx @@ -12,7 +12,7 @@ export function PlayerBoards({ snapshot, image }: { snapshot: Snapshot; image?: {map && (

Battle map

- +
)} diff --git a/src/features/player/PlayerMapView.tsx b/src/features/player/PlayerMapView.tsx index de48786..bd9b5b1 100644 --- a/src/features/player/PlayerMapView.tsx +++ b/src/features/player/PlayerMapView.tsx @@ -2,7 +2,7 @@ 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 | undefined; maxWidth?: number | undefined }) { +export function PlayerMapView({ map, image, viewportHeight }: { map: PlayerMap; image?: string | undefined; viewportHeight?: string | undefined }) { 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, @@ -11,7 +11,7 @@ export function PlayerMapView({ map, image, maxWidth }: { map: PlayerMap; image? ); diff --git a/src/features/world/map/MapCanvas.tsx b/src/features/world/map/MapCanvas.tsx index 104b7d8..af61b11 100644 --- a/src/features/world/map/MapCanvas.tsx +++ b/src/features/world/map/MapCanvas.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; -import type { Point } from '@/lib/map'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Point, Viewport } from '@/lib/map'; +import { fitViewport, zoomToPoint, worldFromScreen } from '@/lib/map'; import { cn } from '@/lib/cn'; /** Render-only drawing shape (both MapDrawing and PlayerDrawing satisfy this). */ @@ -44,7 +45,8 @@ export interface Overlay { interface Props { view: CanvasView; - maxWidth?: number; + /** CSS height of the scroll/clip viewport (e.g. 'calc(100vh - 14rem)') */ + viewportHeight?: string; readOnly?: boolean; /** dim unrevealed cells fully (player view) vs. translucent (GM editor) */ playerFog?: boolean; @@ -56,13 +58,26 @@ interface Props { 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) { +/** + * Shared map renderer. The image + all layers live in a "world" div sized to the + * image's natural pixels; a CSS transform (translate+scale) provides zoom/pan so + * large maps render crisp at full resolution instead of being shrunk to fit. + */ +export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) { const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); + const [vp, setVp] = useState({ zoom: 1, panX: 0, panY: 0 }); + const outerRef = useRef(null); + const worldRef = useRef(null); const fogRef = useRef(null); const drawRef = useRef(null); const overlayRef = useRef(null); + const gridSize = view.gridSize; + const W = natural?.w ?? 0; + const H = natural?.h ?? 0; + const cols = natural ? Math.ceil(W / gridSize) : 0; + const rows = natural ? Math.ceil(H / gridSize) : 0; + useEffect(() => { if (!view.image) { setNatural(null); return; } const img = new Image(); @@ -70,109 +85,167 @@ export function MapCanvas({ view, maxWidth = 880, readOnly, playerFog, overlay, 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]); + // Fit the image into the viewport whenever a new image loads. + const fit = useCallback(() => { + const el = outerRef.current; + if (!el || !natural) return; + setVp(fitViewport(el.clientWidth, el.clientHeight, natural.w, natural.h)); + }, [natural]); + useEffect(() => { fit(); }, [fit]); + + // Wheel zoom (native, non-passive so we can prevent page scroll). + useEffect(() => { + const el = outerRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const r = el.getBoundingClientRect(); + setVp((v) => zoomToPoint(v, Math.exp(-e.deltaY * 0.0015), e.clientX - r.left, e.clientY - r.top)); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, []); + // Fog useEffect(() => { const cv = fogRef.current; if (!cv || !natural) return; - cv.width = displayW; cv.height = displayH; + cv.width = W; cv.height = H; const ctx = cv.getContext('2d'); if (!ctx) return; - ctx.clearRect(0, 0, displayW, displayH); + ctx.clearRect(0, 0, W, H); 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); + if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * gridSize, r * gridSize, gridSize + 0.5, gridSize + 0.5); } - }, [view.revealed, view.fogEnabled, playerFog, displayW, displayH, cellPx, cols, rows, natural]); + }, [view.revealed, view.fogEnabled, playerFog, W, H, gridSize, cols, rows, natural]); // Drawings useEffect(() => { const cv = drawRef.current; if (!cv || !natural) return; - cv.width = displayW; cv.height = displayH; + cv.width = W; cv.height = H; 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]); + ctx.clearRect(0, 0, W, H); + for (const d of view.drawings) drawShape(ctx, d); + }, [view.drawings, W, H, natural]); // Overlay (AoE/measure/draft) useEffect(() => { const cv = overlayRef.current; if (!cv || !natural) return; - cv.width = displayW; cv.height = displayH; + cv.width = W; cv.height = H; const ctx = cv.getContext('2d'); if (!ctx) return; - ctx.clearRect(0, 0, displayW, displayH); + ctx.clearRect(0, 0, W, H); 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); } + for (const k of overlay.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * gridSize, r! * gridSize, gridSize, gridSize); } } - if (overlay?.draft) drawShape(ctx, overlay.draft, scale); + if (overlay?.draft) drawShape(ctx, overlay.draft); 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(); + ctx.beginPath(); ctx.moveTo(overlay.line.a.x, overlay.line.a.y); ctx.lineTo(overlay.line.b.x, overlay.line.b.y); 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; + const x = overlay.label.at.x + 6, y = overlay.label.at.y - 6; ctx.strokeText(overlay.label.text, x, y); ctx.fillText(overlay.label.text, x, y); } - }, [overlay, displayW, displayH, cellPx, scale, natural]); + }, [overlay, W, H, gridSize, 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) }; + const rect = (worldRef.current ?? (e.currentTarget as HTMLElement)).getBoundingClientRect(); + const pt = worldFromScreen(vp.zoom, e.clientX, e.clientY, rect.left, rect.top); + return { point: pt, col: Math.floor(pt.x / gridSize), row: Math.floor(pt.y / gridSize) }; + }; + + // Pan (drag) state. Pan happens on middle/right button always, or on primary + // button when no tool owns the canvas (move tool / read-only player view). + const pan = useRef<{ x: number; y: number; px: number; py: number } | null>(null); + const interactive = !readOnly && !!onPointer; + + const ixDown = (e: React.PointerEvent) => { + const wantPan = e.button === 1 || e.button === 2 || !interactive; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + if (wantPan) { pan.current = { x: e.clientX, y: e.clientY, px: vp.panX, py: vp.panY }; e.preventDefault(); return; } + onPointer!('down', toPoint(e)); + }; + const ixMove = (e: React.PointerEvent) => { + if (pan.current) { + const { x, y, px, py } = pan.current; + setVp((v) => ({ ...v, panX: px + (e.clientX - x), panY: py + (e.clientY - y) })); + return; + } + if (interactive && e.buttons) onPointer!('move', toPoint(e)); + }; + const ixUp = (e: React.PointerEvent) => { + if (pan.current) { pan.current = null; return; } + if (interactive) onPointer!('up', toPoint(e)); }; if (!view.image) return

No image.

; - if (!natural) return

Loading map…

; - const interactive = !readOnly && !!onPointer; + const zoomCentered = (factor: number) => { + const el = outerRef.current; if (!el) return; + setVp((v) => zoomToPoint(v, factor, el.clientWidth / 2, el.clientHeight / 2)); + }; + return ( -
-
- - {view.showGrid && ( -
- )} - - - - {interactive && ( +
+ {!natural ? ( +

Loading map…

+ ) : ( +
+ + {view.showGrid && ( +
+ )} + + +
{ (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))} + style={{ cursor: interactive ? 'crosshair' : 'grab', touchAction: 'none' }} + onPointerDown={ixDown} + onPointerMove={ixMove} + onPointerUp={ixUp} + onContextMenu={(e) => e.preventDefault()} /> - )} - {view.tokens.map((t) => ( - - ))} -
+ {view.tokens.map((t) => ( + + ))} +
+ )} + + {natural && ( +
+ + {Math.round(vp.zoom * 100)}% + + +
+ )}
); } -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 })); +function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing): void { + ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; + const p = d.points; if (d.kind === 'text' && d.text && p[0]) { - ctx.font = `${Math.max(12, d.width * 5 * scale)}px sans-serif`; + ctx.font = `${Math.max(12, d.width * 5)}px sans-serif`; ctx.fillText(d.text, p[0].x, p[0].y); return; } @@ -187,7 +260,7 @@ function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing, scale: numbe 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; + const ang = Math.atan2(b.y - p[p.length - 2]!.y, b.x - p[p.length - 2]!.x), h = 10 + d.width; 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)); } @@ -195,14 +268,14 @@ function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing, scale: numbe ctx.stroke(); } -function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: { - token: CanvasToken; cellPx: number; cols: number; rows: number; +function TokenChip({ token, gridSize, zoom, cols, rows, draggable, onMove, onClick }: { + token: CanvasToken; gridSize: number; zoom: 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 span = gridSize * 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) => { @@ -216,11 +289,13 @@ function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: { }; const up = (e: React.PointerEvent) => { if (!drag.current) return; + e.stopPropagation(); 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))); + // screen-px drift → world-px (÷zoom) → cell delta (÷gridSize) + const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / zoom / gridSize))); + const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / zoom / gridSize))); onMove(token.id, col, row); } else if (!moved && onClick) onClick(token.id); drag.current = null; setDrift({ dx: 0, dy: 0 }); @@ -235,8 +310,8 @@ function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: { 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, + left: token.col * gridSize + span * 0.04 + drift.dx / zoom, + top: token.row * gridSize + span * 0.04 + drift.dy / zoom, background: token.color, cursor: draggable ? 'grab' : onClick ? 'pointer' : 'default', touchAction: 'none', diff --git a/src/features/world/map/MapEditor.tsx b/src/features/world/map/MapEditor.tsx index 6c70252..61fb170 100644 --- a/src/features/world/map/MapEditor.tsx +++ b/src/features/world/map/MapEditor.tsx @@ -9,12 +9,14 @@ import { type Point, } from '@/lib/map'; import { useCharacters } from '@/features/characters/hooks'; +import { useEncounters } from '@/features/combat/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'; +import { TokenPalette, type TokenSpec } from './TokenPalette'; const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000']; type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping'; @@ -25,6 +27,7 @@ type DrawKind = MapDrawing['kind']; export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaign }) { const [m, setM] = useState(map); const characters = useCharacters(campaign.id); + const encounters = useEncounters(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; }); @@ -38,6 +41,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig const [gmDraw, setGmDraw] = useState(true); const [dims, setDims] = useState({ cols: 0, rows: 0 }); const [editToken, setEditToken] = useState(null); + const [showPalette, setShowPalette] = useState(true); // transient interaction state const [overlay, setOverlay] = useState({}); @@ -46,15 +50,20 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig const draftPts = useRef([]); const distMode = campaign.system === 'pf2e' ? 'pf2e' : '5e'; + const activeEncounter = useMemo(() => encounters.find((e) => e.status === 'active') ?? null, [encounters]); - // Live HP from a linked character drives the ring. + // Live HP/conditions: prefer an active-encounter combatant, then a linked + // character, then the token's denormalized snapshot. const canvasTokens: CanvasToken[] = useMemo(() => m.tokens.map((t) => { - const linked = t.characterId ? characters.find((c) => c.id === t.characterId) : undefined; + const linkedChar = t.characterId ? characters.find((c) => c.id === t.characterId) : undefined; + const linkedCb = t.combatantId && activeEncounter ? activeEncounter.combatants.find((c) => c.id === t.combatantId) : 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, + id: t.id, label: t.label || (linkedChar?.name ?? linkedCb?.name ?? ''), color: t.color, col: t.col, row: t.row, + size: t.size, kind: t.kind, + hp: linkedCb?.hp ?? linkedChar?.hp ?? t.hp, + conditions: linkedCb?.conditions ?? t.conditions, }; - }), [m.tokens, characters]); + }), [m.tokens, characters, activeEncounter]); const commitReveal = (cells: string[], reveal: boolean) => update({ revealed: reveal ? applyReveal(m.revealed, cells) : applyHide(m.revealed, cells) }); @@ -130,77 +139,91 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig 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 addTokenSpec = (spec: TokenSpec) => update({ tokens: [...m.tokens, { id: newId(), ...spec }] }); + const addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); }; 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) => ( - - ))} +
+ {showPalette && ( + setShowPalette(false)} /> + )} + +
+
+ {!showPalette && } + 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} + /> +
- {/* 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. + Place party/foe tokens from the left palette. Drag to move (Move tool); scroll to zoom, drag empty space (or middle/right-drag) to pan. + Use Reveal/Hide for fog, Measure/AoE for ranges, Draw for annotations, then “Show to players”.

{token && ( @@ -212,6 +235,9 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig + {activeEncounter && ( + + )}
{TOKEN_COLORS.map((c) => +
+ +
+
+
+ Party + +
+ {pcs.length === 0 ? ( +

No player characters in this campaign.

+ ) : ( +
    + {pcs.map((c) => ( +
  • + +
  • + ))} +
+ )} +
+ + {activeEnc && activeEnc.combatants.length > 0 && ( +
+
{activeEnc.name}
+
    + {activeEnc.combatants.map((cb) => { + const placed = hasCombatant(cb); + return ( +
  • + +
  • + ); + })} +
+
+ )} + +
+
Add blank
+
+ + + +
+
+
+ + ); +} diff --git a/src/features/world/map/tokens.ts b/src/features/world/map/tokens.ts new file mode 100644 index 0000000..4125cf0 --- /dev/null +++ b/src/features/world/map/tokens.ts @@ -0,0 +1,16 @@ +import type { Character, MapToken } from '@/lib/schemas'; + +export type TokenSpec = Omit; + +export const KIND_COLOR: Record = { + pc: '#64b5f6', npc: '#66bb6a', monster: '#ef5350', object: '#bdbdbd', +}; + +export function baseSpec(over: Partial): TokenSpec { + return { label: '', color: '#d4af37', col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false, ...over }; +} + +/** PCs that don't already have a linked token on the map (for "+ All"). */ +export function unplacedPcs(pcs: Character[], existingTokens: Pick[]): Character[] { + return pcs.filter((c) => !existingTokens.some((t) => t.characterId === c.id)); +} diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts index 97473c2..c30fc5e 100644 --- a/src/lib/db/db.ts +++ b/src/lib/db/db.ts @@ -96,6 +96,10 @@ export class TtrpgDatabase extends Dexie { } }); }); + + // v9 — Phase 20: tokens gain optional combatantId (live HP from an encounter + // combatant). Optional field — no backfill needed; existing rows parse fine. + this.version(9).stores({}); } } diff --git a/src/lib/map/index.ts b/src/lib/map/index.ts index d39d791..4783ae1 100644 --- a/src/lib/map/index.ts +++ b/src/lib/map/index.ts @@ -4,3 +4,4 @@ export * from './distance'; export * from './shapes'; export * from './fog'; export * from './projection'; +export * from './viewport'; diff --git a/src/lib/map/viewport.test.ts b/src/lib/map/viewport.test.ts new file mode 100644 index 0000000..a21f2cc --- /dev/null +++ b/src/lib/map/viewport.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { clampZoom, zoomToPoint, fitViewport, worldFromScreen, MIN_ZOOM, MAX_ZOOM } from './viewport'; + +describe('viewport math', () => { + it('clamps zoom to bounds', () => { + expect(clampZoom(0.001)).toBe(MIN_ZOOM); + expect(clampZoom(999)).toBe(MAX_ZOOM); + expect(clampZoom(1.5)).toBe(1.5); + }); + + it('keeps the world point under the cursor fixed when zooming', () => { + const start = { zoom: 1, panX: 0, panY: 0 }; + const cx = 100, cy = 50; + const before = worldFromScreen(start.zoom, cx, cy, start.panX, start.panY); + const next = zoomToPoint(start, 2, cx, cy); + const after = worldFromScreen(next.zoom, cx, cy, next.panX, next.panY); + expect(next.zoom).toBe(2); + expect(after.x).toBeCloseTo(before.x); + expect(after.y).toBeCloseTo(before.y); + }); + + it('fits a large landscape map by shrinking and centering', () => { + const vp = fitViewport(800, 600, 4000, 3000); + expect(vp.zoom).toBeCloseTo(0.2); + expect(vp.panX).toBeCloseTo(0); + expect(vp.panY).toBeCloseTo(0); + }); + + it('never upscales a small map past 1:1, and centers it', () => { + const vp = fitViewport(800, 600, 100, 100); + expect(vp.zoom).toBe(1); + expect(vp.panX).toBeCloseTo(350); + expect(vp.panY).toBeCloseTo(250); + }); + + it('guards degenerate sizes', () => { + expect(fitViewport(800, 600, 0, 0)).toEqual({ zoom: 1, panX: 0, panY: 0 }); + }); + + it('converts screen to world by dividing out zoom', () => { + expect(worldFromScreen(2, 120, 80, 20, 0)).toEqual({ x: 50, y: 40 }); + }); +}); diff --git a/src/lib/map/viewport.ts b/src/lib/map/viewport.ts new file mode 100644 index 0000000..9e3b78e --- /dev/null +++ b/src/lib/map/viewport.ts @@ -0,0 +1,44 @@ +/** Pure pan/zoom viewport math for the map canvas (no DOM, unit-tested). */ +import type { Point } from './types'; + +export interface Viewport { + /** multiplier; 1 = natural image resolution */ + zoom: number; + /** translation in screen pixels, applied before scale */ + panX: number; + panY: number; +} + +export const MIN_ZOOM = 0.1; +export const MAX_ZOOM = 6; + +export function clampZoom(z: number): number { + return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z)); +} + +/** + * Zoom by `factor` keeping the world point under (cx, cy) fixed on screen. + * cx/cy are cursor coordinates relative to the viewport's top-left. + */ +export function zoomToPoint(vp: Viewport, factor: number, cx: number, cy: number): Viewport { + const zoom = clampZoom(vp.zoom * factor); + const wx = (cx - vp.panX) / vp.zoom; + const wy = (cy - vp.panY) / vp.zoom; + return { zoom, panX: cx - wx * zoom, panY: cy - wy * zoom }; +} + +/** Center a natural-size image inside a viewport, never upscaling past 1:1. */ +export function fitViewport(vw: number, vh: number, naturalW: number, naturalH: number): Viewport { + if (naturalW <= 0 || naturalH <= 0 || vw <= 0 || vh <= 0) return { zoom: 1, panX: 0, panY: 0 }; + const zoom = Math.min(vw / naturalW, vh / naturalH, 1); + return { zoom, panX: (vw - naturalW * zoom) / 2, panY: (vh - naturalH * zoom) / 2 }; +} + +/** + * Convert a screen point to world (natural image) pixels, given the bounding rect + * of the already-transformed world layer. Because the layer's rect reflects the + * CSS transform, dividing by zoom is all that's needed. + */ +export function worldFromScreen(zoom: number, clientX: number, clientY: number, rectLeft: number, rectTop: number): Point { + return { x: (clientX - rectLeft) / zoom, y: (clientY - rectTop) / zoom }; +} diff --git a/src/lib/schemas/world.ts b/src/lib/schemas/world.ts index a2faaaf..5ee3a18 100644 --- a/src/lib/schemas/world.ts +++ b/src/lib/schemas/world.ts @@ -98,6 +98,8 @@ export const mapTokenSchema = z.object({ kind: z.enum(['pc', 'npc', 'monster', 'object']).default('npc'), /** optional link to a Character for a live HP ring / conditions */ characterId: z.string().optional(), + /** optional link to an active-encounter Combatant for a live HP ring */ + combatantId: z.string().optional(), /** denormalized HP snapshot (used when no live combatant drives the ring) */ hp: hpSchema.optional(), conditions: z.array(conditionSchema).default([]),