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
+42 -13
View File
@@ -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 <RequireCampaign>{(c) => <Maps campaign={c} />}</RequireCampaign>;
@@ -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 }) {
<PageHeader
title="Maps"
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"
onChange={(e) => { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }} />
<input ref={fileRef} type="file" accept=".dd2vtt,.uvtt,.df2vtt,image/*" className="hidden"
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>}
{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]">
<ul className="space-y-1">
@@ -69,6 +97,7 @@ function Maps({ campaign }: { campaign: Campaign }) {
{activeMapId === selected.id ? '📺 Showing to players' : 'Show to players'}
</Button>
<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>
<MapEditor key={selected.id} map={selected} campaign={campaign} />
</div>
+19 -2
View File
@@ -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) {
+1 -1
View File
@@ -231,7 +231,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
<MapCanvas
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}
tokensDraggable={tool === 'move'}
{...(tool !== 'move' ? { onPointer, onLeave } : {})}