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 && (

Battle 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 && ( @@ -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" /> )} - {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 ? ( - setEncId(e.target.value)} aria-label="Encounter"> {withCombatants.map((e) => ( ))} diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts index c30fc5e..0199acf 100644 --- a/src/lib/db/db.ts +++ b/src/lib/db/db.ts @@ -100,6 +100,10 @@ export class TtrpgDatabase extends Dexie { // v9 — Phase 20: tokens gain optional combatantId (live HP from an encounter // combatant). Optional field — no backfill needed; existing rows parse fine. this.version(9).stores({}); + + // v10 — Phase 2: characters gain `portrait`, map tokens gain `image` + // (both optional data URLs). No backfill needed. + this.version(10).stores({}); } } diff --git a/src/lib/img/resize.test.ts b/src/lib/img/resize.test.ts new file mode 100644 index 0000000..f833001 --- /dev/null +++ b/src/lib/img/resize.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { coverCrop } from './resize'; + +describe('coverCrop', () => { + it('crops a landscape image to a centered square', () => { + expect(coverCrop(200, 100)).toEqual({ sx: 50, sy: 0, s: 100 }); + }); + it('crops a portrait image to a centered square', () => { + expect(coverCrop(100, 200)).toEqual({ sx: 0, sy: 50, s: 100 }); + }); + it('leaves a square image unchanged', () => { + expect(coverCrop(120, 120)).toEqual({ sx: 0, sy: 0, s: 120 }); + }); + it('guards degenerate sizes', () => { + expect(coverCrop(0, 0)).toEqual({ sx: 0, sy: 0, s: 0 }); + }); +}); diff --git a/src/lib/img/resize.ts b/src/lib/img/resize.ts new file mode 100644 index 0000000..0eb56bc --- /dev/null +++ b/src/lib/img/resize.ts @@ -0,0 +1,51 @@ +/** Center-crop + downscale helpers for character portraits / token icons. + * Kept tiny so portraits stay cheap in IndexedDB and over the wire. */ + +/** Source rect for a centered square crop of a WxH image. */ +export function coverCrop(w: number, h: number): { sx: number; sy: number; s: number } { + const s = Math.max(0, Math.min(w, h)); + return { sx: (w - s) / 2, sy: (h - s) / 2, s }; +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); +} + +/** Read a File as a data URL. */ +export function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +/** + * Load an image (data URL or object URL), center-crop to a square and downscale + * to `size`px, returning a compact JPEG data URL. Falls back to the source on + * any failure so callers never end up with nothing. + */ +export async function squareThumbnail(src: string, size = 160, quality = 0.82): Promise { + try { + const img = await loadImage(src); + const { sx, sy, s } = coverCrop(img.naturalWidth, img.naturalHeight); + if (s <= 0) return src; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return src; + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(img, sx, sy, s, s, 0, 0, size, size); + return canvas.toDataURL('image/jpeg', quality); + } catch { + return src; + } +} diff --git a/src/lib/map/types.ts b/src/lib/map/types.ts index 9c857d0..b222bb3 100644 --- a/src/lib/map/types.ts +++ b/src/lib/map/types.ts @@ -30,6 +30,8 @@ export interface PlayerToken { row: number; size: number; kind: 'pc' | 'npc' | 'monster' | 'object'; + /** id into the session image cache (token icon), if any */ + imageId?: string; hp?: { current: number; max: number; temp: number }; conditions: { name: string; value?: number }[]; } diff --git a/src/lib/schemas/character.ts b/src/lib/schemas/character.ts index f4ac02d..5d06569 100644 --- a/src/lib/schemas/character.ts +++ b/src/lib/schemas/character.ts @@ -98,6 +98,8 @@ export const characterSchema = z.object({ kind: characterKindSchema.default('pc'), name: z.string().min(1).max(120), + /** square portrait as a data URL (downscaled); doubles as the map-token icon */ + portrait: z.string().optional(), /** ancestry (PF2e) / race (5e), free text for MVP */ ancestry: z.string().max(80).default(''), /** class, free text for MVP */ diff --git a/src/lib/schemas/world.ts b/src/lib/schemas/world.ts index 5ee3a18..911e9c7 100644 --- a/src/lib/schemas/world.ts +++ b/src/lib/schemas/world.ts @@ -96,6 +96,8 @@ export const mapTokenSchema = z.object({ /** NxN footprint (1 = one cell) */ size: int.min(1).max(4).default(1), kind: z.enum(['pc', 'npc', 'monster', 'object']).default('npc'), + /** square icon as a data URL; if absent, a linked character's portrait is used */ + image: z.string().optional(), /** optional link to a Character for a live HP ring / conditions */ characterId: z.string().optional(), /** optional link to an active-encounter Combatant for a live HP ring */ diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts index 69d253f..d7ab808 100644 --- a/src/lib/sync/messages.ts +++ b/src/lib/sync/messages.ts @@ -17,6 +17,7 @@ export const playerCharacterSchema = z.object({ name: z.string(), level: int, ac: int, + imageId: z.string().optional(), hp: hpSchema, conditions: z.array(condSchema).default([]), }); @@ -41,6 +42,7 @@ export const playerEncounterSchema = z.object({ const playerTokenSchema = z.object({ id: z.string(), label: z.string(), color: z.string(), col: int, row: int, size: int, kind: z.enum(['pc', 'npc', 'monster', 'object']), + imageId: z.string().optional(), hp: hpSchema.optional(), conditions: z.array(condSchema).default([]), }); diff --git a/src/lib/sync/snapshot.ts b/src/lib/sync/snapshot.ts index 482011a..6157333 100644 --- a/src/lib/sync/snapshot.ts +++ b/src/lib/sync/snapshot.ts @@ -4,7 +4,18 @@ import { toPlayerEncounter } from '@/lib/combat/playerProjection'; import { toPlayerProjection } from '@/lib/map'; import type { Snapshot } from './messages'; -/** Build the player-safe session snapshot from the GM's local state. */ +/** Stable id for a character's portrait in the session image cache. */ +const charImageId = (id: string) => `char:${id}`; +/** Stable id for a token's own icon image. */ +const tokenImageId = (id: string) => `tok:${id}`; + +export interface SnapshotResult { + snapshot: Snapshot; + /** icon/portrait/map images keyed by id, to stream over the image channel */ + images: Record; +} + +/** Build the player-safe session snapshot + the images it references. */ export function buildSnapshot(input: { campaign: Campaign; characters: Character[]; @@ -13,30 +24,61 @@ export function buildSnapshot(input: { calendar: Calendar | null; activeEncounterId: string | null; activeMapId: string | null; -}): Snapshot { +}): SnapshotResult { const { campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId } = input; const sys = getSystem(campaign.system); + const images: Record = {}; + const byId = new Map(characters.map((c) => [c.id, c])); - const party = characters.filter((c) => c.kind === 'pc').map((c) => ({ - id: c.id, - name: c.name, - level: c.level, - ac: sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus }), - hp: c.hp, - conditions: c.conditions.map((x) => ({ name: x.name, ...(x.value !== undefined ? { value: x.value } : {}) })), - })); + const party = characters.filter((c) => c.kind === 'pc').map((c) => { + if (c.portrait) images[charImageId(c.id)] = c.portrait; + return { + id: c.id, + name: c.name, + level: c.level, + ac: sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus }), + ...(c.portrait ? { imageId: charImageId(c.id) } : {}), + hp: c.hp, + conditions: c.conditions.map((x) => ({ name: x.name, ...(x.value !== undefined ? { value: x.value } : {}) })), + }; + }); const activeEnc = encounters.find((e) => e.id === activeEncounterId && e.status === 'active') ?? encounters.find((e) => e.status === 'active') ?? null; const activeMap = maps.find((m) => m.id === activeMapId) ?? null; const projection = activeMap ? toPlayerProjection(activeMap) : null; + // Resolve each visible token's icon (own image, else linked character portrait). + if (projection && activeMap) { + for (const pt of projection.tokens) { + const orig = activeMap.tokens.find((t) => t.id === pt.id); + if (!orig) continue; + if (orig.image) { + const id = tokenImageId(orig.id); + images[id] = orig.image; + pt.imageId = id; + } else if (orig.characterId) { + const portrait = byId.get(orig.characterId)?.portrait; + if (portrait) { + const id = charImageId(orig.characterId); + images[id] = portrait; + pt.imageId = id; + } + } + } + } + + if (activeMap?.image) images[activeMap.id] = activeMap.image; + return { - campaignName: campaign.name, - calendarDay: calendar ? calendar.currentDay : null, - party, - encounter: activeEnc ? toPlayerEncounter(activeEnc) : null, - map: projection, - mapImageId: activeMap && activeMap.image ? activeMap.id : null, + snapshot: { + campaignName: campaign.name, + calendarDay: calendar ? calendar.currentDay : null, + party, + encounter: activeEnc ? toPlayerEncounter(activeEnc) : null, + map: projection, + mapImageId: activeMap && activeMap.image ? activeMap.id : null, + }, + images, }; } diff --git a/src/lib/sync/wsSync.ts b/src/lib/sync/wsSync.ts index 288ff0e..19fe7b4 100644 --- a/src/lib/sync/wsSync.ts +++ b/src/lib/sync/wsSync.ts @@ -14,7 +14,8 @@ let ws: WebSocket | null = null; let gmSecret = ''; let intent: { kind: 'gm'; campaignId: string; password?: string } | { kind: 'player'; joinCode: string; password?: string } | null = null; let lastSnapshot: Snapshot | null = null; -let lastImage: { id: string; dataUrl: string } | null = null; +/** images sent this session (map background + token icons / portraits), by id */ +const sentImages = new Map(); let reconnectMs = 1000; let reconnectTimer: ReturnType | null = null; let manualStop = false; @@ -58,7 +59,7 @@ function connect(): void { session.set({ role: 'gm', status: 'connected', roomId: msg.roomId, joinCode: msg.joinCode, error: null }); // (re)send current state on (re)connect if (lastSnapshot) send({ t: 'state', gmSecret, snapshot: lastSnapshot }); - if (lastImage) send({ t: 'image', gmSecret, id: lastImage.id, dataUrl: lastImage.dataUrl }); + for (const [id, dataUrl] of sentImages) send({ t: 'image', gmSecret, id, dataUrl }); } else if (msg.t === 'joined') { session.set({ role: 'player', status: 'connected', roomId: msg.roomId, error: null }); } else if (msg.t === 'snapshot') { @@ -120,8 +121,8 @@ export function pushSnapshot(snapshot: Snapshot): void { } export function pushImage(id: string, dataUrl: string): void { - if (lastImage?.id === id) return; - lastImage = { id, dataUrl }; + if (sentImages.get(id) === dataUrl) return; // already sent (dedup by content) + sentImages.set(id, dataUrl); if (useSessionStore.getState().role === 'gm') send({ t: 'image', gmSecret, id, dataUrl }); } @@ -156,7 +157,7 @@ export function stopSession(): void { intent = null; gmSecret = ''; lastSnapshot = null; - lastImage = null; + sentImages.clear(); if (reconnectTimer) clearTimeout(reconnectTimer); try { ws?.close(); } catch { /* ignore */ } ws = null;