Phase 8: player view (local/projector) + sync seam

- Player View (/play): read-only projector screen — party HP bars + conditions,
  active-encounter initiative with current turn; enemy HP hidden behind a
  Healthy/Bloodied/Down status band; Fullscreen button
- SyncAdapter seam (src/lib/sync): local-first default via Dexie liveQuery;
  documents where a future networked (WebSocket/CRDT) backend plugs in
- Dashboard + routes get a Player View entry
- Robustness: encountersRepo.mutate() does transactional read-modify-write so
  rapid combat mutations can't overwrite each other (old C19 race); tracker uses it

New player-view e2e. Networked multiplayer backend intentionally deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 02:07:27 +02:00
parent 1632018ce9
commit 744e91f703
8 changed files with 212 additions and 2 deletions
+124
View File
@@ -0,0 +1,124 @@
import type { Campaign, Combatant } from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
import { localSync } from '@/lib/sync';
import { useUiStore } from '@/stores/uiStore';
import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { useCalendar } from '@/features/world/hooks';
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
export function PlayerViewPage() {
return <RequireCampaign>{(c) => <PlayerView campaign={c} />}</RequireCampaign>;
}
/** Hide exact enemy HP from players — show a status band instead. */
function enemyStatus(c: Combatant): { label: string; cls: string } {
if (c.hp.current <= 0) return { label: 'Down', cls: 'text-danger' };
const frac = c.hp.max > 0 ? c.hp.current / c.hp.max : 1;
if (frac <= 0.5) return { label: 'Bloodied', cls: 'text-warning' };
return { label: 'Healthy', cls: 'text-success' };
}
function PlayerView({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id);
const encounters = useEncounters(campaign.id);
const calendar = useCalendar(campaign.id);
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const sys = getSystem(campaign.system);
const pcs = characters.filter((c) => c.kind === 'pc');
const encounter =
encounters.find((e) => e.id === activeEncounterId && e.status === 'active') ??
encounters.find((e) => e.status === 'active');
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">{campaign.name}</h1>
<p className="text-sm text-muted">
Player View{calendar ? ` · in-world day ${calendar.currentDay}` : ''} ·{' '}
<span className="uppercase">{localSync.status}</span>
</p>
</div>
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"> Fullscreen</Button>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Party */}
<section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Party</h2>
{pcs.length === 0 ? (
<p className="text-sm text-muted">No player characters.</p>
) : (
<div className="space-y-2">
{pcs.map((c) => {
const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0;
return (
<div key={c.id} className="rounded-lg border border-line bg-panel p-3">
<div className="flex items-baseline justify-between">
<span className="font-display text-lg font-semibold text-ink">{c.name}</span>
<span className="text-sm text-muted">
Lv {c.level} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}
</span>
</div>
<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>
<div className="mt-1 text-right text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
{c.conditions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => (
<span key={i} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
))}
</div>
)}
</div>
);
})}
</div>
)}
</section>
{/* Initiative */}
<section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">
Initiative {encounter ? `· Round ${encounter.round}` : ''}
</h2>
{!encounter ? (
<EmptyState title="No active combat" hint="When the GM starts an encounter, the turn order shows here." />
) : (
<ol className="space-y-1">
{encounter.combatants.map((c, idx) => {
const isCurrent = idx === encounter.turnIndex;
const isPC = c.kind === 'pc';
const status = enemyStatus(c);
return (
<li
key={c.id}
className={cn(
'flex items-center gap-3 rounded-md border px-3 py-2',
isCurrent ? 'border-accent bg-elevated ring-1 ring-accent/40' : 'border-line bg-panel',
)}
>
<span className="w-8 text-center font-display text-lg font-semibold text-accent">{c.initiative}</span>
<span className="flex-1 text-ink">{c.name}{isCurrent && <span className="ml-2 text-xs text-accent"> turn</span>}</span>
{isPC ? (
<span className="text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span>
) : (
<span className={cn('text-sm font-medium', status.cls)}>{status.label}</span>
)}
</li>
);
})}
</ol>
)}
</section>
</div>
</Page>
);
}