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:
@@ -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 = [];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user