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:
@@ -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');
|
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 }) => {
|
test('maps: place a party token from the palette', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
||||||
await page.locator('input[data-autofocus]').fill('Party');
|
await page.locator('input[data-autofocus]').fill('Party');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from
|
|||||||
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||||
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
|
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
|
||||||
import { encodeClaim } from '@/lib/sync/playerLink';
|
import { encodeClaim } from '@/lib/sync/playerLink';
|
||||||
|
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
|
||||||
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
|
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
|
||||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||||
import { rollCheck } from '@/lib/useRoll';
|
import { rollCheck } from '@/lib/useRoll';
|
||||||
@@ -40,6 +41,11 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
const [genScores, setGenScores] = useState(false);
|
const [genScores, setGenScores] = useState(false);
|
||||||
const [shared, setShared] = 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 shareWithPlayer = async () => {
|
||||||
const campaign = await campaignsRepo.get(c.campaignId);
|
const campaign = await campaignsRepo.get(c.campaignId);
|
||||||
const link = `${location.origin}/player?c=${encodeClaim({ character: c, campaignName: campaign?.name ?? 'Campaign', campaignSystem: c.system })}`;
|
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 */}
|
{/* Header */}
|
||||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
<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
|
<Input
|
||||||
className="max-w-xs font-display text-xl font-bold"
|
className="max-w-xs font-display text-xl font-bold"
|
||||||
value={c.name}
|
value={c.name}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
|||||||
import { useSessionStore } from '@/stores/sessionStore';
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
import { buildSnapshot } from '@/lib/sync/snapshot';
|
import { buildSnapshot } from '@/lib/sync/snapshot';
|
||||||
|
import type { Snapshot } from '@/lib/sync/messages';
|
||||||
import { pushSnapshot, pushImage } from '@/lib/sync/wsSync';
|
import { pushSnapshot, pushImage } from '@/lib/sync/wsSync';
|
||||||
import { useCharacters } from '@/features/characters/hooks';
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
import { useEncounters } from '@/features/combat/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 activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
||||||
const activeMapId = useUiStore((s) => s.activeMapId);
|
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(() => {
|
useEffect(() => {
|
||||||
if (role !== 'gm' || status !== 'connected' || !campaign) return;
|
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);
|
debouncedPush(snapshot);
|
||||||
const activeMap = maps.find((m) => m.id === activeMapId);
|
for (const [id, dataUrl] of Object.entries(images)) pushImage(id, dataUrl);
|
||||||
if (activeMap?.image) pushImage(activeMap.id, activeMap.image);
|
|
||||||
}, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, debouncedPush]);
|
}, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, debouncedPush]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { cn } from '@/lib/cn';
|
|||||||
import { PlayerMapView } from './PlayerMapView';
|
import { PlayerMapView } from './PlayerMapView';
|
||||||
|
|
||||||
/** Presentational player view: battle map + party + initiative, from a snapshot. */
|
/** 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;
|
const { party, encounter, map } = snapshot;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{map && (
|
{map && (
|
||||||
<section className="mb-6">
|
<section className="mb-6">
|
||||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2>
|
<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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -25,11 +25,15 @@ export function PlayerBoards({ snapshot, image }: { snapshot: Snapshot; image?:
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{party.map((c) => {
|
{party.map((c) => {
|
||||||
const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0;
|
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 (
|
return (
|
||||||
<div key={c.id} className="rounded-lg border border-line bg-panel p-3">
|
<div key={c.id} className="rounded-lg border border-line bg-panel p-3">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="font-display text-lg font-semibold text-ink">{c.name}</span>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="text-sm text-muted">Lv {c.level} · AC {c.ac}</span>
|
{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>
|
||||||
<div className="mt-2 h-3 overflow-hidden rounded-full bg-surface">
|
<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}%` }} />
|
<div className={cn('h-full', pct > 50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} />
|
||||||
|
|||||||
@@ -2,11 +2,20 @@ import type { PlayerMap } from '@/lib/map';
|
|||||||
import { MapCanvas, type CanvasToken } from '@/features/world/map/MapCanvas';
|
import { MapCanvas, type CanvasToken } from '@/features/world/map/MapCanvas';
|
||||||
|
|
||||||
/** Read-only render of a player-facing map projection + its image. */
|
/** 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 }) {
|
export function PlayerMapView({ map, image, images, viewportHeight }: {
|
||||||
const tokens: CanvasToken[] = map.tokens.map((t) => ({
|
map: PlayerMap;
|
||||||
id: t.id, label: t.label, color: t.color, col: t.col, row: t.row,
|
image?: string | undefined;
|
||||||
size: t.size, kind: t.kind, hp: t.hp, conditions: t.conditions,
|
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 (
|
return (
|
||||||
<MapCanvas
|
<MapCanvas
|
||||||
readOnly
|
readOnly
|
||||||
|
|||||||
@@ -53,12 +53,12 @@ function LocalPlayerView({ campaign }: { campaign: Campaign }) {
|
|||||||
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
||||||
const activeMapId = useUiStore((s) => s.activeMapId);
|
const activeMapId = useUiStore((s) => s.activeMapId);
|
||||||
|
|
||||||
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 });
|
||||||
const image = maps.find((m) => m.id === activeMapId)?.image;
|
const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell title={campaign.name} subtitle={`Player View${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''} · ${localSync.status.toUpperCase()}`}>
|
<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>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ function NetworkedPlayerView({ room }: { room: string }) {
|
|||||||
) : (
|
) : (
|
||||||
<SeatClaimScreen snapshot={snapshot} localChars={localChars} pending={seatStatus === 'pending'} onClaim={handleClaim} />
|
<SeatClaimScreen snapshot={snapshot} localChars={localChars} pending={seatStatus === 'pending'} onClaim={handleClaim} />
|
||||||
)}
|
)}
|
||||||
<PlayerBoards snapshot={snapshot} {...(image ? { image } : {})} />
|
<PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} />
|
||||||
<RollFeed />
|
<RollFeed />
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface CanvasToken {
|
|||||||
row: number;
|
row: number;
|
||||||
size: number;
|
size: number;
|
||||||
kind: 'pc' | 'npc' | 'monster' | 'object';
|
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;
|
hp?: { current: number; max: number; temp: number } | undefined;
|
||||||
conditions: { name: string; value?: number | undefined }[];
|
conditions: { name: string; value?: number | undefined }[];
|
||||||
}
|
}
|
||||||
@@ -412,6 +414,10 @@ function TokenChip({ token, gridSize, vp, cols, rows, draggable, onMove, onClick
|
|||||||
touchAction: 'none',
|
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 && (
|
{frac !== null && (
|
||||||
<svg className="pointer-events-none absolute inset-0" viewBox="0 0 36 36" aria-hidden>
|
<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" />
|
<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" />
|
strokeWidth="2.5" strokeDasharray={`${frac * 106.8} 106.8`} transform="rotate(-90 18 18)" strokeLinecap="round" />
|
||||||
</svg>
|
</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 && (
|
{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(', ')}>
|
<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}
|
{token.conditions.length}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
|
|||||||
import { mapsRepo } from '@/lib/db/repositories';
|
import { mapsRepo } from '@/lib/db/repositories';
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||||
|
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
|
||||||
import {
|
import {
|
||||||
brushCells, rectCells, polygonCells, circleCells, coneCells, lineCells, squareCells,
|
brushCells, rectCells, polygonCells, circleCells, coneCells, lineCells, squareCells,
|
||||||
gridDistance, toFeet, applyReveal, applyHide, revealAll, hideAll, worldToCell,
|
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 canvasTokens: CanvasToken[] = useMemo(() => m.tokens.map((t) => {
|
||||||
const linkedChar = t.characterId ? characters.find((c) => c.id === t.characterId) : undefined;
|
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 linkedCb = t.combatantId && activeEncounter ? activeEncounter.combatants.find((c) => c.id === t.combatantId) : undefined;
|
||||||
|
const image = t.image ?? linkedChar?.portrait;
|
||||||
return {
|
return {
|
||||||
id: t.id, label: t.label || (linkedChar?.name ?? linkedCb?.name ?? ''), color: t.color, col: t.col, row: t.row,
|
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,
|
size: t.size, kind: t.kind,
|
||||||
|
...(image ? { image } : {}),
|
||||||
hp: linkedCb?.hp ?? linkedChar?.hp ?? t.hp,
|
hp: linkedCb?.hp ?? linkedChar?.hp ?? t.hp,
|
||||||
conditions: linkedCb?.conditions ?? t.conditions,
|
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 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 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 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 token = m.tokens.find((t) => t.id === editToken) ?? null;
|
||||||
|
const tokenLinkedChar = token?.characterId ? characters.find((c) => c.id === token.characterId) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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>
|
<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>
|
||||||
|
<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">
|
<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 }} />)}
|
{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>
|
</div>
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
|
|||||||
{pcs.map((c) => (
|
{pcs.map((c) => (
|
||||||
<li key={c.id}>
|
<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">
|
<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="flex-1 truncate text-ink">{c.name}</span>
|
||||||
<span className="text-[10px] uppercase text-muted">{hasPc(c.id) ? 'placed' : `L${c.level}`}</span>
|
<span className="text-[10px] uppercase text-muted">{hasPc(c.id) ? 'placed' : `L${c.level}`}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -77,7 +79,7 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
|
|||||||
})))}>+ All</Button>
|
})))}>+ All</Button>
|
||||||
</div>
|
</div>
|
||||||
{withCombatants.length > 1 ? (
|
{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) => (
|
{withCombatants.map((e) => (
|
||||||
<option key={e.id} value={e.id}>{e.status === 'active' ? '● ' : ''}{e.name} ({e.combatants.length})</option>
|
<option key={e.id} value={e.id}>{e.status === 'active' ? '● ' : ''}{e.name} ({e.combatants.length})</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ export class TtrpgDatabase extends Dexie {
|
|||||||
// v9 — Phase 20: tokens gain optional combatantId (live HP from an encounter
|
// v9 — Phase 20: tokens gain optional combatantId (live HP from an encounter
|
||||||
// combatant). Optional field — no backfill needed; existing rows parse fine.
|
// combatant). Optional field — no backfill needed; existing rows parse fine.
|
||||||
this.version(9).stores({});
|
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({});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<HTMLImageElement> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ export interface PlayerToken {
|
|||||||
row: number;
|
row: number;
|
||||||
size: number;
|
size: number;
|
||||||
kind: 'pc' | 'npc' | 'monster' | 'object';
|
kind: 'pc' | 'npc' | 'monster' | 'object';
|
||||||
|
/** id into the session image cache (token icon), if any */
|
||||||
|
imageId?: string;
|
||||||
hp?: { current: number; max: number; temp: number };
|
hp?: { current: number; max: number; temp: number };
|
||||||
conditions: { name: string; value?: number }[];
|
conditions: { name: string; value?: number }[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export const characterSchema = z.object({
|
|||||||
kind: characterKindSchema.default('pc'),
|
kind: characterKindSchema.default('pc'),
|
||||||
|
|
||||||
name: z.string().min(1).max(120),
|
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 (PF2e) / race (5e), free text for MVP */
|
||||||
ancestry: z.string().max(80).default(''),
|
ancestry: z.string().max(80).default(''),
|
||||||
/** class, free text for MVP */
|
/** class, free text for MVP */
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export const mapTokenSchema = z.object({
|
|||||||
/** NxN footprint (1 = one cell) */
|
/** NxN footprint (1 = one cell) */
|
||||||
size: int.min(1).max(4).default(1),
|
size: int.min(1).max(4).default(1),
|
||||||
kind: z.enum(['pc', 'npc', 'monster', 'object']).default('npc'),
|
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 */
|
/** optional link to a Character for a live HP ring / conditions */
|
||||||
characterId: z.string().optional(),
|
characterId: z.string().optional(),
|
||||||
/** optional link to an active-encounter Combatant for a live HP ring */
|
/** optional link to an active-encounter Combatant for a live HP ring */
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const playerCharacterSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
level: int,
|
level: int,
|
||||||
ac: int,
|
ac: int,
|
||||||
|
imageId: z.string().optional(),
|
||||||
hp: hpSchema,
|
hp: hpSchema,
|
||||||
conditions: z.array(condSchema).default([]),
|
conditions: z.array(condSchema).default([]),
|
||||||
});
|
});
|
||||||
@@ -41,6 +42,7 @@ export const playerEncounterSchema = z.object({
|
|||||||
const playerTokenSchema = z.object({
|
const playerTokenSchema = z.object({
|
||||||
id: z.string(), label: z.string(), color: z.string(), col: int, row: int, size: int,
|
id: z.string(), label: z.string(), color: z.string(), col: int, row: int, size: int,
|
||||||
kind: z.enum(['pc', 'npc', 'monster', 'object']),
|
kind: z.enum(['pc', 'npc', 'monster', 'object']),
|
||||||
|
imageId: z.string().optional(),
|
||||||
hp: hpSchema.optional(),
|
hp: hpSchema.optional(),
|
||||||
conditions: z.array(condSchema).default([]),
|
conditions: z.array(condSchema).default([]),
|
||||||
});
|
});
|
||||||
|
|||||||
+58
-16
@@ -4,7 +4,18 @@ import { toPlayerEncounter } from '@/lib/combat/playerProjection';
|
|||||||
import { toPlayerProjection } from '@/lib/map';
|
import { toPlayerProjection } from '@/lib/map';
|
||||||
import type { Snapshot } from './messages';
|
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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the player-safe session snapshot + the images it references. */
|
||||||
export function buildSnapshot(input: {
|
export function buildSnapshot(input: {
|
||||||
campaign: Campaign;
|
campaign: Campaign;
|
||||||
characters: Character[];
|
characters: Character[];
|
||||||
@@ -13,30 +24,61 @@ export function buildSnapshot(input: {
|
|||||||
calendar: Calendar | null;
|
calendar: Calendar | null;
|
||||||
activeEncounterId: string | null;
|
activeEncounterId: string | null;
|
||||||
activeMapId: string | null;
|
activeMapId: string | null;
|
||||||
}): Snapshot {
|
}): SnapshotResult {
|
||||||
const { campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId } = input;
|
const { campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId } = input;
|
||||||
const sys = getSystem(campaign.system);
|
const sys = getSystem(campaign.system);
|
||||||
|
const images: Record<string, string> = {};
|
||||||
|
const byId = new Map(characters.map((c) => [c.id, c]));
|
||||||
|
|
||||||
const party = characters.filter((c) => c.kind === 'pc').map((c) => ({
|
const party = characters.filter((c) => c.kind === 'pc').map((c) => {
|
||||||
id: c.id,
|
if (c.portrait) images[charImageId(c.id)] = c.portrait;
|
||||||
name: c.name,
|
return {
|
||||||
level: c.level,
|
id: c.id,
|
||||||
ac: sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus }),
|
name: c.name,
|
||||||
hp: c.hp,
|
level: c.level,
|
||||||
conditions: c.conditions.map((x) => ({ name: x.name, ...(x.value !== undefined ? { value: x.value } : {}) })),
|
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')
|
const activeEnc = encounters.find((e) => e.id === activeEncounterId && e.status === 'active')
|
||||||
?? encounters.find((e) => e.status === 'active') ?? null;
|
?? encounters.find((e) => e.status === 'active') ?? null;
|
||||||
const activeMap = maps.find((m) => m.id === activeMapId) ?? null;
|
const activeMap = maps.find((m) => m.id === activeMapId) ?? null;
|
||||||
const projection = activeMap ? toPlayerProjection(activeMap) : 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 {
|
return {
|
||||||
campaignName: campaign.name,
|
snapshot: {
|
||||||
calendarDay: calendar ? calendar.currentDay : null,
|
campaignName: campaign.name,
|
||||||
party,
|
calendarDay: calendar ? calendar.currentDay : null,
|
||||||
encounter: activeEnc ? toPlayerEncounter(activeEnc) : null,
|
party,
|
||||||
map: projection,
|
encounter: activeEnc ? toPlayerEncounter(activeEnc) : null,
|
||||||
mapImageId: activeMap && activeMap.image ? activeMap.id : null,
|
map: projection,
|
||||||
|
mapImageId: activeMap && activeMap.image ? activeMap.id : null,
|
||||||
|
},
|
||||||
|
images,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ let ws: WebSocket | null = null;
|
|||||||
let gmSecret = '';
|
let gmSecret = '';
|
||||||
let intent: { kind: 'gm'; campaignId: string; password?: string } | { kind: 'player'; joinCode: string; password?: string } | null = null;
|
let intent: { kind: 'gm'; campaignId: string; password?: string } | { kind: 'player'; joinCode: string; password?: string } | null = null;
|
||||||
let lastSnapshot: Snapshot | 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<string, string>();
|
||||||
let reconnectMs = 1000;
|
let reconnectMs = 1000;
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let manualStop = false;
|
let manualStop = false;
|
||||||
@@ -58,7 +59,7 @@ function connect(): void {
|
|||||||
session.set({ role: 'gm', status: 'connected', roomId: msg.roomId, joinCode: msg.joinCode, error: null });
|
session.set({ role: 'gm', status: 'connected', roomId: msg.roomId, joinCode: msg.joinCode, error: null });
|
||||||
// (re)send current state on (re)connect
|
// (re)send current state on (re)connect
|
||||||
if (lastSnapshot) send({ t: 'state', gmSecret, snapshot: lastSnapshot });
|
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') {
|
} else if (msg.t === 'joined') {
|
||||||
session.set({ role: 'player', status: 'connected', roomId: msg.roomId, error: null });
|
session.set({ role: 'player', status: 'connected', roomId: msg.roomId, error: null });
|
||||||
} else if (msg.t === 'snapshot') {
|
} else if (msg.t === 'snapshot') {
|
||||||
@@ -120,8 +121,8 @@ export function pushSnapshot(snapshot: Snapshot): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function pushImage(id: string, dataUrl: string): void {
|
export function pushImage(id: string, dataUrl: string): void {
|
||||||
if (lastImage?.id === id) return;
|
if (sentImages.get(id) === dataUrl) return; // already sent (dedup by content)
|
||||||
lastImage = { id, dataUrl };
|
sentImages.set(id, dataUrl);
|
||||||
if (useSessionStore.getState().role === 'gm') send({ t: 'image', gmSecret, id, dataUrl });
|
if (useSessionStore.getState().role === 'gm') send({ t: 'image', gmSecret, id, dataUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +157,7 @@ export function stopSession(): void {
|
|||||||
intent = null;
|
intent = null;
|
||||||
gmSecret = '';
|
gmSecret = '';
|
||||||
lastSnapshot = null;
|
lastSnapshot = null;
|
||||||
lastImage = null;
|
sentImages.clear();
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
try { ws?.close(); } catch { /* ignore */ }
|
try { ws?.close(); } catch { /* ignore */ }
|
||||||
ws = null;
|
ws = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user