diff --git a/e2e/maps.spec.ts b/e2e/maps.spec.ts
index ad8b9d6..2b94e0f 100644
--- a/e2e/maps.spec.ts
+++ b/e2e/maps.spec.ts
@@ -88,6 +88,26 @@ test('maps: place a token from a planned encounter via the palette', async ({ pa
await expect(page.getByTestId('map-token')).toContainText('Bandit');
});
+test('maps: a character portrait becomes the token icon', async ({ page }) => {
+ await page.getByRole('button', { name: '+ New campaign' }).first().click();
+ await page.locator('input[data-autofocus]').fill('Art');
+ await page.getByRole('button', { name: 'Create' }).click();
+
+ await page.getByRole('link', { name: 'Characters' }).click();
+ await createCharacter(page, 'Aria');
+ // Upload a portrait on the sheet; it renders in the header avatar.
+ await page.getByLabel('Portrait').setInputFiles('public/pwa-512x512.png');
+ await expect(page.locator('label img').first()).toBeVisible();
+
+ 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');
+
+ // Placing the PC drops a token that shows the portrait image.
+ await page.getByRole('button', { name: /Aria/ }).click();
+ await expect(page.getByTestId('map-token').first().locator('img')).toBeVisible();
+});
+
test('maps: place a party token from the palette', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Party');
diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx
index 6087343..20c0cc7 100644
--- a/src/features/characters/CharacterSheet.tsx
+++ b/src/features/characters/CharacterSheet.tsx
@@ -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 */}
+
s.activeEncounterId);
const activeMapId = useUiStore((s) => s.activeMapId);
- const debouncedPush = useDebouncedCallback((s: ReturnType
) => 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]);
}
diff --git a/src/features/player/PlayerBoards.tsx b/src/features/player/PlayerBoards.tsx
index 212f36d..49b2318 100644
--- a/src/features/player/PlayerBoards.tsx
+++ b/src/features/player/PlayerBoards.tsx
@@ -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 }) {
const { party, encounter, map } = snapshot;
return (
<>
{map && (
)}
@@ -25,11 +25,15 @@ export function PlayerBoards({ snapshot, image }: { snapshot: Snapshot; image?:
{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 (
-
-
{c.name}
-
Lv {c.level} · AC {c.ac}
+
+
+ {portrait &&

}
+
{c.name}
+
+
Lv {c.level} · AC {c.ac}
50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} />
diff --git a/src/features/player/PlayerMapView.tsx b/src/features/player/PlayerMapView.tsx
index bd9b5b1..82094ed 100644
--- a/src/features/player/PlayerMapView.tsx
+++ b/src/features/player/PlayerMapView.tsx
@@ -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
| 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 (
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 (
-
+
);
}
@@ -114,7 +114,7 @@ function NetworkedPlayerView({ room }: { room: string }) {
) : (
)}
-
+
);
diff --git a/src/features/world/map/MapCanvas.tsx b/src/features/world/map/MapCanvas.tsx
index a73467f..95fcebd 100644
--- a/src/features/world/map/MapCanvas.tsx
+++ b/src/features/world/map/MapCanvas.tsx
@@ -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 && (
+
+ )}
{frac !== null && (
)}
- {token.label}
+ {!token.image && {token.label}}
{token.conditions.length > 0 && (
c.name).join(', ')}>
{token.conditions.length}
diff --git a/src/features/world/map/MapEditor.tsx b/src/features/world/map/MapEditor.tsx
index 60c3168..ae4588e 100644
--- a/src/features/world/map/MapEditor.tsx
+++ b/src/features/world/map/MapEditor.tsx
@@ -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) => 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 (
@@ -252,6 +260,18 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
)}
+
+
Icon
+ {token.image ?? tokenLinkedChar?.portrait
+ ?

+ :
none}
+
+ {token.image &&
}
+ {!token.image && tokenLinkedChar?.portrait &&
using {tokenLinkedChar.name}'s portrait}
+
{TOKEN_COLORS.map((c) =>
diff --git a/src/features/world/map/TokenPalette.tsx b/src/features/world/map/TokenPalette.tsx
index 2093307..fe39083 100644
--- a/src/features/world/map/TokenPalette.tsx
+++ b/src/features/world/map/TokenPalette.tsx
@@ -55,7 +55,9 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
{pcs.map((c) => (
@@ -77,7 +79,7 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
})))}>+ All
{withCombatants.length > 1 ? (
-