99c7657f96
Ported all 9 screens to the prototype layout (visual only; all data wiring, text, aria-labels, and testids preserved) via parallel agents + central verification: - Dashboard: editorial hero, party roster w/ HP meters + avatars, quests, threats, rolls. - Campaigns: cover-art deck cards + gilt rule + footer. - Character sheet + roster: hero header, StatCoin abilities, prof rows; grimoire cards. - Combat: glowing initiative rows (PC gilt / foe ember), condition badges, log. - Dice: die-pool glyphs, adv/dis segmented toggle, crit/fumble stage, history. - Compendium: Spectral stat blocks (ember headers, ability row). - Settings: grouped icon-tile cards. Live Session: room-code card, seat grid, player board. - Battle Map: carded list + tooled editor chrome (canvas untouched). Regression fixes after the ports: dice die-buttons aria-label `d4` not `Roll 1d4` (was colliding with the primary Roll button); map "Open player view" link uses an ExternalLink icon (test updated, ↗ glyph removed); seat-claim card gets a stable data-testid="seat-option" (redesign moved rounded-lg→rounded-xl). 223 unit + 34 e2e + 2 realtime green; tsc + eslint + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
408 lines
26 KiB
TypeScript
408 lines
26 KiB
TypeScript
import { useMemo, useRef, useState } from 'react';
|
||
import {
|
||
Move, ScanEye, EyeOff, Ruler, Cloud, PenTool, Crosshair, Spline, CirclePlus,
|
||
ChevronRight, type LucideIcon,
|
||
} from 'lucide-react';
|
||
import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
|
||
import { mapsRepo, encountersRepo } from '@/lib/db/repositories';
|
||
import { nextTurn, previousTurn, applyDamage, applyHealing, updateCombatant, logEvent } from '@/lib/combat/engine';
|
||
import { newId } from '@/lib/ids';
|
||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
|
||
import {
|
||
brushCells, rectCells, polygonCells, circleCells, coneCells, lineCells, squareCells,
|
||
gridDistance, toFeet, applyReveal, applyHide, revealAll, hideAll, worldToCell,
|
||
blockingSegments, computeVisibleCells,
|
||
type Point,
|
||
} from '@/lib/map';
|
||
import { useCharacters } from '@/features/characters/hooks';
|
||
import { useEncounters } from '@/features/combat/hooks';
|
||
import { Button } from '@/components/ui/Button';
|
||
import { Input, Select } from '@/components/ui/Input';
|
||
import { NumberField } from '@/components/ui/NumberField';
|
||
import { Modal } from '@/components/ui/Modal';
|
||
import { cn } from '@/lib/cn';
|
||
import { MapCanvas, type CanvasToken, type Overlay } from './MapCanvas';
|
||
import { TokenPalette, type TokenSpec } from './TokenPalette';
|
||
|
||
const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000'];
|
||
type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping' | 'walls';
|
||
|
||
const TOOL_ICON: Record<Tool, LucideIcon> = {
|
||
move: Move, reveal: ScanEye, hide: EyeOff, measure: Ruler,
|
||
aoe: Cloud, draw: PenTool, ping: Crosshair, walls: Spline,
|
||
};
|
||
|
||
/** Distance from point p to segment a-b (image px). */
|
||
function distToSegment(p: Point, a: Point, b: Point): number {
|
||
const dx = b.x - a.x, dy = b.y - a.y;
|
||
const len2 = dx * dx + dy * dy;
|
||
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
|
||
const cx = a.x + t * dx, cy = a.y + t * dy;
|
||
return Math.hypot(p.x - cx, p.y - cy);
|
||
}
|
||
type FogShape = 'brush' | 'rect' | 'poly';
|
||
type AoeShape = 'circle' | 'cone' | 'line' | 'square';
|
||
type DrawKind = MapDrawing['kind'];
|
||
|
||
export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaign }) {
|
||
const [m, setM] = useState<BattleMap>(map);
|
||
const characters = useCharacters(campaign.id);
|
||
const encounters = useEncounters(campaign.id);
|
||
const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400);
|
||
const update = (patch: Partial<BattleMap>) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
|
||
|
||
const [tool, setTool] = useState<Tool>('move');
|
||
const [fogShape, setFogShape] = useState<FogShape>('brush');
|
||
const [brush, setBrush] = useState(0);
|
||
const [aoeShape, setAoeShape] = useState<AoeShape>('circle');
|
||
const [aoeFeet, setAoeFeet] = useState(20);
|
||
const [drawKind, setDrawKind] = useState<DrawKind>('freehand');
|
||
const [drawColor, setDrawColor] = useState('#d4af37');
|
||
const [gmDraw, setGmDraw] = useState(true);
|
||
const [dims, setDims] = useState({ cols: 0, rows: 0 });
|
||
const [editToken, setEditToken] = useState<string | null>(null);
|
||
// Collapsed by default on small screens so the map gets the width.
|
||
const [showPalette, setShowPalette] = useState(() => typeof window === 'undefined' || window.innerWidth >= 880);
|
||
|
||
// transient interaction state
|
||
const [overlay, setOverlay] = useState<Overlay>({});
|
||
const anchor = useRef<Point | null>(null);
|
||
const [poly, setPoly] = useState<Point[]>([]);
|
||
const draftPts = useRef<Point[]>([]);
|
||
|
||
const distMode = campaign.system === 'pf2e' ? 'pf2e' : '5e';
|
||
const activeEncounter = useMemo(() => encounters.find((e) => e.status === 'active') ?? null, [encounters]);
|
||
const activeCombatant = activeEncounter ? activeEncounter.combatants[activeEncounter.turnIndex] : undefined;
|
||
const activeTokenId = activeCombatant ? m.tokens.find((t) => t.combatantId === activeCombatant.id)?.id : undefined;
|
||
|
||
const damageCombatant = (id: string, amt: number) => {
|
||
if (!activeEncounter) return;
|
||
void encountersRepo.mutate(activeEncounter.id, (e) => {
|
||
const cb = e.combatants.find((c) => c.id === id);
|
||
if (!cb) return e;
|
||
const updated = amt < 0 ? applyDamage(cb, -amt) : applyHealing(cb, amt);
|
||
return logEvent(updateCombatant(e, id, { hp: updated.hp }), `${cb.name} ${amt < 0 ? `takes ${-amt} damage` : `heals ${amt}`}`);
|
||
});
|
||
};
|
||
|
||
// Live HP/conditions: prefer an active-encounter combatant, then a linked
|
||
// character, then the token's denormalized snapshot.
|
||
const canvasTokens: CanvasToken[] = useMemo(() => m.tokens.map((t) => {
|
||
const linkedChar = t.characterId ? characters.find((c) => c.id === t.characterId) : undefined;
|
||
const linkedCb = t.combatantId && activeEncounter ? activeEncounter.combatants.find((c) => c.id === t.combatantId) : undefined;
|
||
const image = t.image ?? linkedChar?.portrait;
|
||
return {
|
||
id: t.id, label: t.label || (linkedChar?.name ?? linkedCb?.name ?? ''), color: t.color, col: t.col, row: t.row,
|
||
size: t.size, kind: t.kind,
|
||
...(image ? { image } : {}),
|
||
hp: linkedCb?.hp ?? linkedChar?.hp ?? t.hp,
|
||
conditions: linkedCb?.conditions ?? t.conditions,
|
||
};
|
||
}), [m.tokens, characters, activeEncounter]);
|
||
|
||
const commitReveal = (cells: string[], reveal: boolean) =>
|
||
update({ revealed: reveal ? applyReveal(m.revealed, cells) : applyHide(m.revealed, cells) });
|
||
|
||
const onPointer = (phase: 'down' | 'move' | 'up' | 'hover', p: { point: Point; col: number; row: number }) => {
|
||
// Live previews when no button is held.
|
||
if (phase === 'hover') {
|
||
if (tool === 'reveal' || tool === 'hide') {
|
||
if (fogShape === 'brush') setOverlay({ brush: { col: p.col, row: p.row, size: brush } });
|
||
else if (fogShape === 'poly') setOverlay({ poly: { points: poly, cursor: p.point }, ...(poly.length >= 2 ? { cells: polygonCells([...poly, p.point], gridSpec()) } : {}) });
|
||
} else if (tool === 'aoe' && (aoeShape === 'circle' || aoeShape === 'square')) {
|
||
setOverlay({ cells: aoeShape === 'circle' ? circleCells(p.point, aoeFeet, gridSpec()) : squareCells(p.point, aoeFeet, gridSpec()) });
|
||
} else if (tool === 'walls') {
|
||
setOverlay({ poly: { points: poly, cursor: p.point } });
|
||
}
|
||
return;
|
||
}
|
||
if (tool === 'walls') {
|
||
if (phase === 'down') {
|
||
if (toggleDoorNear(p.point)) return; // click a door to open/close it
|
||
const next = [...poly, p.point];
|
||
setPoly(next);
|
||
setOverlay({ poly: { points: next, cursor: p.point } });
|
||
}
|
||
return;
|
||
}
|
||
const reveal = tool === 'reveal';
|
||
if (tool === 'reveal' || tool === 'hide') {
|
||
if (fogShape === 'brush') {
|
||
if (phase !== 'up') { commitReveal(brushCells(p.col, p.row, brush, dims.cols, dims.rows), reveal); setOverlay({ brush: { col: p.col, row: p.row, size: brush } }); }
|
||
} else if (fogShape === 'rect') {
|
||
if (phase === 'down') anchor.current = p.point;
|
||
else if (anchor.current) {
|
||
const a = worldToCell(anchor.current, m.gridSize);
|
||
const cells = rectCells(a, { col: p.col, row: p.row }, dims.cols, dims.rows);
|
||
if (phase === 'move') setOverlay({ cells });
|
||
else { commitReveal(cells, reveal); anchor.current = null; setOverlay({}); }
|
||
}
|
||
} else if (fogShape === 'poly') {
|
||
if (phase === 'down') { const next = [...poly, p.point]; setPoly(next); setOverlay({ poly: { points: next, cursor: p.point }, ...(next.length >= 3 ? { cells: polygonCells(next, gridSpec()) } : {}) }); }
|
||
}
|
||
return;
|
||
}
|
||
if (tool === 'measure') {
|
||
if (phase === 'down') anchor.current = p.point;
|
||
else if (anchor.current) {
|
||
const a = worldToCell(anchor.current, m.gridSize);
|
||
const cells = gridDistance(a, { col: p.col, row: p.row }, distMode);
|
||
setOverlay({ line: { a: anchor.current, b: p.point }, label: { at: p.point, text: `${toFeet(cells, m.gridUnit.feet)} ft` } });
|
||
if (phase === 'up') anchor.current = null;
|
||
}
|
||
return;
|
||
}
|
||
if (tool === 'aoe') {
|
||
if (phase === 'down') anchor.current = p.point;
|
||
const origin = anchor.current ?? p.point;
|
||
let cells: string[] = [];
|
||
if (aoeShape === 'circle') cells = circleCells(p.point, aoeFeet, gridSpec());
|
||
else if (aoeShape === 'square') cells = squareCells(p.point, aoeFeet, gridSpec());
|
||
else if (aoeShape === 'cone') cells = coneCells(origin, p.point, aoeFeet, 53, gridSpec());
|
||
else cells = lineCells(origin, p.point, 5, gridSpec());
|
||
setOverlay({ cells });
|
||
if (phase === 'up' && aoeShape !== 'circle' && aoeShape !== 'square') anchor.current = null;
|
||
return;
|
||
}
|
||
if (tool === 'draw') {
|
||
if (drawKind === 'text') {
|
||
if (phase === 'down') {
|
||
const text = window.prompt('Label text:')?.trim();
|
||
if (text) update({ drawings: [...m.drawings, { id: newId(), kind: 'text', points: [p.point], color: drawColor, width: 4, text, gmOnly: gmDraw }] });
|
||
}
|
||
return;
|
||
}
|
||
if (phase === 'down') { draftPts.current = [p.point]; }
|
||
else if (phase === 'move') {
|
||
draftPts.current = drawKind === 'freehand' ? [...draftPts.current, p.point] : [draftPts.current[0]!, p.point];
|
||
setOverlay({ draft: { id: 'draft', kind: drawKind, points: draftPts.current, color: drawColor, width: 3 } });
|
||
} else {
|
||
if (draftPts.current.length >= 2) update({ drawings: [...m.drawings, { id: newId(), kind: drawKind, points: draftPts.current, color: drawColor, width: 3, gmOnly: gmDraw }] });
|
||
draftPts.current = []; setOverlay({});
|
||
}
|
||
return;
|
||
}
|
||
if (tool === 'ping' && phase === 'down') {
|
||
const at = p.point;
|
||
setOverlay({ cells: [`${p.col},${p.row}`] });
|
||
window.setTimeout(() => setOverlay((o) => (o.cells?.[0] === `${p.col},${p.row}` ? {} : o)), 1500);
|
||
void at;
|
||
}
|
||
};
|
||
|
||
const gridSpec = () => ({ gridSize: m.gridSize, feetPerCell: m.gridUnit.feet, cols: dims.cols, rows: dims.rows });
|
||
|
||
const finishPoly = () => { if (poly.length >= 3) commitReveal(polygonCells(poly, gridSpec()), tool === 'reveal'); setPoly([]); setOverlay({}); };
|
||
|
||
// --- walls & dynamic vision ---
|
||
const finishWall = () => { if (poly.length >= 2) update({ walls: [...m.walls, { id: newId(), points: poly }] }); setPoly([]); setOverlay({}); };
|
||
const toggleDoorNear = (p: Point): boolean => {
|
||
const thresh = m.gridSize * 0.5;
|
||
let bestI = -1, best = thresh;
|
||
m.doors.forEach((d, i) => { const dist = distToSegment(p, d.a, d.b); if (dist < best) { best = dist; bestI = i; } });
|
||
if (bestI < 0) return false;
|
||
update({ doors: m.doors.map((d, i) => (i === bestI ? { ...d, open: !d.open } : d)) });
|
||
return true;
|
||
};
|
||
/** Cells visible to the party (pc tokens), honouring walls + closed doors. */
|
||
const visionReveal = (tokens: MapToken[], revealed: string[]): string[] => {
|
||
const viewers = tokens.filter((t) => t.kind === 'pc').map((t) => ({ x: (t.col + t.size / 2) * m.gridSize, y: (t.row + t.size / 2) * m.gridSize }));
|
||
if (viewers.length === 0 || dims.cols === 0) return revealed;
|
||
const segments = blockingSegments({ walls: m.walls, doors: m.doors });
|
||
const radiusPx = m.sightRadiusFeet > 0 ? (m.sightRadiusFeet / m.gridUnit.feet) * m.gridSize : 0;
|
||
const vis = computeVisibleCells({ viewers, segments, gridSize: m.gridSize, cols: dims.cols, rows: dims.rows, radiusPx });
|
||
return applyReveal(revealed, [...vis]);
|
||
};
|
||
const revealFromParty = () => update({ fogEnabled: true, revealed: visionReveal(m.tokens, m.revealed) });
|
||
|
||
// Clear transient hover previews when the cursor leaves, but keep an in-progress polygon.
|
||
const onLeave = () => setOverlay(poly.length ? { poly: { points: poly } } : {});
|
||
|
||
const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false }] });
|
||
const addTokenSpec = (spec: TokenSpec) => update({ tokens: [...m.tokens, { id: newId(), ...spec }] });
|
||
const addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); };
|
||
const patchToken = (id: string, patch: Partial<MapToken>) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) });
|
||
const moveToken = (id: string, col: number, row: number) => setM((prev) => {
|
||
const tokens = prev.tokens.map((t) => (t.id === id ? { ...t, col, row } : t));
|
||
const revealed = prev.dynamicVision ? visionReveal(tokens, prev.revealed) : prev.revealed;
|
||
const next = { ...prev, tokens, revealed };
|
||
save(next);
|
||
return next;
|
||
});
|
||
const removeToken = (id: string) => update({ tokens: m.tokens.filter((t) => t.id !== id) });
|
||
const onTokenIcon = async (id: string, file: File) => {
|
||
const thumb = await squareThumbnail(await fileToDataUrl(file), 160);
|
||
patchToken(id, { image: thumb });
|
||
};
|
||
const token = m.tokens.find((t) => t.id === editToken) ?? null;
|
||
const tokenLinkedChar = token?.characterId ? characters.find((c) => c.id === token.characterId) : undefined;
|
||
|
||
return (
|
||
<div>
|
||
<div className="grid gap-2" style={{ gridTemplateColumns: showPalette ? '210px minmax(0, 1fr)' : 'minmax(0, 1fr)' }}>
|
||
{showPalette && (
|
||
<TokenPalette characters={characters} encounters={encounters} existingTokens={m.tokens}
|
||
onPlace={addTokenSpec} onPlaceMany={addTokenSpecs} onClose={() => setShowPalette(false)} />
|
||
)}
|
||
|
||
<div className="min-w-0">
|
||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2 text-sm paper-grain">
|
||
{!showPalette && (
|
||
<Button size="sm" variant="ghost" onClick={() => setShowPalette(true)}>
|
||
Tokens <ChevronRight size={14} aria-hidden />
|
||
</Button>
|
||
)}
|
||
<Input className="h-8 max-w-36 font-display" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
|
||
<span className="mx-1 h-5 w-px bg-line" aria-hidden />
|
||
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping', 'walls'] as Tool[]).map((t) => {
|
||
const ToolIcon = TOOL_ICON[t];
|
||
return (
|
||
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t}
|
||
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">
|
||
<ToolIcon size={15} aria-hidden />
|
||
{t}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Contextual sub-toolbar */}
|
||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel-2 p-2 text-xs text-muted">
|
||
<label className="flex items-center gap-1"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label>
|
||
<label className="flex items-center gap-1"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label>
|
||
<label className="flex items-center gap-1" title="Auto-reveal fog from party line of sight (walls block sight)"><input type="checkbox" checked={m.dynamicVision} onChange={(e) => update(e.target.checked ? { dynamicVision: true, fogEnabled: true, revealed: visionReveal(m.tokens, m.revealed) } : { dynamicVision: false })} /> Vision</label>
|
||
<label className="flex items-center gap-1">Cell px<NumberField className="w-16" value={m.gridSize} min={10} max={400} onChange={(gridSize) => update({ gridSize })} aria-label="Grid cell size" /></label>
|
||
<label className="flex items-center gap-1">Feet/cell<NumberField className="w-14" value={m.gridUnit.feet} min={1} max={100} onChange={(feet) => update({ gridUnit: { feet } })} aria-label="Feet per cell" /></label>
|
||
|
||
{(tool === 'reveal' || tool === 'hide') && (
|
||
<>
|
||
<span className="mx-1 h-4 w-px bg-line" />
|
||
{(['brush', 'rect', 'poly'] as FogShape[]).map((s) => (
|
||
<Button key={s} size="sm" variant={fogShape === s ? 'primary' : 'ghost'} onClick={() => { setFogShape(s); setPoly([]); setOverlay({}); }} className="capitalize">{s}</Button>
|
||
))}
|
||
{fogShape === 'brush' && <label className="flex items-center gap-1">Size<NumberField className="w-12" value={brush} min={0} max={5} onChange={setBrush} aria-label="Brush size" /></label>}
|
||
{fogShape === 'poly' && <Button size="sm" variant="secondary" disabled={poly.length < 3} onClick={finishPoly}>Finish polygon ({poly.length})</Button>}
|
||
<Button size="sm" variant="ghost" onClick={() => update({ revealed: revealAll(dims.cols, dims.rows) })}>Reveal all</Button>
|
||
<Button size="sm" variant="ghost" onClick={() => update({ revealed: hideAll() })}>Hide all</Button>
|
||
</>
|
||
)}
|
||
{tool === 'aoe' && (
|
||
<>
|
||
<span className="mx-1 h-4 w-px bg-line" />
|
||
{(['circle', 'cone', 'line', 'square'] as AoeShape[]).map((s) => (
|
||
<Button key={s} size="sm" variant={aoeShape === s ? 'primary' : 'ghost'} onClick={() => { setAoeShape(s); setOverlay({}); }} className="capitalize">{s}</Button>
|
||
))}
|
||
<label className="flex items-center gap-1">Feet<NumberField className="w-14" value={aoeFeet} min={5} max={120} onChange={setAoeFeet} aria-label="AoE size feet" /></label>
|
||
<Button size="sm" variant="ghost" onClick={() => setOverlay({})}>Clear</Button>
|
||
</>
|
||
)}
|
||
{tool === 'draw' && (
|
||
<>
|
||
<span className="mx-1 h-4 w-px bg-line" />
|
||
<Select className="h-8 w-28" value={drawKind} onChange={(e) => setDrawKind(e.target.value as DrawKind)} aria-label="Draw kind">
|
||
{(['freehand', 'line', 'arrow', 'rect', 'circle', 'text'] as DrawKind[]).map((k) => <option key={k} value={k}>{k}</option>)}
|
||
</Select>
|
||
<input type="color" value={drawColor} onChange={(e) => setDrawColor(e.target.value)} aria-label="Draw color" className="h-7 w-9" />
|
||
<label className="flex items-center gap-1"><input type="checkbox" checked={gmDraw} onChange={(e) => setGmDraw(e.target.checked)} /> GM only</label>
|
||
<Button size="sm" variant="ghost" disabled={m.drawings.length === 0} onClick={() => update({ drawings: m.drawings.slice(0, -1) })}>Undo draw</Button>
|
||
<Button size="sm" variant="ghost" disabled={m.drawings.length === 0} onClick={() => update({ drawings: [] })}>Clear drawings</Button>
|
||
</>
|
||
)}
|
||
{tool === 'walls' && (
|
||
<>
|
||
<span className="mx-1 h-4 w-px bg-line" />
|
||
<span className="text-[11px]">Click to add wall points; click a door to open/close.</span>
|
||
<Button size="sm" variant="secondary" disabled={poly.length < 2} onClick={finishWall}>Finish wall ({poly.length})</Button>
|
||
<Button size="sm" variant="ghost" disabled={m.walls.length === 0} onClick={() => update({ walls: [] })}>Clear walls</Button>
|
||
<label className="flex items-center gap-1">Sight ft<NumberField className="w-14" value={m.sightRadiusFeet} min={0} max={500} onChange={(v) => update({ sightRadiusFeet: v })} aria-label="Sight radius feet" /></label>
|
||
<Button size="sm" variant="secondary" onClick={revealFromParty}>Reveal from party</Button>
|
||
</>
|
||
)}
|
||
<span className="mx-1 h-4 w-px bg-line" />
|
||
<Button size="sm" variant="secondary" onClick={addToken}>
|
||
<CirclePlus size={14} aria-hidden />
|
||
+ Token
|
||
</Button>
|
||
</div>
|
||
|
||
{activeEncounter && (
|
||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-accent-glow p-2 text-sm">
|
||
<span className="smallcaps text-accent-deep">Combat · Round {activeEncounter.round}</span>
|
||
<span className="font-display font-semibold text-ink">▶ {activeCombatant?.name ?? '—'}</span>
|
||
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}>‹ Prev</Button>
|
||
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn ›</Button>
|
||
{activeTokenId ? <span className="text-xs text-muted">— their token glows on the map</span> : <span className="text-xs text-muted">— place their token from the palette</span>}
|
||
</div>
|
||
)}
|
||
|
||
<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, walls: m.walls, doors: m.doors }}
|
||
overlay={overlay}
|
||
tokensDraggable={tool === 'move'}
|
||
activeTokenId={activeTokenId}
|
||
{...(tool !== 'move' ? { onPointer, onLeave } : {})}
|
||
onTokenMove={moveToken}
|
||
onTokenClick={(id) => setEditToken(id)}
|
||
onReady={setDims}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="mt-2 text-xs text-muted">
|
||
Place party/foe tokens from the left palette. Drag to move (Move tool); scroll to zoom, drag empty space (or middle/right-drag) to pan.
|
||
Use <strong>Reveal/Hide</strong> for fog, <strong>Measure</strong>/<strong>AoE</strong> for ranges, <strong>Draw</strong> for annotations, then “Show to players”.
|
||
</p>
|
||
|
||
{token && (
|
||
<Modal open onClose={() => setEditToken(null)} title="Token"
|
||
footer={<><Button variant="ghost" onClick={() => setEditToken(null)}>Done</Button><Button variant="danger" onClick={() => { removeToken(token.id); setEditToken(null); }}>Delete</Button></>}>
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<label className="block text-xs text-muted">Label<Input value={token.label} onChange={(e) => patchToken(token.id, { label: e.target.value })} /></label>
|
||
<label className="block text-xs text-muted">Size<Select value={token.size} onChange={(e) => patchToken(token.id, { size: Number(e.target.value) })}>{[1, 2, 3, 4].map((s) => <option key={s} value={s}>{s}×{s}</option>)}</Select></label>
|
||
<label className="block text-xs text-muted">Kind<Select value={token.kind} onChange={(e) => patchToken(token.id, { kind: e.target.value as MapToken['kind'] })}>{['pc', 'npc', 'monster', 'object'].map((k) => <option key={k} value={k}>{k}</option>)}</Select></label>
|
||
<label className="block text-xs text-muted">Link character<Select value={token.characterId ?? ''} onChange={(e) => patchToken(token.id, e.target.value ? { characterId: e.target.value } : { characterId: undefined })}><option value="">— none —</option>{characters.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></label>
|
||
{activeEncounter && (
|
||
<label className="block text-xs text-muted">Link combatant<Select value={token.combatantId ?? ''} onChange={(e) => patchToken(token.id, e.target.value ? { combatantId: e.target.value } : { combatantId: undefined })}><option value="">— none —</option>{activeEncounter.combatants.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></label>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-muted">Icon</span>
|
||
{token.image ?? tokenLinkedChar?.portrait
|
||
? <img src={token.image ?? tokenLinkedChar?.portrait} alt="" className="h-10 w-10 rounded-full border border-line object-cover" />
|
||
: <span className="grid h-10 w-10 place-items-center rounded-full border border-line text-[10px] text-muted">none</span>}
|
||
<label className="cursor-pointer rounded-md border border-line px-2 py-1 text-xs text-ink hover:border-accent">
|
||
Upload
|
||
<input type="file" accept="image/*" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onTokenIcon(token.id, f); e.target.value = ''; }} />
|
||
</label>
|
||
{token.image && <Button size="sm" variant="ghost" onClick={() => patchToken(token.id, { image: undefined })}>Clear</Button>}
|
||
{!token.image && tokenLinkedChar?.portrait && <span className="text-[10px] text-muted">using {tokenLinkedChar.name}'s portrait</span>}
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-1">
|
||
{TOKEN_COLORS.map((c) => <button key={c} aria-label={`color ${c}`} onClick={() => patchToken(token.id, { color: c })} className={cn('h-6 w-6 rounded-full border', token.color === c ? 'border-accent' : 'border-line')} style={{ background: c }} />)}
|
||
</div>
|
||
<label className="flex items-center gap-2 text-sm text-ink"><input type="checkbox" checked={token.gmOnly} onChange={(e) => patchToken(token.id, { gmOnly: e.target.checked })} /> Hidden from players (GM-only)</label>
|
||
|
||
{token.combatantId && activeEncounter?.combatants.some((c) => c.id === token.combatantId) && (() => {
|
||
const cb = activeEncounter.combatants.find((c) => c.id === token.combatantId)!;
|
||
return (
|
||
<div className="rounded-md border border-line bg-surface p-2">
|
||
<div className="mb-1 flex items-center justify-between text-xs"><span className="text-muted">Combat HP (syncs to the tracker)</span><span className="tabular-nums text-ink">{cb.hp.current}/{cb.hp.max}</span></div>
|
||
<div className="flex flex-wrap gap-1">
|
||
<Button size="sm" variant="danger" onClick={() => damageCombatant(token.combatantId!, -5)}>−5</Button>
|
||
<Button size="sm" variant="secondary" onClick={() => damageCombatant(token.combatantId!, -1)}>−1</Button>
|
||
<Button size="sm" variant="secondary" onClick={() => damageCombatant(token.combatantId!, 1)}>+1</Button>
|
||
<Button size="sm" variant="primary" onClick={() => damageCombatant(token.combatantId!, 5)}>+5</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|