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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user