Phase 17: map & fog VTT overhaul + player-facing projection

- Pure geometry library src/lib/map/* (grid, distance 5e/pf2e/euclid, AoE
  circle/cone/line/square, polygon rasterize + point-in-polygon, brush, fog set
  algebra, player projection) with unit tests.
- Schema: map tokens gain size/kind/characterId/hp/conditions/gmOnly; maps gain
  drawings/gridUnit(feet)/gridType/fogVersion + point/drawing sub-schemas. Dexie v8
  additive migration; mapsRepo.get + transactional mutate.
- Editor split into a shared MapCanvas renderer + MapEditor tool state machine:
  fog via brush/rectangle/polygon (+ size, reveal-all/hide-all), measurement
  (system-aware distance in feet), AoE templates (circle/cone/line/square preview),
  drawing annotations (freehand/line/arrow/rect/circle/text, GM-only), pings, and
  richer tokens (NxN size, character link with live HP ring, condition dots, edit popover).
- Player-facing projection: toPlayerProjection strips GM-only content; PlayerMapView
  renders it read-only with opaque fog; uiStore.activeMapId + 'Show to players'; the
  player view now shows the live battle map. enemyStatus extracted to
  src/lib/combat/playerProjection.ts (reused, and for Phase 18).

9 geometry unit tests; maps e2e covers upload→token→reveal-all→player projection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 10:31:26 +02:00
parent ffe24495b6
commit fb459ad92c
20 changed files with 1047 additions and 223 deletions
+15 -10
View File
@@ -1,31 +1,29 @@
import type { Campaign, Combatant } from '@/lib/schemas';
import type { Campaign } from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
import { localSync } from '@/lib/sync';
import { enemyStatus } from '@/lib/combat/playerProjection';
import { toPlayerProjection } from '@/lib/map';
import { useUiStore } from '@/stores/uiStore';
import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { useCalendar } from '@/features/world/hooks';
import { useCalendar, useMaps } from '@/features/world/hooks';
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
import { PlayerMapView } from './PlayerMapView';
export function PlayerViewPage() {
return <RequireCampaign>{(c) => <PlayerView campaign={c} />}</RequireCampaign>;
}
/** Hide exact enemy HP from players — show a status band instead. */
function enemyStatus(c: Combatant): { label: string; cls: string } {
if (c.hp.current <= 0) return { label: 'Down', cls: 'text-danger' };
const frac = c.hp.max > 0 ? c.hp.current / c.hp.max : 1;
if (frac <= 0.5) return { label: 'Bloodied', cls: 'text-warning' };
return { label: 'Healthy', cls: 'text-success' };
}
function PlayerView({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id);
const encounters = useEncounters(campaign.id);
const calendar = useCalendar(campaign.id);
const maps = useMaps(campaign.id);
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const activeMapId = useUiStore((s) => s.activeMapId);
const activeMap = maps.find((mp) => mp.id === activeMapId) ?? null;
const sys = getSystem(campaign.system);
const pcs = characters.filter((c) => c.kind === 'pc');
@@ -48,6 +46,13 @@ function PlayerView({ campaign }: { campaign: Campaign }) {
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"> Fullscreen</Button>
</div>
{activeMap && (
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2>
<PlayerMapView map={toPlayerProjection(activeMap)} image={activeMap.image} maxWidth={1100} />
</section>
)}
<div className="grid gap-6 lg:grid-cols-2">
{/* Party */}
<section>