Phase 3: Universal VTT (.dd2vtt/.uvtt) import + export

- src/lib/vtt/uvtt.ts: pure, tested parser converting UVTT grid-unit geometry to
  image pixels (pixels_per_grid → gridSize), extracting the base64 image, walls
  (line_of_sight), doors (portals) and lights; plus toUvtt() for export.
- BattleMap gains walls/doors/lights (Dexie v11, backfilled). mapsRepo.createFromVtt.
- MapsPage "+ Add map" now accepts .dd2vtt/.uvtt/.df2vtt (text) and images, and a
  per-map "Export .uvtt" (image dims → map_size). downloadText() helper.
- Walls render faintly for the GM (CanvasView.walls / drawScene) so imported
  line-of-sight is visible; Phase 4 will turn walls into dynamic vision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 14:51:56 +02:00
parent 1aff63f29c
commit 36f0ea66b3
12 changed files with 338 additions and 16 deletions
+10
View File
@@ -104,6 +104,16 @@ export class TtrpgDatabase extends Dexie {
// v10 — Phase 2: characters gain `portrait`, map tokens gain `image`
// (both optional data URLs). No backfill needed.
this.version(10).stores({});
// v11 — Phase 3: maps gain walls/doors/lights (UVTT import + dynamic vision).
// Backfill empty arrays so code can read them on pre-existing maps.
this.version(11).stores({}).upgrade(async (tx) => {
await tx.table('maps').toCollection().modify((m: Record<string, unknown>) => {
if (m.walls === undefined) m.walls = [];
if (m.doors === undefined) m.doors = [];
if (m.lights === undefined) m.lights = [];
});
});
}
}
+15
View File
@@ -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<BattleMap> {
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<BattleMap | undefined> {
return db.maps.get(id);
},
+5
View File
@@ -16,6 +16,11 @@ function loadImage(src: string): Promise<HTMLImageElement> {
});
}
/** 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<string> {
return new Promise((resolve, reject) => {
+13
View File
@@ -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<string | null> {
return new Promise((resolve) => {
+33
View File
@@ -125,6 +125,33 @@ export const drawingSchema = z.object({
});
export type MapDrawing = z.infer<typeof drawingSchema>;
/** 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<typeof mapWallSchema>;
/** 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<typeof mapDoorSchema>;
/** 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<typeof mapLightSchema>;
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'),
+46
View File
@@ -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 }]);
});
});
+131
View File
@@ -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<string, unknown> {
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,
};
}