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,
+ };
+}