From f923375ebb6e462bdcc426623e3c54773bad5aa5 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 15:41:06 +0200 Subject: [PATCH] =?UTF-8?q?Phase=206:=20combat=20=E2=86=94=20map=20fusion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Map combat HUD on the editor (Round, current combatant, Prev/Next turn) that drives the active encounter via encountersRepo.mutate(nextTurn/previousTurn). - The current combatant's linked token glows (MapCanvas activeTokenId). - Token edit modal gains a "Combat HP" control (−5/−1/+1/+5) for combatant-linked tokens, writing damage/heal back to the tracker (applyDamage/applyHealing + logEvent + updateCombatant) — HP now syncs both ways between map and combat. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/maps.spec.ts | 32 ++++++++++++++++++++++ src/features/world/map/MapCanvas.tsx | 11 +++++--- src/features/world/map/MapEditor.tsx | 41 +++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/e2e/maps.spec.ts b/e2e/maps.spec.ts index 6725354..6b1e7e7 100644 --- a/e2e/maps.spec.ts +++ b/e2e/maps.spec.ts @@ -53,6 +53,38 @@ test('maps: import a Universal VTT (.dd2vtt) map', async ({ page }) => { await expect(page.getByRole('button', { name: 'Export .uvtt' })).toBeVisible(); }); +test('maps: combat HUD drives turns + token HP syncs to the tracker', async ({ page }) => { + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('Fight'); + await page.getByRole('button', { name: 'Create' }).click(); + + await page.getByRole('link', { name: 'Characters' }).click(); + await createCharacter(page, 'Hero'); + + // Encounter with the hero + a monster, then start combat. + await page.getByRole('link', { name: 'Combat' }).click(); + await page.getByRole('button', { name: '+ New encounter' }).first().click(); + await page.locator('input[data-autofocus]').fill('Brawl'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByLabel('Name').fill('Goblin'); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + await page.locator('select').filter({ has: page.locator('option', { hasText: 'Choose…' }) }).selectOption({ label: 'Hero (Lv 1)' }); + await page.getByRole('button', { name: 'Start combat' }).click(); + + // Map: the combat HUD appears and Next turn works. + await page.getByRole('link', { name: 'Dashboard' }).click(); + await page.getByRole('link', { name: 'Maps' }).click(); + await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png'); + await expect(page.getByText(/Combat · Round 1/)).toBeVisible(); + await page.getByRole('button', { name: /Next turn/ }).click(); + await expect(page.getByText(/Combat · Round/)).toBeVisible(); + + // Place the Goblin combatant; its token edit shows the synced Combat HP control. + await page.getByRole('button', { name: /Goblin/ }).click(); + await page.getByTestId('map-token').first().click(); + await expect(page.getByText(/Combat HP/)).toBeVisible(); +}); + test('maps: walls tool + dynamic vision controls', async ({ page }) => { await page.getByRole('button', { name: '+ New campaign' }).first().click(); await page.locator('input[data-autofocus]').fill('Vision'); diff --git a/src/features/world/map/MapCanvas.tsx b/src/features/world/map/MapCanvas.tsx index 5bf661b..edf1d1f 100644 --- a/src/features/world/map/MapCanvas.tsx +++ b/src/features/world/map/MapCanvas.tsx @@ -68,6 +68,8 @@ interface Props { onTokenMove?: (id: string, col: number, row: number) => void; onTokenClick?: (id: string) => void; tokensDraggable?: boolean; + /** token id of the combatant whose turn it is (glowing highlight) */ + activeTokenId?: string | undefined; onReady?: (info: { cols: number; rows: number }) => void; } @@ -79,7 +81,7 @@ const MAX_DPR = 2.5; * resolution, so everything stays crisp at any zoom (unlike CSS-scaling a * natural-resolution bitmap). Tokens are DOM, in a matching transformed layer. */ -export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, onLeave, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) { +export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, onLeave, onTokenMove, onTokenClick, tokensDraggable, activeTokenId, onReady }: Props) { const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); const [vp, setVp] = useState({ zoom: 1, panX: 0, panY: 0 }); const [size, setSize] = useState({ w: 0, h: 0 }); @@ -211,7 +213,7 @@ export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog,
{view.tokens.map((t) => ( + draggable={!!tokensDraggable} active={t.id === activeTokenId} onMove={onTokenMove} onClick={onTokenClick} /> ))}
)} @@ -396,9 +398,9 @@ function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing): void { ctx.stroke(); } -function TokenChip({ token, gridSize, vp, cols, rows, draggable, onMove, onClick }: { +function TokenChip({ token, gridSize, vp, cols, rows, draggable, active, onMove, onClick }: { token: CanvasToken; gridSize: number; vp: Viewport; cols: number; rows: number; - draggable: boolean; onMove?: ((id: string, col: number, row: number) => void) | undefined; onClick?: ((id: string) => void) | undefined; + draggable: boolean; active?: boolean; onMove?: ((id: string, col: number, row: number) => void) | undefined; onClick?: ((id: string) => void) | undefined; }) { const ref = useRef(null); const drag = useRef<{ x: number; y: number; col: number; row: number; moved: boolean } | null>(null); @@ -445,6 +447,7 @@ function TokenChip({ token, gridSize, vp, cols, rows, draggable, onMove, onClick background: token.color, cursor: draggable ? 'grab' : onClick ? 'pointer' : 'default', touchAction: 'none', + ...(active ? { boxShadow: '0 0 0 3px #ffd54f, 0 0 12px 2px rgba(255,213,79,0.8)', zIndex: 20 } : {}), }} > {token.image && ( diff --git a/src/features/world/map/MapEditor.tsx b/src/features/world/map/MapEditor.tsx index 7c6c4cf..dd16181 100644 --- a/src/features/world/map/MapEditor.tsx +++ b/src/features/world/map/MapEditor.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef, useState } from 'react'; import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas'; -import { mapsRepo } from '@/lib/db/repositories'; +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'; @@ -62,6 +63,18 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig 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. @@ -289,11 +302,22 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig + {activeEncounter && ( +
+ Combat · Round {activeEncounter.round} + ▶ {activeCombatant?.name ?? '—'} + + + {activeTokenId ? — their token glows on the map : — place their token from the palette} +
+ )} + setEditToken(id)} @@ -336,6 +360,21 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig {TOKEN_COLORS.map((c) => + + + + + + ); + })()} )}