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:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user