Every made hand maps to a presentation tier from high card (no styling) through brass, silver, and gold pairs to a sapphire straight flush and a royal flush prismatic treatment. The live hand hint carries the tier, so the you-have placard glows with your hand's rank, and royals now read as royal flush. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import type { Card, PublicPlayerView } from "@/lib/minigames/poker";
|
|
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { CardView } from "./CardView";
|
|
import { PokerActionConsole } from "./PokerActionConsole";
|
|
import { PokerActionToolbar } from "./PokerActionToolbar";
|
|
import { PokerClock } from "./PokerClock";
|
|
import { PokerDeckOrigin } from "./PokerDeckOrigin";
|
|
import { PokerPopupBubble } from "./PokerPopups";
|
|
import { PokerSeat } from "./PokerSeat";
|
|
import type { ActivePokerPopup } from "./usePokerPopups";
|
|
import { isTopSeatPosition, seatPosition } from "./seatPosition";
|
|
|
|
export type SeatedSnapshot = PokerSnapshot & {
|
|
readonly you: NonNullable<PokerSnapshot["you"]>;
|
|
};
|
|
|
|
export interface FeltCanvasActionProps {
|
|
readonly starting: boolean;
|
|
readonly leaving: boolean;
|
|
readonly onStart: () => void;
|
|
readonly onLeave: () => void;
|
|
}
|
|
|
|
interface PokerFeltCanvasProps {
|
|
readonly snapshot: SeatedSnapshot;
|
|
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
|
readonly handLabel: string | null;
|
|
readonly handTier: number | null;
|
|
readonly bestCardKeys: ReadonlySet<string>;
|
|
readonly actionProps: FeltCanvasActionProps;
|
|
readonly onActionFeedback: () => void;
|
|
readonly popups: readonly ActivePokerPopup[];
|
|
readonly fullscreen?: boolean;
|
|
}
|
|
|
|
function cardKey(card: Card): string {
|
|
return `${card.rank}-${card.suit}`;
|
|
}
|
|
|
|
function signedAmount(value: number): string {
|
|
return `${value >= 0 ? "+" : ""}${value.toLocaleString()}`;
|
|
}
|
|
|
|
function PlacementBoard({ view }: { readonly view: SeatedSnapshot["you"] }) {
|
|
const placements = [...(view.finalPlacements ?? [])].sort((a, b) => a.placement - b.placement);
|
|
|
|
return (
|
|
<UiCard className="w-full max-w-sm rounded-none border-white/20 bg-black/55 text-white">
|
|
<CardHeader className="p-4 pb-2">
|
|
<CardTitle className="text-sm uppercase tracking-wider">Final placements</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4 pt-0">
|
|
<ol className="flex flex-col gap-2">
|
|
{placements.map((placement) => {
|
|
const player = view.players.find((item) => item.seatId === placement.seatId);
|
|
return (
|
|
<li
|
|
key={placement.seatId}
|
|
className="flex items-center justify-between gap-3 text-sm"
|
|
>
|
|
<span className="font-mono text-muted-foreground">{placement.placement}.</span>
|
|
<span className="min-w-0 flex-1 truncate font-medium">
|
|
{player?.name ?? `Seat ${placement.seatId + 1}`}
|
|
</span>
|
|
<span className="font-mono tabular-nums text-muted-foreground">
|
|
{player?.stack.toLocaleString() ?? "0"}
|
|
</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
</CardContent>
|
|
</UiCard>
|
|
);
|
|
}
|
|
|
|
export function PokerFeltCanvas({
|
|
snapshot,
|
|
onSnapshot,
|
|
handLabel,
|
|
handTier,
|
|
bestCardKeys,
|
|
actionProps,
|
|
onActionFeedback,
|
|
popups,
|
|
fullscreen = false,
|
|
}: PokerFeltCanvasProps) {
|
|
const view = snapshot.you;
|
|
const winnerSeats = new Set(view.showdown?.pots.flatMap((pot) => pot.winnerSeats) ?? []);
|
|
const result = view.showdown?.results.find((item) => item.seatId === view.viewerSeat);
|
|
const hasHint = handLabel !== null;
|
|
const opponents = view.players.filter((player) => player.seatId !== view.viewerSeat);
|
|
const viewerPlayer = view.players.find((player) => player.seatId === view.viewerSeat);
|
|
const popupsForSeat = (seatId: number): readonly ActivePokerPopup[] =>
|
|
popups.filter((popup) => popup.seatId === seatId && popup.tone !== "leave");
|
|
const viewerPopups = popupsForSeat(view.viewerSeat);
|
|
const tableAnnouncements = popups.filter((popup) => popup.tone === "leave");
|
|
|
|
return (
|
|
<div className="poker-table-stage">
|
|
<div
|
|
className={cn(
|
|
"poker-felt-table relative min-h-[36rem] overflow-hidden p-4 sm:min-h-[42rem]",
|
|
fullscreen && "poker-felt-fullscreen",
|
|
)}
|
|
>
|
|
{opponents.map((player: PublicPlayerView, opponentIndex) => {
|
|
const positionClass = seatPosition(opponentIndex, opponents.length);
|
|
return (
|
|
<div
|
|
key={player.seatId}
|
|
className={cn(
|
|
"poker-seat-slot absolute z-10 w-36 max-w-[34%] sm:w-52",
|
|
positionClass,
|
|
)}
|
|
>
|
|
<PokerSeat
|
|
player={player}
|
|
isCurrentUser={false}
|
|
isActing={view.phase === "betting" && player.seatId === view.toActSeat}
|
|
isWinner={winnerSeats.has(player.seatId)}
|
|
showHoleCards={view.phase !== "hand-complete" && view.phase !== "game-complete"}
|
|
/>
|
|
{popupsForSeat(player.seatId).length > 0 && (
|
|
<div
|
|
className={cn(
|
|
"poker-popup-stack",
|
|
isTopSeatPosition(positionClass) && "poker-popup-stack-below",
|
|
)}
|
|
aria-hidden="true"
|
|
>
|
|
{popupsForSeat(player.seatId).map((popup) => (
|
|
<PokerPopupBubble key={popup.key} popup={popup} />
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="pointer-events-none absolute -top-2.5 left-1/2 z-20 flex -translate-x-1/2 gap-1">
|
|
{player.seatId === view.buttonSeat && (
|
|
<span className="poker-dealer-button flex size-6 items-center justify-center rounded-full border text-xs font-bold">
|
|
D
|
|
</span>
|
|
)}
|
|
{player.seatId === view.smallBlindSeat && <Badge variant="secondary">SB</Badge>}
|
|
{player.seatId === view.bigBlindSeat && <Badge variant="secondary">BB</Badge>}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<div className="poker-table-center absolute left-1/2 top-1/2 z-10 flex w-[min(26rem,72%)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-2 text-white">
|
|
<PokerDeckOrigin />
|
|
<div className="poker-pot-plaque">
|
|
<span className="poker-console-kicker">Pot</span>
|
|
<strong className="font-mono text-lg tabular-nums">
|
|
{view.potTotal.toLocaleString()}
|
|
</strong>
|
|
</div>
|
|
<div
|
|
className="poker-community-cards flex flex-wrap justify-center gap-1"
|
|
aria-label="Community cards"
|
|
>
|
|
{view.communityCards.length > 0 ? (
|
|
view.communityCards.map((card, index) => {
|
|
const isBestCard = bestCardKeys.has(cardKey(card));
|
|
return (
|
|
<CardView
|
|
key={`${card.rank}-${card.suit}`}
|
|
card={card}
|
|
size="md"
|
|
dealIndex={index + 1}
|
|
highlighted={hasHint && isBestCard}
|
|
dimmed={hasHint && !isBestCard}
|
|
className="poker-card-community"
|
|
/>
|
|
);
|
|
})
|
|
) : (
|
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/55">
|
|
Community cards
|
|
</span>
|
|
)}
|
|
</div>
|
|
{view.phase === "betting" && (
|
|
<div className="poker-table-turn-clock">
|
|
<PokerClock deadline={snapshot.turnDeadline} totalMs={view.config.turnTimeoutMs} />
|
|
</div>
|
|
)}
|
|
|
|
{(view.phase === "showdown" || view.phase === "hand-complete") && (
|
|
<div
|
|
className="flex w-full flex-col items-center gap-1 border border-white/15 bg-black/35 px-3 py-2 text-center"
|
|
aria-live="polite"
|
|
>
|
|
<p className="font-semibold">
|
|
{view.phase === "showdown" ? "Showdown" : "Hand complete"}
|
|
</p>
|
|
<p className="text-sm text-white/70">
|
|
{result
|
|
? `Your result: ${signedAmount(result.netChange)} chips`
|
|
: "The hand is settled."}
|
|
</p>
|
|
{snapshot.nextHandAt && (
|
|
<PokerClock deadline={snapshot.nextHandAt} mode="text" label="Next hand in" />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{view.phase !== "game-complete" && view.yourHoleCards.length > 0 && (
|
|
<div className="poker-viewer-cards" aria-label="Your hole cards">
|
|
{view.yourHoleCards.map((card, index) => (
|
|
<CardView
|
|
key={`felt-hole-${index}`}
|
|
card={card}
|
|
size="md"
|
|
dealIndex={index + 1}
|
|
highlighted={hasHint && bestCardKeys.has(cardKey(card))}
|
|
dimmed={hasHint && !bestCardKeys.has(cardKey(card))}
|
|
className={cn(
|
|
index === 0 ? "poker-card-viewer-left" : "poker-card-viewer-right",
|
|
viewerPlayer?.folded && "poker-card-folded",
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<PokerActionToolbar
|
|
snapshot={snapshot}
|
|
onSnapshot={onSnapshot}
|
|
onActionFeedback={onActionFeedback}
|
|
/>
|
|
|
|
{viewerPopups.length > 0 && (
|
|
<div className="poker-popup-stack poker-popup-viewer-spot" aria-hidden="true">
|
|
{viewerPopups.map((popup) => (
|
|
<PokerPopupBubble key={popup.key} popup={popup} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{tableAnnouncements.length > 0 && (
|
|
<div
|
|
className="poker-popup-stack poker-popup-announcements"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
{tableAnnouncements.map((popup) => (
|
|
<PokerPopupBubble key={popup.key} popup={popup} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{view.phase === "game-complete" && (
|
|
<div className="poker-placement-board absolute left-1/2 top-1/2 z-20 flex w-[min(24rem,calc(100%-2rem))] -translate-x-1/2 -translate-y-1/2 justify-center">
|
|
<PlacementBoard view={view} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<PokerActionConsole
|
|
snapshot={snapshot}
|
|
handLabel={handLabel}
|
|
handTier={handTier}
|
|
hostSeatId={snapshot.hostSeatId}
|
|
starting={actionProps.starting}
|
|
leaving={actionProps.leaving}
|
|
onStart={actionProps.onStart}
|
|
onLeave={actionProps.onLeave}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|