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
+4
View File
@@ -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({});
}
}
+17
View File
@@ -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 });
});
});
+51
View File
@@ -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;
}
}
+2
View File
@@ -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 }[];
}
+2
View File
@@ -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 */
+2
View File
@@ -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 */
+2
View File
@@ -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([]),
});
+58 -16
View File
@@ -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<string, string>;
}
/** 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<string, string> = {};
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,
};
}
+6 -5
View File
@@ -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<string, string>();
let reconnectMs = 1000;
let reconnectTimer: ReturnType<typeof setTimeout> | 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;