P20: map viewport zoom/pan/fit + token placement palette
Render maps at natural resolution inside a CSS-transform world layer (zoom, pan, fit-to-view) instead of shrinking to maxWidth — large maps are now usable. Pure viewport math in src/lib/map/viewport.ts (unit-tested). New TokenPalette places party PCs / active-encounter combatants / blanks in one click with dup detection; tokens gain optional combatantId (Dexie v9) for live encounter HP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Point } from '@/lib/map';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Point, Viewport } from '@/lib/map';
|
||||
import { fitViewport, zoomToPoint, worldFromScreen } from '@/lib/map';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/** Render-only drawing shape (both MapDrawing and PlayerDrawing satisfy this). */
|
||||
@@ -44,7 +45,8 @@ export interface Overlay {
|
||||
|
||||
interface Props {
|
||||
view: CanvasView;
|
||||
maxWidth?: number;
|
||||
/** CSS height of the scroll/clip viewport (e.g. 'calc(100vh - 14rem)') */
|
||||
viewportHeight?: string;
|
||||
readOnly?: boolean;
|
||||
/** dim unrevealed cells fully (player view) vs. translucent (GM editor) */
|
||||
playerFog?: boolean;
|
||||
@@ -56,13 +58,26 @@ interface Props {
|
||||
onReady?: (info: { cols: number; rows: number }) => void;
|
||||
}
|
||||
|
||||
/** Shared map renderer: image + grid + fog + drawings + AoE/measure overlay + tokens. */
|
||||
export function MapCanvas({ view, maxWidth = 880, readOnly, playerFog, overlay, onPointer, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) {
|
||||
/**
|
||||
* Shared map renderer. The image + all layers live in a "world" div sized to the
|
||||
* image's natural pixels; a CSS transform (translate+scale) provides zoom/pan so
|
||||
* large maps render crisp at full resolution instead of being shrunk to fit.
|
||||
*/
|
||||
export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) {
|
||||
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||
const [vp, setVp] = useState<Viewport>({ zoom: 1, panX: 0, panY: 0 });
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const worldRef = useRef<HTMLDivElement>(null);
|
||||
const fogRef = useRef<HTMLCanvasElement>(null);
|
||||
const drawRef = useRef<HTMLCanvasElement>(null);
|
||||
const overlayRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const gridSize = view.gridSize;
|
||||
const W = natural?.w ?? 0;
|
||||
const H = natural?.h ?? 0;
|
||||
const cols = natural ? Math.ceil(W / gridSize) : 0;
|
||||
const rows = natural ? Math.ceil(H / gridSize) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!view.image) { setNatural(null); return; }
|
||||
const img = new Image();
|
||||
@@ -70,109 +85,167 @@ export function MapCanvas({ view, maxWidth = 880, readOnly, playerFog, overlay,
|
||||
img.src = view.image;
|
||||
}, [view.image]);
|
||||
|
||||
const displayW = natural ? Math.min(natural.w, maxWidth) : 0;
|
||||
const scale = natural ? displayW / natural.w : 1;
|
||||
const displayH = natural ? natural.h * scale : 0;
|
||||
const cellPx = view.gridSize * scale;
|
||||
const cols = natural ? Math.ceil(natural.w / view.gridSize) : 0;
|
||||
const rows = natural ? Math.ceil(natural.h / view.gridSize) : 0;
|
||||
|
||||
useEffect(() => { if (natural) onReady?.({ cols, rows }); }, [cols, rows, natural, onReady]);
|
||||
|
||||
// Fit the image into the viewport whenever a new image loads.
|
||||
const fit = useCallback(() => {
|
||||
const el = outerRef.current;
|
||||
if (!el || !natural) return;
|
||||
setVp(fitViewport(el.clientWidth, el.clientHeight, natural.w, natural.h));
|
||||
}, [natural]);
|
||||
useEffect(() => { fit(); }, [fit]);
|
||||
|
||||
// Wheel zoom (native, non-passive so we can prevent page scroll).
|
||||
useEffect(() => {
|
||||
const el = outerRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const r = el.getBoundingClientRect();
|
||||
setVp((v) => zoomToPoint(v, Math.exp(-e.deltaY * 0.0015), e.clientX - r.left, e.clientY - r.top));
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
// Fog
|
||||
useEffect(() => {
|
||||
const cv = fogRef.current;
|
||||
if (!cv || !natural) return;
|
||||
cv.width = displayW; cv.height = displayH;
|
||||
cv.width = W; cv.height = H;
|
||||
const ctx = cv.getContext('2d'); if (!ctx) return;
|
||||
ctx.clearRect(0, 0, displayW, displayH);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
if (!view.fogEnabled) return;
|
||||
const revealed = new Set(view.revealed);
|
||||
ctx.fillStyle = playerFog ? 'rgba(8,8,12,1)' : 'rgba(0,0,0,0.6)';
|
||||
for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) {
|
||||
if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * cellPx, r * cellPx, cellPx + 0.5, cellPx + 0.5);
|
||||
if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * gridSize, r * gridSize, gridSize + 0.5, gridSize + 0.5);
|
||||
}
|
||||
}, [view.revealed, view.fogEnabled, playerFog, displayW, displayH, cellPx, cols, rows, natural]);
|
||||
}, [view.revealed, view.fogEnabled, playerFog, W, H, gridSize, cols, rows, natural]);
|
||||
|
||||
// Drawings
|
||||
useEffect(() => {
|
||||
const cv = drawRef.current;
|
||||
if (!cv || !natural) return;
|
||||
cv.width = displayW; cv.height = displayH;
|
||||
cv.width = W; cv.height = H;
|
||||
const ctx = cv.getContext('2d'); if (!ctx) return;
|
||||
ctx.clearRect(0, 0, displayW, displayH);
|
||||
for (const d of view.drawings) drawShape(ctx, d, scale);
|
||||
}, [view.drawings, displayW, displayH, scale, natural]);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
for (const d of view.drawings) drawShape(ctx, d);
|
||||
}, [view.drawings, W, H, natural]);
|
||||
|
||||
// Overlay (AoE/measure/draft)
|
||||
useEffect(() => {
|
||||
const cv = overlayRef.current;
|
||||
if (!cv || !natural) return;
|
||||
cv.width = displayW; cv.height = displayH;
|
||||
cv.width = W; cv.height = H;
|
||||
const ctx = cv.getContext('2d'); if (!ctx) return;
|
||||
ctx.clearRect(0, 0, displayW, displayH);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
if (overlay?.cells?.length) {
|
||||
ctx.fillStyle = 'rgba(212,175,55,0.35)';
|
||||
for (const k of overlay.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * cellPx, r! * cellPx, cellPx, cellPx); }
|
||||
for (const k of overlay.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * gridSize, r! * gridSize, gridSize, gridSize); }
|
||||
}
|
||||
if (overlay?.draft) drawShape(ctx, overlay.draft, scale);
|
||||
if (overlay?.draft) drawShape(ctx, overlay.draft);
|
||||
if (overlay?.line) {
|
||||
ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(overlay.line.a.x * scale, overlay.line.a.y * scale); ctx.lineTo(overlay.line.b.x * scale, overlay.line.b.y * scale); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(overlay.line.a.x, overlay.line.a.y); ctx.lineTo(overlay.line.b.x, overlay.line.b.y); ctx.stroke();
|
||||
}
|
||||
if (overlay?.label) {
|
||||
ctx.font = '600 13px sans-serif'; ctx.fillStyle = '#fff'; ctx.strokeStyle = 'rgba(0,0,0,0.7)'; ctx.lineWidth = 3;
|
||||
const x = overlay.label.at.x * scale + 6, y = overlay.label.at.y * scale - 6;
|
||||
const x = overlay.label.at.x + 6, y = overlay.label.at.y - 6;
|
||||
ctx.strokeText(overlay.label.text, x, y); ctx.fillText(overlay.label.text, x, y);
|
||||
}
|
||||
}, [overlay, displayW, displayH, cellPx, scale, natural]);
|
||||
}, [overlay, W, H, gridSize, natural]);
|
||||
|
||||
const toPoint = (e: React.PointerEvent): { point: Point; col: number; row: number } => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / scale, y = (e.clientY - rect.top) / scale;
|
||||
return { point: { x, y }, col: Math.floor(x / view.gridSize), row: Math.floor(y / view.gridSize) };
|
||||
const rect = (worldRef.current ?? (e.currentTarget as HTMLElement)).getBoundingClientRect();
|
||||
const pt = worldFromScreen(vp.zoom, e.clientX, e.clientY, rect.left, rect.top);
|
||||
return { point: pt, col: Math.floor(pt.x / gridSize), row: Math.floor(pt.y / gridSize) };
|
||||
};
|
||||
|
||||
// Pan (drag) state. Pan happens on middle/right button always, or on primary
|
||||
// button when no tool owns the canvas (move tool / read-only player view).
|
||||
const pan = useRef<{ x: number; y: number; px: number; py: number } | null>(null);
|
||||
const interactive = !readOnly && !!onPointer;
|
||||
|
||||
const ixDown = (e: React.PointerEvent) => {
|
||||
const wantPan = e.button === 1 || e.button === 2 || !interactive;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
if (wantPan) { pan.current = { x: e.clientX, y: e.clientY, px: vp.panX, py: vp.panY }; e.preventDefault(); return; }
|
||||
onPointer!('down', toPoint(e));
|
||||
};
|
||||
const ixMove = (e: React.PointerEvent) => {
|
||||
if (pan.current) {
|
||||
const { x, y, px, py } = pan.current;
|
||||
setVp((v) => ({ ...v, panX: px + (e.clientX - x), panY: py + (e.clientY - y) }));
|
||||
return;
|
||||
}
|
||||
if (interactive && e.buttons) onPointer!('move', toPoint(e));
|
||||
};
|
||||
const ixUp = (e: React.PointerEvent) => {
|
||||
if (pan.current) { pan.current = null; return; }
|
||||
if (interactive) onPointer!('up', toPoint(e));
|
||||
};
|
||||
|
||||
if (!view.image) return <p className="text-sm text-muted">No image.</p>;
|
||||
if (!natural) return <p className="text-sm text-muted">Loading map…</p>;
|
||||
|
||||
const interactive = !readOnly && !!onPointer;
|
||||
const zoomCentered = (factor: number) => {
|
||||
const el = outerRef.current; if (!el) return;
|
||||
setVp((v) => zoomToPoint(v, factor, el.clientWidth / 2, el.clientHeight / 2));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-auto rounded-lg border border-line bg-surface p-2">
|
||||
<div className="relative" style={{ width: displayW, height: displayH }}>
|
||||
<img src={view.image} alt="" className="absolute inset-0 h-full w-full select-none" draggable={false} />
|
||||
{view.showGrid && (
|
||||
<div className="pointer-events-none absolute inset-0" style={{
|
||||
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.22) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.22) 1px, transparent 1px)',
|
||||
backgroundSize: `${cellPx}px ${cellPx}px`,
|
||||
}} />
|
||||
)}
|
||||
<canvas ref={fogRef} className="pointer-events-none absolute inset-0" style={{ width: displayW, height: displayH }} />
|
||||
<canvas ref={drawRef} className="pointer-events-none absolute inset-0" style={{ width: displayW, height: displayH }} />
|
||||
<canvas ref={overlayRef} className="pointer-events-none absolute inset-0" style={{ width: displayW, height: displayH }} />
|
||||
{interactive && (
|
||||
<div ref={outerRef} className="relative overflow-hidden rounded-lg border border-line bg-surface" style={{ height: viewportHeight, touchAction: 'none' }}>
|
||||
{!natural ? (
|
||||
<p className="p-3 text-sm text-muted">Loading map…</p>
|
||||
) : (
|
||||
<div
|
||||
ref={worldRef}
|
||||
data-testid="map-world"
|
||||
className="absolute left-0 top-0"
|
||||
style={{ width: W, height: H, transform: `translate(${vp.panX}px, ${vp.panY}px) scale(${vp.zoom})`, transformOrigin: '0 0', willChange: 'transform' }}
|
||||
>
|
||||
<img src={view.image} alt="" className="absolute inset-0 h-full w-full select-none" draggable={false} />
|
||||
{view.showGrid && (
|
||||
<div className="pointer-events-none absolute inset-0" style={{
|
||||
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.22) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.22) 1px, transparent 1px)',
|
||||
backgroundSize: `${gridSize}px ${gridSize}px`,
|
||||
}} />
|
||||
)}
|
||||
<canvas ref={fogRef} className="pointer-events-none absolute inset-0" style={{ width: W, height: H }} />
|
||||
<canvas ref={drawRef} className="pointer-events-none absolute inset-0" style={{ width: W, height: H }} />
|
||||
<canvas ref={overlayRef} className="pointer-events-none absolute inset-0" style={{ width: W, height: H }} />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||
onPointerDown={(e) => { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); onPointer!('down', toPoint(e)); }}
|
||||
onPointerMove={(e) => { if (e.buttons) onPointer!('move', toPoint(e)); }}
|
||||
onPointerUp={(e) => onPointer!('up', toPoint(e))}
|
||||
style={{ cursor: interactive ? 'crosshair' : 'grab', touchAction: 'none' }}
|
||||
onPointerDown={ixDown}
|
||||
onPointerMove={ixMove}
|
||||
onPointerUp={ixUp}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
)}
|
||||
{view.tokens.map((t) => (
|
||||
<TokenChip key={t.id} token={t} cellPx={cellPx} cols={cols} rows={rows}
|
||||
draggable={!!tokensDraggable} onMove={onTokenMove} onClick={onTokenClick} />
|
||||
))}
|
||||
</div>
|
||||
{view.tokens.map((t) => (
|
||||
<TokenChip key={t.id} token={t} gridSize={gridSize} zoom={vp.zoom} cols={cols} rows={rows}
|
||||
draggable={!!tokensDraggable} onMove={onTokenMove} onClick={onTokenClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{natural && (
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1 rounded-md border border-line bg-panel/90 px-1 py-0.5 text-xs shadow backdrop-blur">
|
||||
<button className="grid h-6 w-6 place-items-center rounded hover:bg-elevated" onClick={() => zoomCentered(1 / 1.25)} aria-label="Zoom out">−</button>
|
||||
<span className="w-10 text-center tabular-nums text-muted">{Math.round(vp.zoom * 100)}%</span>
|
||||
<button className="grid h-6 w-6 place-items-center rounded hover:bg-elevated" onClick={() => zoomCentered(1.25)} aria-label="Zoom in">+</button>
|
||||
<button className="grid h-6 px-1.5 place-items-center rounded hover:bg-elevated" onClick={fit} aria-label="Fit map to view" title="Fit">⊡</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing, scale: number): void {
|
||||
ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width * scale; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||||
const p = d.points.map((pt) => ({ x: pt.x * scale, y: pt.y * scale }));
|
||||
function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing): void {
|
||||
ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||||
const p = d.points;
|
||||
if (d.kind === 'text' && d.text && p[0]) {
|
||||
ctx.font = `${Math.max(12, d.width * 5 * scale)}px sans-serif`;
|
||||
ctx.font = `${Math.max(12, d.width * 5)}px sans-serif`;
|
||||
ctx.fillText(d.text, p[0].x, p[0].y);
|
||||
return;
|
||||
}
|
||||
@@ -187,7 +260,7 @@ function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing, scale: numbe
|
||||
ctx.moveTo(p[0]!.x, p[0]!.y);
|
||||
for (const pt of p.slice(1)) ctx.lineTo(pt.x, pt.y);
|
||||
if (d.kind === 'arrow') {
|
||||
const ang = Math.atan2(b.y - p[p.length - 2]!.y, b.x - p[p.length - 2]!.x), h = 10 + d.width * scale;
|
||||
const ang = Math.atan2(b.y - p[p.length - 2]!.y, b.x - p[p.length - 2]!.x), h = 10 + d.width;
|
||||
ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - h * Math.cos(ang - 0.4), b.y - h * Math.sin(ang - 0.4));
|
||||
ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - h * Math.cos(ang + 0.4), b.y - h * Math.sin(ang + 0.4));
|
||||
}
|
||||
@@ -195,14 +268,14 @@ function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing, scale: numbe
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: {
|
||||
token: CanvasToken; cellPx: number; cols: number; rows: number;
|
||||
function TokenChip({ token, gridSize, zoom, cols, rows, draggable, onMove, onClick }: {
|
||||
token: CanvasToken; gridSize: number; zoom: number; cols: number; rows: number;
|
||||
draggable: boolean; onMove?: ((id: string, col: number, row: number) => void) | undefined; onClick?: ((id: string) => void) | undefined;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ x: number; y: number; col: number; row: number; moved: boolean } | null>(null);
|
||||
const [drift, setDrift] = useState({ dx: 0, dy: 0 });
|
||||
const span = cellPx * token.size;
|
||||
const span = gridSize * token.size;
|
||||
const frac = token.hp && token.hp.max > 0 ? Math.max(0, Math.min(1, token.hp.current / token.hp.max)) : null;
|
||||
|
||||
const down = (e: React.PointerEvent) => {
|
||||
@@ -216,11 +289,13 @@ function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: {
|
||||
};
|
||||
const up = (e: React.PointerEvent) => {
|
||||
if (!drag.current) return;
|
||||
e.stopPropagation();
|
||||
ref.current?.releasePointerCapture(e.pointerId);
|
||||
const moved = drag.current.moved;
|
||||
if (moved && onMove) {
|
||||
const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / cellPx)));
|
||||
const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / cellPx)));
|
||||
// screen-px drift → world-px (÷zoom) → cell delta (÷gridSize)
|
||||
const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / zoom / gridSize)));
|
||||
const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / zoom / gridSize)));
|
||||
onMove(token.id, col, row);
|
||||
} else if (!moved && onClick) onClick(token.id);
|
||||
drag.current = null; setDrift({ dx: 0, dy: 0 });
|
||||
@@ -235,8 +310,8 @@ function TokenChip({ token, cellPx, cols, rows, draggable, onMove, onClick }: {
|
||||
className={cn('absolute grid place-items-center rounded-full border-2 border-black/50 text-[11px] font-bold text-black shadow', frac !== null && frac <= 0 && 'opacity-50')}
|
||||
style={{
|
||||
width: span * 0.92, height: span * 0.92,
|
||||
left: token.col * cellPx + span * 0.04 + drift.dx,
|
||||
top: token.row * cellPx + span * 0.04 + drift.dy,
|
||||
left: token.col * gridSize + span * 0.04 + drift.dx / zoom,
|
||||
top: token.row * gridSize + span * 0.04 + drift.dy / zoom,
|
||||
background: token.color,
|
||||
cursor: draggable ? 'grab' : onClick ? 'pointer' : 'default',
|
||||
touchAction: 'none',
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
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';
|
||||
@@ -25,6 +27,7 @@ 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; });
|
||||
|
||||
@@ -38,6 +41,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
const [gmDraw, setGmDraw] = useState(true);
|
||||
const [dims, setDims] = useState({ cols: 0, rows: 0 });
|
||||
const [editToken, setEditToken] = useState<string | null>(null);
|
||||
const [showPalette, setShowPalette] = useState(true);
|
||||
|
||||
// transient interaction state
|
||||
const [overlay, setOverlay] = useState<Overlay>({});
|
||||
@@ -46,15 +50,20 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
const draftPts = useRef<Point[]>([]);
|
||||
|
||||
const distMode = campaign.system === 'pf2e' ? 'pf2e' : '5e';
|
||||
const activeEncounter = useMemo(() => encounters.find((e) => e.status === 'active') ?? null, [encounters]);
|
||||
|
||||
// Live HP from a linked character drives the ring.
|
||||
// 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 linked = t.characterId ? characters.find((c) => c.id === t.characterId) : undefined;
|
||||
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;
|
||||
return {
|
||||
id: t.id, label: t.label || (linked?.name ?? ''), color: t.color, col: t.col, row: t.row,
|
||||
size: t.size, kind: t.kind, hp: linked?.hp ?? t.hp, conditions: t.conditions,
|
||||
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,
|
||||
hp: linkedCb?.hp ?? linkedChar?.hp ?? t.hp,
|
||||
conditions: linkedCb?.conditions ?? t.conditions,
|
||||
};
|
||||
}), [m.tokens, characters]);
|
||||
}), [m.tokens, characters, activeEncounter]);
|
||||
|
||||
const commitReveal = (cells: string[], reveal: boolean) =>
|
||||
update({ revealed: reveal ? applyReveal(m.revealed, cells) : applyHide(m.revealed, cells) });
|
||||
@@ -130,77 +139,91 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
const finishPoly = () => { if (poly.length >= 3) commitReveal(polygonCells(poly, gridSpec()), tool === 'reveal'); setPoly([]); setOverlay({}); };
|
||||
|
||||
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 removeToken = (id: string) => update({ tokens: m.tokens.filter((t) => t.id !== id) });
|
||||
const token = m.tokens.find((t) => t.id === editToken) ?? null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
|
||||
<Input className="h-8 max-w-36" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
|
||||
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping'] as Tool[]).map((t) => (
|
||||
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t}
|
||||
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">{t}</Button>
|
||||
))}
|
||||
<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-lg border border-line bg-panel p-2 text-sm">
|
||||
{!showPalette && <Button size="sm" variant="ghost" onClick={() => setShowPalette(true)}>Tokens ▸</Button>}
|
||||
<Input className="h-8 max-w-36" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
|
||||
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping'] as Tool[]).map((t) => (
|
||||
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t}
|
||||
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">{t}</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Contextual sub-toolbar */}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel 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">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>
|
||||
</>
|
||||
)}
|
||||
<span className="mx-1 h-4 w-px bg-line" />
|
||||
<Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button>
|
||||
</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 }}
|
||||
overlay={overlay}
|
||||
tokensDraggable={tool === 'move'}
|
||||
{...(tool !== 'move' ? { onPointer } : {})}
|
||||
onTokenMove={(id, col, row) => patchToken(id, { col, row })}
|
||||
onTokenClick={(id) => setEditToken(id)}
|
||||
onReady={setDims}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contextual sub-toolbar */}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel 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">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>
|
||||
</>
|
||||
)}
|
||||
<span className="mx-1 h-4 w-px bg-line" />
|
||||
<Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button>
|
||||
</div>
|
||||
|
||||
<MapCanvas
|
||||
view={{ image: m.image, gridSize: m.gridSize, showGrid: m.showGrid, fogEnabled: m.fogEnabled, revealed: m.revealed, tokens: canvasTokens, drawings: m.drawings }}
|
||||
overlay={overlay}
|
||||
tokensDraggable={tool === 'move'}
|
||||
{...(tool !== 'move' ? { onPointer } : {})}
|
||||
onTokenMove={(id, col, row) => patchToken(id, { col, row })}
|
||||
onTokenClick={(id) => setEditToken(id)}
|
||||
onReady={setDims}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
Click a token to edit it. Use <strong>Reveal/Hide</strong> (brush, rectangle, polygon) for fog,
|
||||
<strong> Measure</strong>/<strong>AoE</strong> for ranges, <strong>Draw</strong> for annotations, then “Open player view” to show players.
|
||||
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 && (
|
||||
@@ -212,6 +235,9 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
<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 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 }} />)}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
import { unplacedPcs } from './tokens';
|
||||
|
||||
const pc = (id: string): Character => ({ id, name: id } as unknown as Character);
|
||||
|
||||
describe('unplacedPcs', () => {
|
||||
it('returns only PCs without a linked token', () => {
|
||||
const pcs = [pc('a'), pc('b'), pc('c')];
|
||||
const tokens = [{ characterId: 'b' }, { characterId: undefined }];
|
||||
expect(unplacedPcs(pcs, tokens).map((c) => c.id)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('returns all when none placed, none when all placed', () => {
|
||||
const pcs = [pc('a'), pc('b')];
|
||||
expect(unplacedPcs(pcs, []).map((c) => c.id)).toEqual(['a', 'b']);
|
||||
expect(unplacedPcs(pcs, [{ characterId: 'a' }, { characterId: 'b' }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Character, Combatant, Encounter, MapToken } from '@/lib/schemas';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { KIND_COLOR, baseSpec, unplacedPcs, type TokenSpec } from './tokens';
|
||||
|
||||
export type { TokenSpec };
|
||||
|
||||
/** Left sidebar for one-click placement of party / encounter / blank tokens. */
|
||||
export function TokenPalette({ characters, encounters, existingTokens, onPlace, onPlaceMany, onClose }: {
|
||||
characters: Character[];
|
||||
encounters: Encounter[];
|
||||
existingTokens: MapToken[];
|
||||
onPlace: (spec: TokenSpec) => void;
|
||||
onPlaceMany: (specs: TokenSpec[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const pcs = characters.filter((c) => c.kind === 'pc');
|
||||
const activeEnc = encounters.find((e) => e.status === 'active') ?? encounters.find((e) => e.combatants.length > 0) ?? null;
|
||||
|
||||
const hasPc = (id: string) => existingTokens.some((t) => t.characterId === id);
|
||||
const hasCombatant = (cb: Combatant) => existingTokens.some((t) => t.combatantId === cb.id || (!t.combatantId && !t.characterId && t.label === cb.name));
|
||||
|
||||
const placePc = (c: Character) => onPlace(baseSpec({ label: c.name, color: KIND_COLOR.pc, kind: 'pc', characterId: c.id, hp: c.hp }));
|
||||
const addAllPcs = () => onPlaceMany(
|
||||
unplacedPcs(pcs, existingTokens).map((c, i) => baseSpec({ label: c.name, color: KIND_COLOR.pc, kind: 'pc', characterId: c.id, hp: c.hp, col: i, row: 0 })),
|
||||
);
|
||||
const placeCombatant = (cb: Combatant) => onPlace(baseSpec({
|
||||
label: cb.name, color: KIND_COLOR[cb.kind], kind: cb.kind, hp: cb.hp, combatantId: cb.id,
|
||||
...(cb.characterId ? { characterId: cb.characterId } : {}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 flex-col gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Tokens</h3>
|
||||
<button onClick={onClose} className="text-xs text-muted hover:text-ink" aria-label="Hide token palette">‹ Hide</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<section>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-ink">Party</span>
|
||||
<Button size="sm" variant="ghost" disabled={pcs.length === 0 || pcs.every((c) => hasPc(c.id))} onClick={addAllPcs}>+ All</Button>
|
||||
</div>
|
||||
{pcs.length === 0 ? (
|
||||
<p className="text-xs text-muted">No player characters in this campaign.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{pcs.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button onClick={() => placePc(c)} className="flex w-full items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left hover:border-accent">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ background: KIND_COLOR.pc }} />
|
||||
<span className="flex-1 truncate text-ink">{c.name}</span>
|
||||
<span className="text-[10px] uppercase text-muted">{hasPc(c.id) ? 'placed' : `L${c.level}`}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{activeEnc && activeEnc.combatants.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-1 truncate text-xs font-medium text-ink" title={activeEnc.name}>{activeEnc.name}</div>
|
||||
<ul className="space-y-1">
|
||||
{activeEnc.combatants.map((cb) => {
|
||||
const placed = hasCombatant(cb);
|
||||
return (
|
||||
<li key={cb.id}>
|
||||
<button onClick={() => placeCombatant(cb)} disabled={placed} className="flex w-full items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left enabled:hover:border-accent disabled:opacity-50">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ background: KIND_COLOR[cb.kind] }} />
|
||||
<span className="flex-1 truncate text-ink">{cb.name}</span>
|
||||
<span className="text-[10px] uppercase text-muted">{placed ? 'placed' : cb.kind}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<div className="mb-1 text-xs font-medium text-ink">Add blank</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button size="sm" variant="secondary" onClick={() => onPlace(baseSpec({ label: 'NPC', color: KIND_COLOR.npc, kind: 'npc' }))}>NPC</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => onPlace(baseSpec({ label: 'Foe', color: KIND_COLOR.monster, kind: 'monster' }))}>Monster</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => onPlace(baseSpec({ label: '', color: KIND_COLOR.object, kind: 'object' }))}>Object</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Character, MapToken } from '@/lib/schemas';
|
||||
|
||||
export type TokenSpec = Omit<MapToken, 'id'>;
|
||||
|
||||
export const KIND_COLOR: Record<MapToken['kind'], string> = {
|
||||
pc: '#64b5f6', npc: '#66bb6a', monster: '#ef5350', object: '#bdbdbd',
|
||||
};
|
||||
|
||||
export function baseSpec(over: Partial<TokenSpec>): TokenSpec {
|
||||
return { label: '', color: '#d4af37', col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false, ...over };
|
||||
}
|
||||
|
||||
/** PCs that don't already have a linked token on the map (for "+ All"). */
|
||||
export function unplacedPcs(pcs: Character[], existingTokens: Pick<MapToken, 'characterId'>[]): Character[] {
|
||||
return pcs.filter((c) => !existingTokens.some((t) => t.characterId === c.id));
|
||||
}
|
||||
Reference in New Issue
Block a user