Phase 2: character portraits → token icons (local + realtime)

- character.portrait + mapToken.image (data URLs, Dexie v10, additive).
- src/lib/img/resize.ts: center-crop + downscale helper (160px tokens / 256px
  portraits) keeping IndexedDB + wire payloads small.
- Portrait uploader on the character sheet; token icon uploader in the map token
  modal (falls back to the linked character's portrait). Palette PC entries show
  the portrait thumbnail.
- Tokens render the image clipped to the circle (HP ring + condition badge on top).
- Realtime: generalized the image channel to many images (map bg + token/portrait
  icons), deduped by content and resent on reconnect. Player tokens/party carry an
  imageId resolved from the session image cache; party panel shows portraits.
- Also: fix the encounter-picker dropdown text being clipped (drop fixed height).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 14:42:06 +02:00
parent ca3769eb6b
commit 1aff63f29c
18 changed files with 239 additions and 42 deletions
@@ -5,6 +5,7 @@ import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
import { encodeClaim } from '@/lib/sync/playerLink';
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { rollCheck } from '@/lib/useRoll';
@@ -40,6 +41,11 @@ export function CharacterSheet({ character }: { character: Character }) {
const [genScores, setGenScores] = useState(false);
const [shared, setShared] = useState(false);
const onPortrait = async (file: File) => {
const thumb = await squareThumbnail(await fileToDataUrl(file), 256);
update({ portrait: thumb });
};
const shareWithPlayer = async () => {
const campaign = await campaignsRepo.get(c.campaignId);
const link = `${location.origin}/player?c=${encodeClaim({ character: c, campaignName: campaign?.name ?? 'Campaign', campaignSystem: c.system })}`;
@@ -89,6 +95,13 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Header */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<label className="group relative h-14 w-14 shrink-0 cursor-pointer overflow-hidden rounded-full border border-line bg-surface" title="Upload portrait (also used as the map token)">
{c.portrait
? <img src={c.portrait} alt="" className="h-full w-full object-cover" />
: <span className="grid h-full w-full place-items-center text-[10px] text-muted">+ art</span>}
<span className="absolute inset-x-0 bottom-0 bg-black/55 text-center text-[9px] text-white opacity-0 transition group-hover:opacity-100">edit</span>
<input type="file" accept="image/*" className="hidden" aria-label="Portrait" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onPortrait(f); e.target.value = ''; }} />
</label>
<Input
className="max-w-xs font-display text-xl font-bold"
value={c.name}
+4 -4
View File
@@ -4,6 +4,7 @@ import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useSessionStore } from '@/stores/sessionStore';
import { useUiStore } from '@/stores/uiStore';
import { buildSnapshot } from '@/lib/sync/snapshot';
import type { Snapshot } from '@/lib/sync/messages';
import { pushSnapshot, pushImage } from '@/lib/sync/wsSync';
import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
@@ -25,13 +26,12 @@ export function useSessionBroadcaster(campaign: Campaign | null): void {
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const activeMapId = useUiStore((s) => s.activeMapId);
const debouncedPush = useDebouncedCallback((s: ReturnType<typeof buildSnapshot>) => pushSnapshot(s), 250);
const debouncedPush = useDebouncedCallback((s: Snapshot) => pushSnapshot(s), 250);
useEffect(() => {
if (role !== 'gm' || status !== 'connected' || !campaign) return;
const snapshot = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId });
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId });
debouncedPush(snapshot);
const activeMap = maps.find((m) => m.id === activeMapId);
if (activeMap?.image) pushImage(activeMap.id, activeMap.image);
for (const [id, dataUrl] of Object.entries(images)) pushImage(id, dataUrl);
}, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, debouncedPush]);
}
+9 -5
View File
@@ -5,14 +5,14 @@ import { cn } from '@/lib/cn';
import { PlayerMapView } from './PlayerMapView';
/** Presentational player view: battle map + party + initiative, from a snapshot. */
export function PlayerBoards({ snapshot, image }: { snapshot: Snapshot; image?: string }) {
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
const { party, encounter, map } = snapshot;
return (
<>
{map && (
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2>
<PlayerMapView map={map as PlayerMap} image={image} viewportHeight="65vh" />
<PlayerMapView map={map as PlayerMap} image={image} images={images} viewportHeight="65vh" />
</section>
)}
@@ -25,11 +25,15 @@ export function PlayerBoards({ snapshot, image }: { snapshot: Snapshot; image?:
<div className="space-y-2">
{party.map((c) => {
const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0;
const portrait = c.imageId ? images?.[c.imageId] : undefined;
return (
<div key={c.id} className="rounded-lg border border-line bg-panel p-3">
<div className="flex items-baseline justify-between">
<span className="font-display text-lg font-semibold text-ink">{c.name}</span>
<span className="text-sm text-muted">Lv {c.level} · AC {c.ac}</span>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{portrait && <img src={portrait} alt="" className="h-9 w-9 shrink-0 rounded-full object-cover" />}
<span className="truncate font-display text-lg font-semibold text-ink">{c.name}</span>
</div>
<span className="shrink-0 text-sm text-muted">Lv {c.level} · AC {c.ac}</span>
</div>
<div className="mt-2 h-3 overflow-hidden rounded-full bg-surface">
<div className={cn('h-full', pct > 50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} />
+14 -5
View File
@@ -2,11 +2,20 @@ import type { PlayerMap } from '@/lib/map';
import { MapCanvas, type CanvasToken } from '@/features/world/map/MapCanvas';
/** Read-only render of a player-facing map projection + its image. */
export function PlayerMapView({ map, image, viewportHeight }: { map: PlayerMap; image?: string | undefined; viewportHeight?: string | undefined }) {
const tokens: CanvasToken[] = map.tokens.map((t) => ({
id: t.id, label: t.label, color: t.color, col: t.col, row: t.row,
size: t.size, kind: t.kind, hp: t.hp, conditions: t.conditions,
}));
export function PlayerMapView({ map, image, images, viewportHeight }: {
map: PlayerMap;
image?: string | undefined;
images?: Record<string, string> | undefined;
viewportHeight?: string | undefined;
}) {
const tokens: CanvasToken[] = map.tokens.map((t) => {
const icon = t.imageId ? images?.[t.imageId] : undefined;
return {
id: t.id, label: t.label, color: t.color, col: t.col, row: t.row,
size: t.size, kind: t.kind, hp: t.hp, conditions: t.conditions,
...(icon ? { image: icon } : {}),
};
});
return (
<MapCanvas
readOnly
+4 -4
View File
@@ -53,12 +53,12 @@ function LocalPlayerView({ campaign }: { campaign: Campaign }) {
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const activeMapId = useUiStore((s) => s.activeMapId);
const snapshot = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId });
const image = maps.find((m) => m.id === activeMapId)?.image;
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId });
const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined;
return (
<Shell title={campaign.name} subtitle={`Player View${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''} · ${localSync.status.toUpperCase()}`}>
<PlayerBoards snapshot={snapshot} {...(image ? { image } : {})} />
<PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} />
</Shell>
);
}
@@ -114,7 +114,7 @@ function NetworkedPlayerView({ room }: { room: string }) {
) : (
<SeatClaimScreen snapshot={snapshot} localChars={localChars} pending={seatStatus === 'pending'} onClaim={handleClaim} />
)}
<PlayerBoards snapshot={snapshot} {...(image ? { image } : {})} />
<PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} />
<RollFeed />
</Shell>
);
+7 -1
View File
@@ -22,6 +22,8 @@ export interface CanvasToken {
row: number;
size: number;
kind: 'pc' | 'npc' | 'monster' | 'object';
/** resolved icon data URL (token image or linked character portrait) */
image?: string | undefined;
hp?: { current: number; max: number; temp: number } | undefined;
conditions: { name: string; value?: number | undefined }[];
}
@@ -412,6 +414,10 @@ function TokenChip({ token, gridSize, vp, cols, rows, draggable, onMove, onClick
touchAction: 'none',
}}
>
{token.image && (
<img src={token.image} alt="" draggable={false}
className="pointer-events-none absolute inset-0 h-full w-full rounded-full object-cover" />
)}
{frac !== null && (
<svg className="pointer-events-none absolute inset-0" viewBox="0 0 36 36" aria-hidden>
<circle cx="18" cy="18" r="17" fill="none" stroke="rgba(0,0,0,0.35)" strokeWidth="2" />
@@ -419,7 +425,7 @@ function TokenChip({ token, gridSize, vp, cols, rows, draggable, onMove, onClick
strokeWidth="2.5" strokeDasharray={`${frac * 106.8} 106.8`} transform="rotate(-90 18 18)" strokeLinecap="round" />
</svg>
)}
<span className="z-10 truncate px-0.5">{token.label}</span>
{!token.image && <span className="z-10 truncate px-0.5">{token.label}</span>}
{token.conditions.length > 0 && (
<span className="absolute -right-1 -top-1 z-10 grid h-4 w-4 place-items-center rounded-full bg-danger text-[9px] text-white" title={token.conditions.map((c) => c.name).join(', ')}>
{token.conditions.length}
+20
View File
@@ -3,6 +3,7 @@ import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
import { mapsRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
import {
brushCells, rectCells, polygonCells, circleCells, coneCells, lineCells, squareCells,
gridDistance, toFeet, applyReveal, applyHide, revealAll, hideAll, worldToCell,
@@ -57,9 +58,11 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
const canvasTokens: CanvasToken[] = useMemo(() => m.tokens.map((t) => {
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;
const image = t.image ?? linkedChar?.portrait;
return {
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,
...(image ? { image } : {}),
hp: linkedCb?.hp ?? linkedChar?.hp ?? t.hp,
conditions: linkedCb?.conditions ?? t.conditions,
};
@@ -156,7 +159,12 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
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 onTokenIcon = async (id: string, file: File) => {
const thumb = await squareThumbnail(await fileToDataUrl(file), 160);
patchToken(id, { image: thumb });
};
const token = m.tokens.find((t) => t.id === editToken) ?? null;
const tokenLinkedChar = token?.characterId ? characters.find((c) => c.id === token.characterId) : undefined;
return (
<div>
@@ -252,6 +260,18 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
<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 items-center gap-2">
<span className="text-xs text-muted">Icon</span>
{token.image ?? tokenLinkedChar?.portrait
? <img src={token.image ?? tokenLinkedChar?.portrait} alt="" className="h-10 w-10 rounded-full border border-line object-cover" />
: <span className="grid h-10 w-10 place-items-center rounded-full border border-line text-[10px] text-muted">none</span>}
<label className="cursor-pointer rounded-md border border-line px-2 py-1 text-xs text-ink hover:border-accent">
Upload
<input type="file" accept="image/*" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onTokenIcon(token.id, f); e.target.value = ''; }} />
</label>
{token.image && <Button size="sm" variant="ghost" onClick={() => patchToken(token.id, { image: undefined })}>Clear</Button>}
{!token.image && tokenLinkedChar?.portrait && <span className="text-[10px] text-muted">using {tokenLinkedChar.name}'s portrait</span>}
</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 }} />)}
</div>
+4 -2
View File
@@ -55,7 +55,9 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
{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 }} />
{c.portrait
? <img src={c.portrait} alt="" className="h-5 w-5 shrink-0 rounded-full object-cover" />
: <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>
@@ -77,7 +79,7 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
})))}>+ All</Button>
</div>
{withCombatants.length > 1 ? (
<Select className="mb-1 h-7 w-full text-xs" value={selectedEnc.id} onChange={(e) => setEncId(e.target.value)} aria-label="Encounter">
<Select className="mb-1 w-full px-2 py-1 pr-7 text-xs leading-tight" value={selectedEnc.id} onChange={(e) => setEncId(e.target.value)} aria-label="Encounter">
{withCombatants.map((e) => (
<option key={e.id} value={e.id}>{e.status === 'active' ? '● ' : ''}{e.name} ({e.combatants.length})</option>
))}