Phase 6: combat ↔ map fusion

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 15:41:06 +02:00
parent 06615020f6
commit f923375ebb
3 changed files with 79 additions and 5 deletions
+32
View File
@@ -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');
+7 -4
View File
@@ -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<Viewport>({ 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,
<div className="pointer-events-none absolute inset-0">
{view.tokens.map((t) => (
<TokenChip key={t.id} token={t} gridSize={gridSize} vp={vp} cols={cols} rows={rows}
draggable={!!tokensDraggable} onMove={onTokenMove} onClick={onTokenClick} />
draggable={!!tokensDraggable} active={t.id === activeTokenId} onMove={onTokenMove} onClick={onTokenClick} />
))}
</div>
)}
@@ -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<HTMLDivElement>(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 && (
+40 -1
View File
@@ -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
<Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button>
</div>
{activeEncounter && (
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-accent/40 bg-accent/5 p-2 text-sm">
<span className="text-xs uppercase tracking-wide text-muted">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)}
@@ -336,6 +360,21 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
{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>
)}