219 lines
7.5 KiB
TypeScript
219 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { createPortal } from "react-dom";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { Maximize2, Minimize2 } from "lucide-react";
|
|
import type { PokerPhase } from "@/lib/minigames/poker";
|
|
import { describeHandStrength } from "@/lib/minigames/poker/handHint";
|
|
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { PokerCardStyleToggle } from "./PokerCardStyleToggle";
|
|
import { PokerDevPanel } from "./PokerDevPanel";
|
|
import { PokerFeltCanvas, type SeatedSnapshot } from "./PokerFeltCanvas";
|
|
import { PokerSoundToggle } from "./PokerSoundToggle";
|
|
import type { PokerSoundControls } from "./usePokerSounds";
|
|
import type { ActivePokerPopup } from "./usePokerPopups";
|
|
import type { PokerActionLogEntry } from "./pokerPopups";
|
|
import { RenameDialogGuard } from "./RenameDialogGuard";
|
|
|
|
interface PokerGameTableProps {
|
|
readonly snapshot: SeatedSnapshot;
|
|
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
|
readonly onStart: () => Promise<void>;
|
|
readonly onLeave: () => Promise<void>;
|
|
readonly onJoin: () => Promise<void>;
|
|
readonly sound: PokerSoundControls;
|
|
readonly popups: readonly ActivePokerPopup[];
|
|
readonly actionLog: readonly PokerActionLogEntry[];
|
|
readonly isSuperuser: boolean;
|
|
readonly superuserIds: readonly number[];
|
|
}
|
|
|
|
const PHASE_LABELS: Record<PokerPhase, string> = {
|
|
betting: "Betting",
|
|
showdown: "Showdown",
|
|
"hand-complete": "Hand complete",
|
|
"game-complete": "Game complete",
|
|
};
|
|
|
|
export function PokerGameTable({
|
|
snapshot,
|
|
onSnapshot,
|
|
onStart,
|
|
onLeave,
|
|
onJoin,
|
|
sound,
|
|
popups,
|
|
actionLog,
|
|
isSuperuser,
|
|
superuserIds,
|
|
}: PokerGameTableProps) {
|
|
const [starting, setStarting] = useState(false);
|
|
const [leaving, setLeaving] = useState(false);
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
const [mounted, setMounted] = useState(false);
|
|
const view = snapshot.you;
|
|
|
|
const handHint = useMemo(
|
|
() =>
|
|
view.phase === "game-complete"
|
|
? null
|
|
: describeHandStrength(view.yourHoleCards, view.communityCards),
|
|
[view.communityCards, view.phase, view.yourHoleCards],
|
|
);
|
|
const bestCardKeys = useMemo(() => new Set(handHint?.bestCardKeys ?? []), [handHint]);
|
|
const handTier = handHint?.tier ?? null;
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!fullscreen) return;
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") setFullscreen(false);
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [fullscreen]);
|
|
|
|
const start = async () => {
|
|
setStarting(true);
|
|
await onStart();
|
|
setStarting(false);
|
|
};
|
|
|
|
const leave = async () => {
|
|
setLeaving(true);
|
|
await onLeave();
|
|
setLeaving(false);
|
|
};
|
|
|
|
const tableName = snapshot.name ?? `Table ${snapshot.tableId}`;
|
|
const spectator = Boolean(snapshot.spectator);
|
|
const feltCanvas = (
|
|
<PokerFeltCanvas
|
|
snapshot={snapshot}
|
|
onSnapshot={onSnapshot}
|
|
handLabel={handHint?.label ?? null}
|
|
handTier={handTier}
|
|
bestCardKeys={bestCardKeys}
|
|
actionProps={{
|
|
starting,
|
|
leaving,
|
|
onStart: () => void start(),
|
|
onLeave: () => void leave(),
|
|
}}
|
|
onActionFeedback={sound.playUiClick}
|
|
popups={popups}
|
|
actionLog={actionLog}
|
|
spectator={spectator}
|
|
onJoin={onJoin}
|
|
superuserIds={superuserIds}
|
|
fullscreen={fullscreen}
|
|
/>
|
|
);
|
|
|
|
if (fullscreen) {
|
|
if (!mounted) return null;
|
|
|
|
return createPortal(
|
|
<div className="fixed inset-0 z-50 flex flex-col gap-3 overflow-hidden bg-black/92 p-4 backdrop-blur-sm">
|
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border border-white/15 bg-black/60 p-3 backdrop-blur-sm">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate font-semibold text-white">{tableName}</span>
|
|
{spectator && <Badge variant="secondary">Spectating</Badge>}
|
|
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
{isSuperuser && <PokerDevPanel tableId={snapshot.tableId} view={view} />}
|
|
<PokerCardStyleToggle />
|
|
<PokerSoundToggle
|
|
muted={sound.muted}
|
|
volume={sound.volume}
|
|
onToggleMuted={sound.toggleMuted}
|
|
onVolumeChange={sound.setVolume}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setFullscreen(false)}
|
|
aria-label="Exit fullscreen table"
|
|
title="Exit fullscreen table"
|
|
>
|
|
<Minimize2 aria-hidden="true" />
|
|
</Button>
|
|
{view.phase !== "game-complete" && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => void leave()}
|
|
disabled={leaving}
|
|
>
|
|
{leaving ? "Leaving..." : spectator ? "Stop watching" : "Leave table"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="relative min-h-0 flex-1">{feltCanvas}</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
return (
|
|
<UiCard className="rounded-none">
|
|
<CardHeader className="flex-row items-start justify-between gap-4 border-b p-4">
|
|
<div className="flex min-w-0 flex-col gap-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<CardTitle className="text-base">{tableName}</CardTitle>
|
|
<RenameDialogGuard
|
|
canRename={view.viewerSeat === snapshot.hostSeatId}
|
|
tableId={snapshot.tableId}
|
|
currentName={snapshot.name}
|
|
onSnapshot={onSnapshot}
|
|
/>
|
|
{spectator && <Badge variant="secondary">Spectating</Badge>}
|
|
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
|
|
</div>
|
|
<p className="font-mono text-xs tabular-nums text-muted-foreground">
|
|
Hand {view.handNumber} · Blinds {view.config.smallBlind}/{view.config.bigBlind}
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
|
v{snapshot.version}
|
|
</span>
|
|
{isSuperuser && <PokerDevPanel tableId={snapshot.tableId} view={view} />}
|
|
<PokerCardStyleToggle />
|
|
<PokerSoundToggle
|
|
muted={sound.muted}
|
|
volume={sound.volume}
|
|
onToggleMuted={sound.toggleMuted}
|
|
onVolumeChange={sound.setVolume}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setFullscreen(true)}
|
|
aria-label="Enter fullscreen table"
|
|
title="Enter fullscreen table"
|
|
>
|
|
<Maximize2 aria-hidden="true" />
|
|
</Button>
|
|
{view.phase !== "game-complete" && (
|
|
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
|
|
{leaving ? "Leaving..." : spectator ? "Stop watching" : "Leave table"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-4">{feltCanvas}</CardContent>
|
|
</UiCard>
|
|
);
|
|
}
|