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:
@@ -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({});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
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 }[];
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user