diff --git a/e2e/fixtures/sample.dd2vtt b/e2e/fixtures/sample.dd2vtt new file mode 100644 index 0000000..9f8949b --- /dev/null +++ b/e2e/fixtures/sample.dd2vtt @@ -0,0 +1,8 @@ +{ + "format": 0.3, + "resolution": { "map_origin": { "x": 0, "y": 0 }, "map_size": { "x": 4, "y": 3 }, "pixels_per_grid": 100 }, + "line_of_sight": [[{ "x": 1, "y": 1 }, { "x": 3, "y": 1 }, { "x": 3, "y": 2 }]], + "portals": [{ "position": { "x": 3, "y": 1.5 }, "bounds": [{ "x": 3, "y": 1 }, { "x": 3, "y": 2 }], "closed": true }], + "lights": [{ "position": { "x": 2, "y": 2 }, "range": 4, "color": "ffd9a0", "intensity": 0.6 }], + "image": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" +} diff --git a/e2e/maps.spec.ts b/e2e/maps.spec.ts index 2b94e0f..55785df 100644 --- a/e2e/maps.spec.ts +++ b/e2e/maps.spec.ts @@ -38,6 +38,21 @@ test('maps: upload, place token, reveal all, show to players', async ({ page }) await expect(page.getByTestId('map-token')).toHaveCount(1); }); +test('maps: import a Universal VTT (.dd2vtt) map', async ({ page }) => { + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('VTTimport'); + 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('e2e/fixtures/sample.dd2vtt'); + + // Imported map opens in the editor with the file's pixels_per_grid as the grid size. + await expect(page.getByTestId('map-viewport')).toBeVisible(); + await expect(page.getByLabel('Grid cell size')).toHaveValue('100'); + await expect(page.getByRole('button', { name: 'Export .uvtt' })).toBeVisible(); +}); + 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'); diff --git a/src/features/world/MapsPage.tsx b/src/features/world/MapsPage.tsx index 7c10d5a..9d9a440 100644 --- a/src/features/world/MapsPage.tsx +++ b/src/features/world/MapsPage.tsx @@ -1,7 +1,10 @@ import { useRef, useState } from 'react'; import { Link } from '@tanstack/react-router'; -import type { Campaign } from '@/lib/schemas'; +import type { BattleMap, Campaign } from '@/lib/schemas'; import { mapsRepo } from '@/lib/db/repositories'; +import { parseUvtt, toUvtt } from '@/lib/vtt/uvtt'; +import { downloadText, safeFilename } from '@/lib/io/file'; +import { imageSize } from '@/lib/img/resize'; import { useUiStore } from '@/stores/uiStore'; import { useMaps } from './hooks'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; @@ -9,7 +12,8 @@ import { Button } from '@/components/ui/Button'; import { cn } from '@/lib/cn'; import { MapEditor } from './map/MapEditor'; -const MAX_IMAGE_BYTES = 8 * 1024 * 1024; +const MAX_FILE_BYTES = 24 * 1024 * 1024; +const VTT_RE = /\.(dd2vtt|uvtt|df2vtt)$/i; export function MapsPage() { return {(c) => }; @@ -24,17 +28,41 @@ function Maps({ campaign }: { campaign: Campaign }) { const setActiveMap = useUiStore((s) => s.setActiveMap); const selected = maps.find((m) => m.id === selectedId) ?? null; - const upload = (file: File) => { + const importFile = (file: File) => { setError(null); - if (file.size > MAX_IMAGE_BYTES) { setError('Image is larger than 8 MB.'); return; } + if (file.size > MAX_FILE_BYTES) { setError('File is larger than 24 MB.'); return; } + const isVtt = VTT_RE.test(file.name); + const name = file.name.replace(/\.[^.]+$/, ''); const reader = new FileReader(); reader.onload = async () => { - const dataUrl = typeof reader.result === 'string' ? reader.result : ''; - if (!dataUrl) return; - const map = await mapsRepo.create(campaign.id, file.name.replace(/\.[^.]+$/, ''), dataUrl); - setSelectedId(map.id); + const result = reader.result; + if (typeof result !== 'string') return; + try { + if (isVtt) { + const vtt = parseUvtt(result); + if (!vtt) { setError('Could not read that .dd2vtt / .uvtt file.'); return; } + const map = await mapsRepo.createFromVtt(campaign.id, name, vtt); + setSelectedId(map.id); + } else { + const map = await mapsRepo.create(campaign.id, name, result); + setSelectedId(map.id); + } + } catch { + setError('Import failed.'); + } }; - reader.readAsDataURL(file); + if (isVtt) reader.readAsText(file); else reader.readAsDataURL(file); + }; + + const exportUvtt = async (map: BattleMap) => { + const { w, h } = await imageSize(map.image); + const cols = Math.max(0, Math.round(w / map.gridSize)); + const rows = Math.max(0, Math.round(h / map.gridSize)); + const obj = toUvtt({ + image: map.image, gridSize: map.gridSize, cols, rows, + walls: map.walls ?? [], doors: map.doors ?? [], lights: map.lights ?? [], + }); + downloadText(`${safeFilename(map.name)}.uvtt`, JSON.stringify(obj)); }; return ( @@ -42,14 +70,14 @@ function Maps({ campaign }: { campaign: Campaign }) { fileRef.current?.click()}>+ Upload map} + actions={} /> - { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }} /> + { const f = e.target.files?.[0]; if (f) importFile(f); e.target.value = ''; }} /> {error &&

{error}

} {maps.length === 0 ? ( - fileRef.current?.click()}>+ Upload map} /> + fileRef.current?.click()}>+ Add map} /> ) : (
    @@ -69,6 +97,7 @@ function Maps({ campaign }: { campaign: Campaign }) { {activeMapId === selected.id ? '📺 Showing to players' : 'Show to players'} Open player view ↗ +
diff --git a/src/features/world/map/MapCanvas.tsx b/src/features/world/map/MapCanvas.tsx index 95fcebd..8a52b4e 100644 --- a/src/features/world/map/MapCanvas.tsx +++ b/src/features/world/map/MapCanvas.tsx @@ -36,6 +36,8 @@ export interface CanvasView { revealed: string[]; tokens: CanvasToken[]; drawings: CanvasDrawing[]; + /** sight-blocking walls, drawn faintly for the GM (not sent to players) */ + walls?: { points: Point[] }[] | undefined; } export interface Overlay { @@ -137,7 +139,7 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, drawScene(canvasRef.current, imgRef.current, size.w, size.h, { vp, W, H, gridSize, cols, rows, showGrid: view.showGrid, fogEnabled: view.fogEnabled, revealed: view.revealed, - playerFog: !!playerFog, drawings: view.drawings, overlay, + playerFog: !!playerFog, drawings: view.drawings, overlay, walls: view.walls, }); }); return () => cancelAnimationFrame(raf); @@ -228,7 +230,7 @@ interface SceneParams { vp: Viewport; W: number; H: number; gridSize: number; cols: number; rows: number; showGrid: boolean; fogEnabled: boolean; revealed: string[]; playerFog: boolean; - drawings: CanvasDrawing[]; overlay?: Overlay | undefined; + drawings: CanvasDrawing[]; overlay?: Overlay | undefined; walls?: { points: Point[] }[] | undefined; } /** Draw the whole scene at device resolution with a camera transform. */ @@ -291,6 +293,21 @@ function drawScene(canvas: HTMLCanvasElement | null, img: HTMLImageElement | nul cam(); for (const d of p.drawings) drawShape(ctx, d); + // Walls (GM only — faint, so imported line-of-sight is visible while editing) + if (p.walls?.length) { + cam(); + ctx.strokeStyle = 'rgba(80,200,255,0.6)'; + ctx.lineWidth = 2 / zoom; + ctx.lineJoin = 'round'; ctx.lineCap = 'round'; + for (const w of p.walls) { + if (w.points.length < 2) continue; + ctx.beginPath(); + ctx.moveTo(w.points[0]!.x, w.points[0]!.y); + for (const pt of w.points.slice(1)) ctx.lineTo(pt.x, pt.y); + ctx.stroke(); + } + } + // Overlay (AoE/measure/draft/poly/brush) const o = p.overlay; if (o) { diff --git a/src/features/world/map/MapEditor.tsx b/src/features/world/map/MapEditor.tsx index ae4588e..4c8ec6b 100644 --- a/src/features/world/map/MapEditor.tsx +++ b/src/features/world/map/MapEditor.tsx @@ -231,7 +231,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig { + await tx.table('maps').toCollection().modify((m: Record) => { + if (m.walls === undefined) m.walls = []; + if (m.doors === undefined) m.doors = []; + if (m.lights === undefined) m.lights = []; + }); + }); } } diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index a11686e..4f4f55e 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -25,6 +25,7 @@ import { type BattleMap, } from '@/lib/schemas'; import { getSystem } from '@/lib/rules'; +import type { ParsedUvtt } from '@/lib/vtt/uvtt'; function now(): string { return new Date().toISOString(); @@ -306,6 +307,20 @@ export const mapsRepo = { await db.maps.add(map); return map; }, + /** Create a map from a parsed Universal VTT (image + grid + walls/doors/lights). */ + async createFromVtt(campaignId: string, name: string, vtt: ParsedUvtt): Promise { + const ts = now(); + const map = battleMapSchema.parse({ + id: newId(), campaignId, name, image: vtt.image, gridSize: vtt.gridSize, + showGrid: true, fogEnabled: false, revealed: [], tokens: [], + walls: vtt.walls.map((w) => ({ id: newId(), points: w.points })), + doors: vtt.doors.map((d) => ({ id: newId(), a: d.a, b: d.b, open: d.open })), + lights: vtt.lights.map((l) => ({ id: newId(), x: l.x, y: l.y, range: l.range, color: l.color, intensity: l.intensity })), + createdAt: ts, updatedAt: ts, + }); + await db.maps.add(map); + return map; + }, get(id: string): Promise { return db.maps.get(id); }, diff --git a/src/lib/img/resize.ts b/src/lib/img/resize.ts index 0eb56bc..cf47cdf 100644 --- a/src/lib/img/resize.ts +++ b/src/lib/img/resize.ts @@ -16,6 +16,11 @@ function loadImage(src: string): Promise { }); } +/** Natural pixel size of an image data URL. */ +export function imageSize(src: string): Promise<{ w: number; h: number }> { + return loadImage(src).then((img) => ({ w: img.naturalWidth, h: img.naturalHeight })); +} + /** Read a File as a data URL. */ export function fileToDataUrl(file: File): Promise { return new Promise((resolve, reject) => { diff --git a/src/lib/io/file.ts b/src/lib/io/file.ts index e2139d7..c798edd 100644 --- a/src/lib/io/file.ts +++ b/src/lib/io/file.ts @@ -12,6 +12,19 @@ export function downloadJson(filename: string, data: unknown): void { setTimeout(() => URL.revokeObjectURL(url), 1000); } +/** Trigger a browser download of arbitrary text (e.g. a .uvtt export). */ +export function downloadText(filename: string, text: string, mime = 'application/json'): void { + const blob = new Blob([text], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + /** Open the OS file picker and resolve with the chosen file's text (or null if cancelled). */ export function pickTextFile(accept = 'application/json,.json'): Promise { return new Promise((resolve) => { diff --git a/src/lib/schemas/world.ts b/src/lib/schemas/world.ts index 911e9c7..b5aeefc 100644 --- a/src/lib/schemas/world.ts +++ b/src/lib/schemas/world.ts @@ -125,6 +125,33 @@ export const drawingSchema = z.object({ }); export type MapDrawing = z.infer; +/** Sight-blocking wall as a polyline (image-space px). From UVTT import or hand-drawn. */ +export const mapWallSchema = z.object({ + id: z.string().optional(), + points: z.array(pointSchema).min(2), +}); +export type MapWall = z.infer; + +/** A door/portal segment that can be opened (lets sight through). */ +export const mapDoorSchema = z.object({ + id: z.string().optional(), + a: pointSchema, + b: pointSchema, + open: z.boolean().default(false), +}); +export type MapDoor = z.infer; + +/** A light source (image-space px position, px range). */ +export const mapLightSchema = z.object({ + id: z.string().optional(), + x: num, + y: num, + range: num.default(0), + color: z.string().max(20).default('#ffd9a0'), + intensity: num.default(0.5), +}); +export type MapLight = z.infer; + export const battleMapSchema = z.object({ id: z.string(), campaignId: z.string(), @@ -139,6 +166,12 @@ export const battleMapSchema = z.object({ tokens: z.array(mapTokenSchema).default([]), /** GM annotation layer (some entries may be gmOnly) */ drawings: z.array(drawingSchema).default([]), + /** sight-blocking walls (UVTT import or hand-drawn) — drive dynamic vision */ + walls: z.array(mapWallSchema).default([]), + /** doors that can open to let sight through */ + doors: z.array(mapDoorSchema).default([]), + /** light sources */ + lights: z.array(mapLightSchema).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'), diff --git a/src/lib/vtt/uvtt.test.ts b/src/lib/vtt/uvtt.test.ts new file mode 100644 index 0000000..e1894a9 --- /dev/null +++ b/src/lib/vtt/uvtt.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { parseUvtt, toUvtt } from './uvtt'; + +// 1x1 transparent PNG +const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + +const sample = JSON.stringify({ + format: 0.3, + resolution: { map_origin: { x: 0, y: 0 }, map_size: { x: 30, y: 21 }, pixels_per_grid: 100 }, + line_of_sight: [[{ x: 1, y: 1 }, { x: 5, y: 1 }, { x: 5, y: 4 }]], + portals: [{ position: { x: 5, y: 2.5 }, bounds: [{ x: 5, y: 2 }, { x: 5, y: 3 }], closed: true }], + lights: [{ position: { x: 3, y: 3 }, range: 6, color: 'ff8040', intensity: 0.7 }], + image: PNG, +}); + +describe('parseUvtt', () => { + it('converts grid-unit geometry to image pixels using pixels_per_grid', () => { + const m = parseUvtt(sample)!; + expect(m).toBeTruthy(); + expect(m.gridSize).toBe(100); + expect(m.cols).toBe(30); + expect(m.rows).toBe(21); + expect(m.image.startsWith('data:image/png;base64,')).toBe(true); + // wall: 3 points scaled ×100 + expect(m.walls).toHaveLength(1); + expect(m.walls[0]!.points).toEqual([{ x: 100, y: 100 }, { x: 500, y: 100 }, { x: 500, y: 400 }]); + // door: bounds → segment, closed → open=false + expect(m.doors[0]).toEqual({ a: { x: 500, y: 200 }, b: { x: 500, y: 300 }, open: false }); + // light: position ×100, range ×100, color normalized + expect(m.lights[0]).toEqual({ x: 300, y: 300, range: 600, color: '#ff8040', intensity: 0.7 }); + }); + + it('returns null for non-JSON or imageless files', () => { + expect(parseUvtt('not json')).toBeNull(); + expect(parseUvtt(JSON.stringify({ resolution: { pixels_per_grid: 70 } }))).toBeNull(); + }); + + it('round-trips walls/doors back to grid units on export', () => { + const m = parseUvtt(sample)!; + const out = toUvtt({ image: m.image, gridSize: m.gridSize, cols: m.cols, rows: m.rows, walls: m.walls, doors: m.doors, lights: m.lights }) as { + resolution: { pixels_per_grid: number }; line_of_sight: { x: number; y: number }[][]; + }; + expect(out.resolution.pixels_per_grid).toBe(100); + expect(out.line_of_sight[0]).toEqual([{ x: 1, y: 1 }, { x: 5, y: 1 }, { x: 5, y: 4 }]); + }); +}); diff --git a/src/lib/vtt/uvtt.ts b/src/lib/vtt/uvtt.ts new file mode 100644 index 0000000..9d188d7 --- /dev/null +++ b/src/lib/vtt/uvtt.ts @@ -0,0 +1,131 @@ +import { z } from 'zod'; + +/** + * Universal VTT (.dd2vtt / .uvtt / .df2vtt) interchange — exported by Dungeondraft + * and importable by Foundry/Roll20/etc. JSON with a base64 image, grid size, and + * line-of-sight walls / portals (doors) / lights expressed in GRID units. + * + * We convert everything to image-pixel space (× pixels_per_grid) so it lines up + * with our tokens/drawings, and `pixels_per_grid` becomes our `gridSize`. + */ + +const pt = z.object({ x: z.number(), y: z.number() }); + +const uvttSchema = z.object({ + format: z.number().optional(), + resolution: z.object({ + map_origin: pt.optional(), + map_size: pt.optional(), + pixels_per_grid: z.number().optional(), + }).optional(), + line_of_sight: z.array(z.array(pt)).optional(), + objects_line_of_sight: z.array(z.array(pt)).optional(), + portals: z.array(z.object({ + position: pt.optional(), + bounds: z.array(pt).optional(), + closed: z.boolean().optional(), + })).optional(), + lights: z.array(z.object({ + position: pt.optional(), + range: z.number().optional(), + color: z.string().optional(), + intensity: z.number().optional(), + })).optional(), + image: z.string().optional(), +}).passthrough(); + +export interface ParsedUvtt { + image: string; // data URL + gridSize: number; // px per cell + cols: number; + rows: number; + walls: { points: { x: number; y: number }[] }[]; + doors: { a: { x: number; y: number }; b: { x: number; y: number }; open: boolean }[]; + lights: { x: number; y: number; range: number; color: string; intensity: number }[]; +} + +/** Coerce a UVTT colour ("RRGGBBAA"/"AARRGGBB"/"#rgb") to a #rrggbb string. */ +function toHexColor(c: string | undefined): string { + if (!c) return '#ffd9a0'; + const hex = c.replace(/^#/, ''); + if (hex.length === 6) return `#${hex}`; + if (hex.length === 8) return `#${hex.slice(-6)}`; // drop leading alpha + if (hex.length === 3) return `#${hex}`; + return '#ffd9a0'; +} + +/** Parse UVTT JSON text. Returns null if it isn't a usable VTT map (no image). */ +export function parseUvtt(text: string): ParsedUvtt | null { + let json: unknown; + try { json = JSON.parse(text); } catch { return null; } + const parsed = uvttSchema.safeParse(json); + if (!parsed.success) return null; + const u = parsed.data; + if (!u.image) return null; // line-of-sight-only files aren't maps we can show + + const ppg = u.resolution?.pixels_per_grid && u.resolution.pixels_per_grid > 0 ? u.resolution.pixels_per_grid : 70; + const origin = u.resolution?.map_origin ?? { x: 0, y: 0 }; + const toPx = (p: { x: number; y: number }) => ({ x: (p.x - origin.x) * ppg, y: (p.y - origin.y) * ppg }); + + const size = u.resolution?.map_size ?? { x: 0, y: 0 }; + const cols = Math.max(0, Math.round(size.x)); + const rows = Math.max(0, Math.round(size.y)); + + const segs = [...(u.line_of_sight ?? []), ...(u.objects_line_of_sight ?? [])]; + const walls = segs.map((poly) => ({ points: poly.map(toPx) })).filter((w) => w.points.length >= 2); + + const doors = (u.portals ?? []).flatMap((p) => { + const b = p.bounds; + if (!b || b.length < 2 || !b[0] || !b[1]) return []; + return [{ a: toPx(b[0]), b: toPx(b[1]), open: p.closed === false }]; + }); + + const lights = (u.lights ?? []).flatMap((l) => { + if (!l.position) return []; + const c = toPx(l.position); + return [{ x: c.x, y: c.y, range: (l.range ?? 0) * ppg, color: toHexColor(l.color), intensity: l.intensity ?? 0.5 }]; + }); + + // PNG is the UVTT default; browsers decode by content regardless of declared type. + const image = u.image.startsWith('data:') ? u.image : `data:image/png;base64,${u.image}`; + return { image, gridSize: Math.round(ppg), cols, rows, walls, doors, lights }; +} + +/** Build a UVTT object from one of our maps (image data URL + walls), for export. */ +export function toUvtt(map: { + image: string; + gridSize: number; + cols: number; + rows: number; + walls: { points: { x: number; y: number }[] }[]; + doors: { a: { x: number; y: number }; b: { x: number; y: number }; open: boolean }[]; + lights: { x: number; y: number; range: number; color: string; intensity: number }[]; +}): Record { + const ppg = map.gridSize || 70; + const toGrid = (p: { x: number; y: number }) => ({ x: p.x / ppg, y: p.y / ppg }); + const base64 = map.image.replace(/^data:[^,]*,/, ''); + return { + format: 0.3, + resolution: { + map_origin: { x: 0, y: 0 }, + map_size: { x: map.cols, y: map.rows }, + pixels_per_grid: ppg, + }, + line_of_sight: map.walls.map((w) => w.points.map(toGrid)), + portals: map.doors.map((d) => ({ + position: toGrid({ x: (d.a.x + d.b.x) / 2, y: (d.a.y + d.b.y) / 2 }), + bounds: [toGrid(d.a), toGrid(d.b)], + rotation: 0, + closed: !d.open, + freestanding: false, + })), + lights: map.lights.map((l) => ({ + position: toGrid({ x: l.x, y: l.y }), + range: l.range / ppg, + intensity: l.intensity, + color: l.color.replace('#', ''), + shadows: true, + })), + image: base64, + }; +}