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:
@@ -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=="
|
||||||
|
}
|
||||||
@@ -38,6 +38,21 @@ test('maps: upload, place token, reveal all, show to players', async ({ page })
|
|||||||
await expect(page.getByTestId('map-token')).toHaveCount(1);
|
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 }) => {
|
test('maps: zoom controls scale the canvas and fit resets', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
||||||
await page.locator('input[data-autofocus]').fill('Zoom');
|
await page.locator('input[data-autofocus]').fill('Zoom');
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Link } from '@tanstack/react-router';
|
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 { 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 { useUiStore } from '@/stores/uiStore';
|
||||||
import { useMaps } from './hooks';
|
import { useMaps } from './hooks';
|
||||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
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 { cn } from '@/lib/cn';
|
||||||
import { MapEditor } from './map/MapEditor';
|
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() {
|
export function MapsPage() {
|
||||||
return <RequireCampaign>{(c) => <Maps campaign={c} />}</RequireCampaign>;
|
return <RequireCampaign>{(c) => <Maps campaign={c} />}</RequireCampaign>;
|
||||||
@@ -24,17 +28,41 @@ function Maps({ campaign }: { campaign: Campaign }) {
|
|||||||
const setActiveMap = useUiStore((s) => s.setActiveMap);
|
const setActiveMap = useUiStore((s) => s.setActiveMap);
|
||||||
const selected = maps.find((m) => m.id === selectedId) ?? null;
|
const selected = maps.find((m) => m.id === selectedId) ?? null;
|
||||||
|
|
||||||
const upload = (file: File) => {
|
const importFile = (file: File) => {
|
||||||
setError(null);
|
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();
|
const reader = new FileReader();
|
||||||
reader.onload = async () => {
|
reader.onload = async () => {
|
||||||
const dataUrl = typeof reader.result === 'string' ? reader.result : '';
|
const result = reader.result;
|
||||||
if (!dataUrl) return;
|
if (typeof result !== 'string') return;
|
||||||
const map = await mapsRepo.create(campaign.id, file.name.replace(/\.[^.]+$/, ''), dataUrl);
|
try {
|
||||||
setSelectedId(map.id);
|
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 (
|
return (
|
||||||
@@ -42,14 +70,14 @@ function Maps({ campaign }: { campaign: Campaign }) {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Maps"
|
title="Maps"
|
||||||
subtitle={campaign.name}
|
subtitle={campaign.name}
|
||||||
actions={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Upload map</Button>}
|
actions={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>}
|
||||||
/>
|
/>
|
||||||
<input ref={fileRef} type="file" accept="image/*" className="hidden"
|
<input ref={fileRef} type="file" accept=".dd2vtt,.uvtt,.df2vtt,image/*" className="hidden"
|
||||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }} />
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) importFile(f); e.target.value = ''; }} />
|
||||||
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
|
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
|
||||||
|
|
||||||
{maps.length === 0 ? (
|
{maps.length === 0 ? (
|
||||||
<EmptyState title="No maps yet" hint="Upload a battle map, add a grid, paint fog of war, drop linked tokens, measure ranges — then show it to players." action={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Upload map</Button>} />
|
<EmptyState title="No maps yet" hint="Upload an image or import a Universal VTT (.dd2vtt / .uvtt) map — walls, doors and lights come across — then add a grid, fog, tokens, and show it to players." action={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 lg:grid-cols-[210px_1fr]">
|
<div className="grid gap-4 lg:grid-cols-[210px_1fr]">
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
@@ -69,6 +97,7 @@ function Maps({ campaign }: { campaign: Campaign }) {
|
|||||||
{activeMapId === selected.id ? '📺 Showing to players' : 'Show to players'}
|
{activeMapId === selected.id ? '📺 Showing to players' : 'Show to players'}
|
||||||
</Button>
|
</Button>
|
||||||
<Link to="/play" className="text-sm text-accent hover:underline">Open player view ↗</Link>
|
<Link to="/play" className="text-sm text-accent hover:underline">Open player view ↗</Link>
|
||||||
|
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => void exportUvtt(selected)} title="Export as Universal VTT (.uvtt)">Export .uvtt</Button>
|
||||||
</div>
|
</div>
|
||||||
<MapEditor key={selected.id} map={selected} campaign={campaign} />
|
<MapEditor key={selected.id} map={selected} campaign={campaign} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface CanvasView {
|
|||||||
revealed: string[];
|
revealed: string[];
|
||||||
tokens: CanvasToken[];
|
tokens: CanvasToken[];
|
||||||
drawings: CanvasDrawing[];
|
drawings: CanvasDrawing[];
|
||||||
|
/** sight-blocking walls, drawn faintly for the GM (not sent to players) */
|
||||||
|
walls?: { points: Point[] }[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Overlay {
|
export interface Overlay {
|
||||||
@@ -137,7 +139,7 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
|
|||||||
drawScene(canvasRef.current, imgRef.current, size.w, size.h, {
|
drawScene(canvasRef.current, imgRef.current, size.w, size.h, {
|
||||||
vp, W, H, gridSize, cols, rows,
|
vp, W, H, gridSize, cols, rows,
|
||||||
showGrid: view.showGrid, fogEnabled: view.fogEnabled, revealed: view.revealed,
|
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);
|
return () => cancelAnimationFrame(raf);
|
||||||
@@ -228,7 +230,7 @@ interface SceneParams {
|
|||||||
vp: Viewport;
|
vp: Viewport;
|
||||||
W: number; H: number; gridSize: number; cols: number; rows: number;
|
W: number; H: number; gridSize: number; cols: number; rows: number;
|
||||||
showGrid: boolean; fogEnabled: boolean; revealed: string[]; playerFog: boolean;
|
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. */
|
/** Draw the whole scene at device resolution with a camera transform. */
|
||||||
@@ -291,6 +293,21 @@ function drawScene(canvas: HTMLCanvasElement | null, img: HTMLImageElement | nul
|
|||||||
cam();
|
cam();
|
||||||
for (const d of p.drawings) drawShape(ctx, d);
|
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)
|
// Overlay (AoE/measure/draft/poly/brush)
|
||||||
const o = p.overlay;
|
const o = p.overlay;
|
||||||
if (o) {
|
if (o) {
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
|
|
||||||
<MapCanvas
|
<MapCanvas
|
||||||
viewportHeight="calc(100vh - 17rem)"
|
viewportHeight="calc(100vh - 17rem)"
|
||||||
view={{ image: m.image, gridSize: m.gridSize, showGrid: m.showGrid, fogEnabled: m.fogEnabled, revealed: m.revealed, tokens: canvasTokens, drawings: m.drawings }}
|
view={{ image: m.image, gridSize: m.gridSize, showGrid: m.showGrid, fogEnabled: m.fogEnabled, revealed: m.revealed, tokens: canvasTokens, drawings: m.drawings, walls: m.walls }}
|
||||||
overlay={overlay}
|
overlay={overlay}
|
||||||
tokensDraggable={tool === 'move'}
|
tokensDraggable={tool === 'move'}
|
||||||
{...(tool !== 'move' ? { onPointer, onLeave } : {})}
|
{...(tool !== 'move' ? { onPointer, onLeave } : {})}
|
||||||
|
|||||||
@@ -104,6 +104,16 @@ export class TtrpgDatabase extends Dexie {
|
|||||||
// v10 — Phase 2: characters gain `portrait`, map tokens gain `image`
|
// v10 — Phase 2: characters gain `portrait`, map tokens gain `image`
|
||||||
// (both optional data URLs). No backfill needed.
|
// (both optional data URLs). No backfill needed.
|
||||||
this.version(10).stores({});
|
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,
|
type BattleMap,
|
||||||
} from '@/lib/schemas';
|
} from '@/lib/schemas';
|
||||||
import { getSystem } from '@/lib/rules';
|
import { getSystem } from '@/lib/rules';
|
||||||
|
import type { ParsedUvtt } from '@/lib/vtt/uvtt';
|
||||||
|
|
||||||
function now(): string {
|
function now(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -306,6 +307,20 @@ export const mapsRepo = {
|
|||||||
await db.maps.add(map);
|
await db.maps.add(map);
|
||||||
return 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> {
|
get(id: string): Promise<BattleMap | undefined> {
|
||||||
return db.maps.get(id);
|
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. */
|
/** Read a File as a data URL. */
|
||||||
export function fileToDataUrl(file: File): Promise<string> {
|
export function fileToDataUrl(file: File): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ export function downloadJson(filename: string, data: unknown): void {
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
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). */
|
/** 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> {
|
export function pickTextFile(accept = 'application/json,.json'): Promise<string | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|||||||
@@ -125,6 +125,33 @@ export const drawingSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type MapDrawing = z.infer<typeof drawingSchema>;
|
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({
|
export const battleMapSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
campaignId: z.string(),
|
campaignId: z.string(),
|
||||||
@@ -139,6 +166,12 @@ export const battleMapSchema = z.object({
|
|||||||
tokens: z.array(mapTokenSchema).default([]),
|
tokens: z.array(mapTokenSchema).default([]),
|
||||||
/** GM annotation layer (some entries may be gmOnly) */
|
/** GM annotation layer (some entries may be gmOnly) */
|
||||||
drawings: z.array(drawingSchema).default([]),
|
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 */
|
/** real-world feet per grid cell, for measurement + AoE labels */
|
||||||
gridUnit: z.object({ feet: int.min(1).max(100).default(5) }).default({ feet: 5 }),
|
gridUnit: z.object({ feet: int.min(1).max(100).default(5) }).default({ feet: 5 }),
|
||||||
gridType: z.enum(['square', 'hex']).default('square'),
|
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