Files
ttrpg_manager/src/features/player/PlayerViewPage.tsx
T
NilsBriggen 360d9ff842 P21: player seats + two-way live play + offline handoff
Players claim a seat (GM approves) and drive their own character live —
HP/slots/resources/conditions/dice broadcast to the table — via new seat
protocol messages and per-seat ownership enforced in RoomHub. GM persists
player edits (charactersRepo.update → liveQuery → rebroadcast). Offline dev:
"Share with player" encodes a claim link (src/lib/sync/playerLink.ts) → /player
imports the character locally for full-sheet editing, bundled back as an
offlineSnapshot on seat claim for GM review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:34:00 +02:00

122 lines
5.4 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import type { Campaign, Character } from '@/lib/schemas';
import { localSync } from '@/lib/sync';
import { buildSnapshot } from '@/lib/sync/snapshot';
import { joinSession, stopSession, claimSeat, sendPlayerPatch } from '@/lib/sync/wsSync';
import { charactersRepo } from '@/lib/db/repositories';
import { useUiStore } from '@/stores/uiStore';
import { useSessionStore } from '@/stores/sessionStore';
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { useCalendar, useMaps } from '@/features/world/hooks';
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { PlayerBoards } from './PlayerBoards';
import { SeatClaimScreen } from './SeatClaimScreen';
import { MyCharacterPanel } from './MyCharacterPanel';
import { RollFeed } from './RollFeed';
function roomParam(): string | null {
return new URLSearchParams(window.location.search).get('room');
}
export function PlayerViewPage() {
// A player joining via link (?room=CODE) has no local campaign — networked mode.
if (roomParam()) return <NetworkedPlayerView room={roomParam()!} />;
return <RequireCampaign>{(c) => <LocalPlayerView campaign={c} />}</RequireCampaign>;
}
function Shell({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {});
return (
<Page>
<div className="mb-6 flex items-end justify-between">
<div>
<h1 className="font-display text-3xl font-bold text-accent">{title}</h1>
<p className="text-sm text-muted">{subtitle}</p>
</div>
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"> Fullscreen</Button>
</div>
{children}
</Page>
);
}
/** Local single-device player view (GM's own screen). */
function LocalPlayerView({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id);
const encounters = useEncounters(campaign.id);
const calendar = useCalendar(campaign.id);
const maps = useMaps(campaign.id);
const activeEncounterId = useUiStore((s) => 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;
return (
<Shell title={campaign.name} subtitle={`Player View${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''} · ${localSync.status.toUpperCase()}`}>
<PlayerBoards snapshot={snapshot} {...(image ? { image } : {})} />
</Shell>
);
}
/** Networked player view — joins a GM's session over the network. */
function NetworkedPlayerView({ room }: { room: string }) {
const status = useSessionStore((s) => s.status);
const error = useSessionStore((s) => s.error);
const snapshot = usePlayerSessionStore((s) => s.snapshot);
const images = usePlayerSessionStore((s) => s.images);
const myCharacter = usePlayerSessionStore((s) => s.myCharacter);
const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
const [needPw, setNeedPw] = useState(false);
// Locally-developed copies of party characters (for offline-edit handoff).
const partyIdsKey = snapshot ? snapshot.party.map((p) => p.id).join(',') : '';
const localList = useLiveQuery(() => charactersRepo.getMany(partyIdsKey ? partyIdsKey.split(',') : []), [partyIdsKey], [] as Character[]);
const localChars = new Map((localList ?? []).map((c) => [c.id, c]));
useEffect(() => {
joinSession(room);
return () => stopSession();
}, [room]);
useEffect(() => {
if (error?.toLowerCase().includes('password') && !needPw) {
setNeedPw(true);
const pw = window.prompt('This session needs a password:')?.trim();
if (pw) { joinSession(room, pw); setNeedPw(false); }
}
}, [error, needPw, room]);
const handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId));
const handlePatch = (diff: Partial<Character>) => {
const cur = usePlayerSessionStore.getState().myCharacter;
if (!cur) return;
usePlayerSessionStore.getState().patchMyCharacter(diff);
sendPlayerPatch(cur.id, diff);
};
if (!snapshot) {
return (
<Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}>
{error ? <EmptyState title="Couldn't join" hint={error} /> : <p className="text-sm text-muted">Connecting</p>}
</Shell>
);
}
const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined;
return (
<Shell title={snapshot.campaignName} subtitle={`Player View · live${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''}`}>
{myCharacter && seatStatus === 'granted' ? (
<MyCharacterPanel character={myCharacter} onPatch={handlePatch} />
) : (
<SeatClaimScreen snapshot={snapshot} localChars={localChars} pending={seatStatus === 'pending'} onClaim={handleClaim} />
)}
<PlayerBoards snapshot={snapshot} {...(image ? { image } : {})} />
<RollFeed />
</Shell>
);
}