feat(poker): redesign felt as diegetic oval with toolbar and viewer cards
The table becomes a full oval racetrack with a center deck and pot plaque, perimeter seats as compact pills with stack-first readouts that flash on change, betting actions mounted on the felt near the viewer, your own cards dealt onto the bottom rail at community-card size, directional popups, and gated start and waiting-room polish. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
e9e89929f9
commit
16a44d3fc5
12 changed files with 2011 additions and 415 deletions
18
DESIGN.md
18
DESIGN.md
|
|
@ -9,7 +9,7 @@ Polaris is a dark operations console: focused, quiet, and information-dense with
|
||||||
### Palette
|
### Palette
|
||||||
|
|
||||||
| Role | Token | Usage |
|
| Role | Token | Usage |
|
||||||
|------|-------|-------|
|
| --------------------- | --------------------------------------- | ------------------------------------ |
|
||||||
| Surface / page | `bg-background` | Frontend page background |
|
| Surface / page | `bg-background` | Frontend page background |
|
||||||
| Surface / card | `bg-card` | Elevated content sections |
|
| Surface / card | `bg-card` | Elevated content sections |
|
||||||
| Text / primary | `text-foreground` | Headings and primary labels |
|
| Text / primary | `text-foreground` | Headings and primary labels |
|
||||||
|
|
@ -30,7 +30,7 @@ Polaris is a dark operations console: focused, quiet, and information-dense with
|
||||||
### Scale
|
### Scale
|
||||||
|
|
||||||
| Level | Token | Usage |
|
| Level | Token | Usage |
|
||||||
|-------|-------|-------|
|
| ---------- | ------------------------------- | ---------------------------- |
|
||||||
| Page title | `text-lg font-semibold` | Route heading |
|
| Page title | `text-lg font-semibold` | Route heading |
|
||||||
| Card title | `font-semibold` | Section heading |
|
| Card title | `font-semibold` | Section heading |
|
||||||
| Body | inherited `text-base` | Primary content |
|
| Body | inherited `text-base` | Primary content |
|
||||||
|
|
@ -67,6 +67,16 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap
|
||||||
- **Motion**: no section-level motion.
|
- **Motion**: no section-level motion.
|
||||||
- **Layout**: vertical stack.
|
- **Layout**: vertical stack.
|
||||||
|
|
||||||
|
### Poker table surface
|
||||||
|
|
||||||
|
- **Purpose**: The poker table is a diegetic game surface. The velvet felt carries the board, the dealer station, opponent arc, and the player's action console without floating control overlays.
|
||||||
|
- **Material**: Preserve the existing velvet felt gradient, dark inset rim, and restrained gold trim. The action console is a darker inset panel with the same trim language, while chips use dark, clay, and gold-trim variants for action hierarchy.
|
||||||
|
- **Primitives**: `poker-felt-table`, `poker-dealer-station`, `poker-seat-arc-*`, `poker-action-console`, `poker-hand-placard`, and `poker-chip`.
|
||||||
|
- **States**: betting and waiting console states, acting-seat highlight, folded and eliminated players, showdown result, game-complete placement, open and occupied waiting seats, and reduced-motion deal presentation.
|
||||||
|
- **Accessibility**: The cosmetic dealer station is labelled as a station and is not included in player or seat data. Action controls remain native buttons and inputs with visible focus states. Card labels continue to describe face-up and face-down cards.
|
||||||
|
- **Motion**: Cards originate visually at the dealer shoe using transform and opacity with a 60ms stagger. Reduced motion removes the deal animation while retaining static card tilts and highlight states.
|
||||||
|
- **Layout**: The felt is a flat-bottom semicircle. Opponents occupy count-aware upper arc slots, the dealer is top-center, community cards sit in the middle, and the user's console is pinned to the flat bottom edge. Fullscreen increases felt depth without moving the console out of the viewport canvas.
|
||||||
|
|
||||||
### Locker inventory surface
|
### Locker inventory surface
|
||||||
|
|
||||||
- **Purpose**: The authenticated locker is a Polaris operations surface: a dense, readable inventory rather than a literal game replica. The grid is the focal region and selection context is kept adjacent to it.
|
- **Purpose**: The authenticated locker is a Polaris operations surface: a dense, readable inventory rather than a literal game replica. The grid is the focal region and selection context is kept adjacent to it.
|
||||||
|
|
@ -158,7 +168,7 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap
|
||||||
### Timing
|
### Timing
|
||||||
|
|
||||||
| Type | Duration | Usage |
|
| Type | Duration | Usage |
|
||||||
|------|----------|-------|
|
| -------- | --------- | ---------------------------------- |
|
||||||
| Micro | 100–150ms | Switch and radio state feedback |
|
| Micro | 100–150ms | Switch and radio state feedback |
|
||||||
| Standard | 200–300ms | Button hover and focus transitions |
|
| Standard | 200–300ms | Button hover and focus transitions |
|
||||||
|
|
||||||
|
|
@ -183,5 +193,5 @@ The frontend uses a mixed strategy: card borders plus the existing shadcn card s
|
||||||
### Accepted Debt
|
### Accepted Debt
|
||||||
|
|
||||||
| Item | Location | Why accepted | Owner / Exit |
|
| Item | Location | Why accepted | Owner / Exit |
|
||||||
|------|----------|--------------|--------------|
|
| ---------------------------------------------- | ------------- | -------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
| Existing success color uses `text-emerald-500` | Account forms | Preserve established feedback styling in this focused redesign | Consolidate account feedback tokens in a future account UI pass |
|
| Existing success color uses `text-emerald-500` | Account forms | Preserve established feedback styling in this focused redesign | Consolidate account feedback tokens in a future account UI pass |
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { PokerActionKind, PokerLegalActions } from "@/lib/minigames/poker";
|
import type { PokerActionKind, PokerLegalActions } from "@/lib/minigames/poker";
|
||||||
import { pokerAction, type PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
import { pokerAction, type PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -14,6 +15,8 @@ interface BettingControlsProps {
|
||||||
readonly potTotal: number;
|
readonly potTotal: number;
|
||||||
readonly yourTurn: boolean;
|
readonly yourTurn: boolean;
|
||||||
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
||||||
|
readonly onActionFeedback?: () => void;
|
||||||
|
readonly compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampAmount(value: number, min: number, max: number): number {
|
function clampAmount(value: number, min: number, max: number): number {
|
||||||
|
|
@ -32,10 +35,13 @@ export function BettingControls({
|
||||||
potTotal,
|
potTotal,
|
||||||
yourTurn,
|
yourTurn,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
|
onActionFeedback,
|
||||||
|
compact = false,
|
||||||
}: BettingControlsProps) {
|
}: BettingControlsProps) {
|
||||||
const [pendingAction, setPendingAction] = useState<PokerActionKind | null>(null);
|
const [pendingAction, setPendingAction] = useState<PokerActionKind | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [amount, setAmount] = useState(legalActions.minBetTo);
|
const [amount, setAmount] = useState(legalActions.minBetTo);
|
||||||
|
const [wagerOpen, setWagerOpen] = useState(false);
|
||||||
|
|
||||||
const wagerKind: PokerActionKind | null = legalActions.canBet
|
const wagerKind: PokerActionKind | null = legalActions.canBet
|
||||||
? "bet"
|
? "bet"
|
||||||
|
|
@ -53,8 +59,13 @@ export function BettingControls({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setWagerOpen(false);
|
||||||
}, [snapshotVersion]);
|
}, [snapshotVersion]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (compact && (!yourTurn || wagerKind === null)) setWagerOpen(false);
|
||||||
|
}, [compact, wagerKind, yourTurn]);
|
||||||
|
|
||||||
const submit = async (kind: PokerActionKind, wager?: number) => {
|
const submit = async (kind: PokerActionKind, wager?: number) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setPendingAction(kind);
|
setPendingAction(kind);
|
||||||
|
|
@ -65,6 +76,7 @@ export function BettingControls({
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (compact) setWagerOpen(false);
|
||||||
if (result.data) onSnapshot(result.data);
|
if (result.data) onSnapshot(result.data);
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(caught instanceof Error ? caught.message : "Could not send the table action.");
|
setError(caught instanceof Error ? caught.message : "Could not send the table action.");
|
||||||
|
|
@ -84,44 +96,97 @@ export function BettingControls({
|
||||||
setAmount(clampAmount(nextAmount, wagerMin, wagerMax));
|
setAmount(clampAmount(nextAmount, wagerMin, wagerMax));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitFromControl = (kind: PokerActionKind, wager?: number) => {
|
||||||
|
onActionFeedback?.();
|
||||||
|
void submit(kind, wager);
|
||||||
|
};
|
||||||
|
|
||||||
|
const compactAction = legalActions.canCall
|
||||||
|
? { kind: "call" as const, label: `Call ${amountLabel(legalActions.callAmount)}` }
|
||||||
|
: legalActions.canCheck
|
||||||
|
? { kind: "check" as const, label: "Check" }
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div
|
||||||
<div className="flex flex-wrap gap-2">
|
className={cn(
|
||||||
|
"poker-betting-controls flex flex-col gap-3",
|
||||||
|
compact && "poker-betting-controls-compact",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn("flex flex-wrap gap-2", compact && "poker-compact-actions")}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
className="poker-chip poker-chip-fold"
|
||||||
disabled={!canAct || !legalActions.canFold}
|
disabled={!canAct || !legalActions.canFold}
|
||||||
onClick={() => void submit("fold")}
|
onClick={() => submitFromControl("fold")}
|
||||||
>
|
>
|
||||||
Fold
|
Fold
|
||||||
</Button>
|
</Button>
|
||||||
|
{compact ? (
|
||||||
|
compactAction && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={compactAction.kind === "call" ? "secondary" : "outline"}
|
||||||
|
className="poker-chip poker-chip-call"
|
||||||
|
disabled={!canAct}
|
||||||
|
onClick={() => submitFromControl(compactAction.kind)}
|
||||||
|
>
|
||||||
|
{compactAction.label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="poker-chip poker-chip-call"
|
||||||
disabled={!canAct || !legalActions.canCheck}
|
disabled={!canAct || !legalActions.canCheck}
|
||||||
onClick={() => void submit("check")}
|
onClick={() => submitFromControl("check")}
|
||||||
>
|
>
|
||||||
Check
|
Check
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="poker-chip poker-chip-call"
|
||||||
disabled={!canAct || !legalActions.canCall}
|
disabled={!canAct || !legalActions.canCall}
|
||||||
onClick={() => void submit("call")}
|
onClick={() => submitFromControl("call")}
|
||||||
>
|
>
|
||||||
Call {amountLabel(legalActions.callAmount)}
|
Call {amountLabel(legalActions.callAmount)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{compact && wagerKind && wagerMax >= wagerMin && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="poker-chip poker-chip-wager"
|
||||||
|
disabled={!canAct}
|
||||||
|
aria-expanded={wagerOpen}
|
||||||
|
onClick={() => {
|
||||||
|
onActionFeedback?.();
|
||||||
|
setWagerOpen((open) => !open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wagerKind === "bet" ? "Bet" : "Raise"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{wagerKind && wagerMax >= wagerMin && (
|
{wagerKind && wagerMax >= wagerMin && (!compact || wagerOpen) && (
|
||||||
<Card className="rounded-none">
|
<Card
|
||||||
<CardHeader className="p-4 pb-2">
|
className={cn("poker-wager-panel rounded-none", compact && "poker-wager-panel-compact")}
|
||||||
|
>
|
||||||
|
<CardHeader className="p-3 pb-2">
|
||||||
<CardTitle className="text-sm">
|
<CardTitle className="text-sm">
|
||||||
{wagerKind === "bet" ? "Bet size" : "Raise to"}
|
{wagerKind === "bet" ? "Bet size" : "Raise to"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3 p-4 pt-0">
|
<CardContent
|
||||||
<div className="flex items-center gap-3">
|
className={cn("flex flex-col gap-3 p-3 pt-0", compact && "poker-compact-wager-content")}
|
||||||
|
>
|
||||||
|
<div className={cn("flex items-center gap-3", compact && "poker-compact-wager-line")}>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={wagerMin}
|
min={wagerMin}
|
||||||
|
|
@ -132,7 +197,7 @@ export function BettingControls({
|
||||||
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
|
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
|
||||||
}
|
}
|
||||||
disabled={!canAct}
|
disabled={!canAct}
|
||||||
className="h-2 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-not-allowed"
|
className="poker-wager-range h-2 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-not-allowed"
|
||||||
aria-label={`${wagerKind === "bet" ? "Bet" : "Raise"} amount`}
|
aria-label={`${wagerKind === "bet" ? "Bet" : "Raise"} amount`}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -145,19 +210,23 @@ export function BettingControls({
|
||||||
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
|
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
|
||||||
}
|
}
|
||||||
disabled={!canAct}
|
disabled={!canAct}
|
||||||
className="w-24 font-mono tabular-nums"
|
className="poker-wager-input w-24 font-mono tabular-nums"
|
||||||
aria-label="Wager amount"
|
aria-label="Wager amount"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className={cn("flex flex-wrap gap-2", compact && "poker-compact-quick-row")}>
|
||||||
{(["min", "half", "pot", "all"] as const).map((choice) => (
|
{(["min", "half", "pot", "all"] as const).map((choice) => (
|
||||||
<Button
|
<Button
|
||||||
key={choice}
|
key={choice}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="poker-quick-chip"
|
||||||
disabled={!canAct}
|
disabled={!canAct}
|
||||||
onClick={() => chooseQuickAmount(choice)}
|
onClick={() => {
|
||||||
|
onActionFeedback?.();
|
||||||
|
chooseQuickAmount(choice);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{choice === "min"
|
{choice === "min"
|
||||||
? "Min"
|
? "Min"
|
||||||
|
|
@ -170,9 +239,9 @@ export function BettingControls({
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="ml-auto"
|
className="poker-chip poker-chip-wager ml-auto"
|
||||||
disabled={!canAct}
|
disabled={!canAct}
|
||||||
onClick={() => void submit(wagerKind, amount)}
|
onClick={() => submitFromControl(wagerKind, amount)}
|
||||||
>
|
>
|
||||||
{wagerKind === "bet" ? "Bet" : "Raise"} {amountLabel(amount)}
|
{wagerKind === "bet" ? "Bet" : "Raise"} {amountLabel(amount)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -181,11 +250,12 @@ export function BettingControls({
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex min-h-5 items-center justify-between gap-3 text-xs" aria-live="polite">
|
<div
|
||||||
{!yourTurn && <span className="text-muted-foreground">Waiting for the table to act.</span>}
|
className="poker-betting-message flex min-h-5 items-center justify-between gap-3 text-xs"
|
||||||
{pendingAction && (
|
aria-live="polite"
|
||||||
<span className="text-muted-foreground">Waiting for the table update...</span>
|
>
|
||||||
)}
|
{!compact && !yourTurn && <span>Waiting for the table to act.</span>}
|
||||||
|
{pendingAction && <span>Waiting for the table update...</span>}
|
||||||
{error && <span className="text-destructive">{error}</span>}
|
{error && <span className="text-destructive">{error}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
167
src/components/frontend/minigames/poker/PokerActionConsole.tsx
Normal file
167
src/components/frontend/minigames/poker/PokerActionConsole.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { PublicPlayerView } from "@/lib/minigames/poker";
|
||||||
|
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PokerClock } from "./PokerClock";
|
||||||
|
|
||||||
|
type SeatedSnapshot = PokerSnapshot & {
|
||||||
|
readonly you: NonNullable<PokerSnapshot["you"]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PokerActionConsoleProps {
|
||||||
|
readonly snapshot: SeatedSnapshot;
|
||||||
|
readonly handLabel: string | null;
|
||||||
|
readonly hostSeatId: number | null;
|
||||||
|
readonly starting: boolean;
|
||||||
|
readonly leaving: boolean;
|
||||||
|
readonly onStart: () => void;
|
||||||
|
readonly onLeave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableActionButtons({
|
||||||
|
view,
|
||||||
|
hostSeatId,
|
||||||
|
starting,
|
||||||
|
leaving,
|
||||||
|
onStart,
|
||||||
|
onLeave,
|
||||||
|
}: {
|
||||||
|
readonly view: SeatedSnapshot["you"];
|
||||||
|
readonly hostSeatId: number | null;
|
||||||
|
readonly starting: boolean;
|
||||||
|
readonly leaving: boolean;
|
||||||
|
readonly onStart: () => void;
|
||||||
|
readonly onLeave: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="poker-complete-actions">
|
||||||
|
<div className="poker-complete-copy">
|
||||||
|
<span className="poker-console-kicker">Table complete</span>
|
||||||
|
<span className="text-sm text-white/75">The final placements are in.</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
{view.viewerSeat === hostSeatId && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="poker-chip poker-chip-wager"
|
||||||
|
onClick={onStart}
|
||||||
|
disabled={starting}
|
||||||
|
>
|
||||||
|
{starting ? "Starting..." : "Start new game"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="poker-chip poker-chip-call"
|
||||||
|
onClick={onLeave}
|
||||||
|
disabled={leaving}
|
||||||
|
>
|
||||||
|
{leaving ? "Leaving..." : "Leave table"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewerRoleBadges({ view }: { readonly view: SeatedSnapshot["you"] }) {
|
||||||
|
return (
|
||||||
|
<div className="poker-console-role-row" aria-label="Your table roles">
|
||||||
|
{view.buttonSeat === view.viewerSeat && <Badge variant="secondary">Dealer button</Badge>}
|
||||||
|
{view.smallBlindSeat === view.viewerSeat && <Badge variant="secondary">Small blind</Badge>}
|
||||||
|
{view.bigBlindSeat === view.viewerSeat && <Badge variant="secondary">Big blind</Badge>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PokerActionConsole({
|
||||||
|
snapshot,
|
||||||
|
handLabel,
|
||||||
|
hostSeatId,
|
||||||
|
starting,
|
||||||
|
leaving,
|
||||||
|
onStart,
|
||||||
|
onLeave,
|
||||||
|
}: PokerActionConsoleProps) {
|
||||||
|
const view = snapshot.you;
|
||||||
|
const viewerPlayer: PublicPlayerView | undefined = view.players.find(
|
||||||
|
(player) => player.seatId === view.viewerSeat,
|
||||||
|
);
|
||||||
|
const chipsInPlay = viewerPlayer?.committed ?? 0;
|
||||||
|
const viewerInitial = (viewerPlayer?.name ?? "You").trim().charAt(0).toUpperCase() || "?";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"poker-action-console",
|
||||||
|
view.phase === "game-complete" && "poker-action-console-complete",
|
||||||
|
view.phase === "betting" && !view.yourTurn && "poker-action-console-waiting",
|
||||||
|
)}
|
||||||
|
aria-label="Your poker action console"
|
||||||
|
>
|
||||||
|
<div className="poker-console-player">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"poker-seat-avatar poker-console-avatar",
|
||||||
|
`poker-seat-avatar-tone-${view.viewerSeat % 4}`,
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{viewerInitial}
|
||||||
|
</span>
|
||||||
|
<div className="poker-console-identity">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold">
|
||||||
|
{viewerPlayer?.name ?? "You"}
|
||||||
|
<span className="ml-1 text-xs text-white/55">(you)</span>
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-xs tabular-nums text-white/65">
|
||||||
|
Stack {viewerPlayer?.stack.toLocaleString() ?? "0"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ViewerRoleBadges view={view} />
|
||||||
|
</div>
|
||||||
|
{handLabel && <span className="poker-hand-placard">You have: {handLabel}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="poker-console-economy">
|
||||||
|
<div className="poker-chip-readout">
|
||||||
|
<span className="poker-chip-stack" aria-hidden="true" />
|
||||||
|
<span className="poker-console-readout-copy">
|
||||||
|
<span className="poker-console-kicker">Chips in play</span>
|
||||||
|
<strong className="font-mono text-sm tabular-nums">
|
||||||
|
{chipsInPlay.toLocaleString()}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{view.phase === "betting" && (
|
||||||
|
<div className="poker-console-timer">
|
||||||
|
<PokerClock
|
||||||
|
deadline={snapshot.turnDeadline}
|
||||||
|
totalMs={view.config.turnTimeoutMs}
|
||||||
|
label="Turn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view.phase === "game-complete" && (
|
||||||
|
<div className="poker-console-actions">
|
||||||
|
<TableActionButtons
|
||||||
|
view={view}
|
||||||
|
hostSeatId={hostSeatId}
|
||||||
|
starting={starting}
|
||||||
|
leaving={leaving}
|
||||||
|
onStart={onStart}
|
||||||
|
onLeave={onLeave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
||||||
|
import { BettingControls } from "./BettingControls";
|
||||||
|
|
||||||
|
type SeatedSnapshot = PokerSnapshot & {
|
||||||
|
readonly you: NonNullable<PokerSnapshot["you"]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PokerActionToolbarProps {
|
||||||
|
readonly snapshot: SeatedSnapshot;
|
||||||
|
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
||||||
|
readonly onActionFeedback: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PokerActionToolbar({
|
||||||
|
snapshot,
|
||||||
|
onSnapshot,
|
||||||
|
onActionFeedback,
|
||||||
|
}: PokerActionToolbarProps) {
|
||||||
|
const view = snapshot.you;
|
||||||
|
|
||||||
|
if (view.phase !== "betting") return null;
|
||||||
|
|
||||||
|
if (!view.yourTurn) {
|
||||||
|
const actingPlayer = view.players.find((player) => player.seatId === view.toActSeat);
|
||||||
|
return (
|
||||||
|
<div className="poker-action-toolbar poker-action-toolbar-waiting" role="status">
|
||||||
|
{actingPlayer ? `Waiting for ${actingPlayer.name}` : "Waiting for the table"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="poker-action-toolbar" aria-label="Poker actions">
|
||||||
|
<BettingControls
|
||||||
|
tableId={snapshot.tableId}
|
||||||
|
snapshotVersion={snapshot.version}
|
||||||
|
legalActions={view.legalActions}
|
||||||
|
potTotal={view.potTotal}
|
||||||
|
yourTurn={view.yourTurn}
|
||||||
|
onSnapshot={onSnapshot}
|
||||||
|
onActionFeedback={onActionFeedback}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/components/frontend/minigames/poker/PokerDeckOrigin.tsx
Normal file
11
src/components/frontend/minigames/poker/PokerDeckOrigin.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export function PokerDeckOrigin() {
|
||||||
|
return (
|
||||||
|
<div className="poker-deck-origin" aria-hidden="true">
|
||||||
|
<span className="poker-deck-card poker-deck-card-back" />
|
||||||
|
<span className="poker-deck-card poker-deck-card-middle" />
|
||||||
|
<span className="poker-deck-card poker-deck-card-top" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
src/components/frontend/minigames/poker/PokerFeltCanvas.tsx
Normal file
275
src/components/frontend/minigames/poker/PokerFeltCanvas.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
"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 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,
|
||||||
|
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}
|
||||||
|
hostSeatId={snapshot.hostSeatId}
|
||||||
|
starting={actionProps.starting}
|
||||||
|
leaving={actionProps.leaving}
|
||||||
|
onStart={actionProps.onStart}
|
||||||
|
onLeave={actionProps.onLeave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,52 +3,26 @@
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Maximize2, Minimize2 } from "lucide-react";
|
import { Maximize2, Minimize2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import type { PokerPhase } from "@/lib/minigames/poker";
|
||||||
import type { Card, PokerPhase, PublicPlayerView } from "@/lib/minigames/poker";
|
|
||||||
import { describeHandStrength } from "@/lib/minigames/poker/handHint";
|
import { describeHandStrength } from "@/lib/minigames/poker/handHint";
|
||||||
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
Card as UiCard,
|
import { PokerCardStyleToggle } from "./PokerCardStyleToggle";
|
||||||
CardContent,
|
import { PokerFeltCanvas, type SeatedSnapshot } from "./PokerFeltCanvas";
|
||||||
CardFooter,
|
import { PokerSoundToggle } from "./PokerSoundToggle";
|
||||||
CardHeader,
|
import type { PokerSoundControls } from "./usePokerSounds";
|
||||||
CardTitle,
|
import type { ActivePokerPopup } from "./usePokerPopups";
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { BettingControls } from "./BettingControls";
|
|
||||||
import { CardView } from "./CardView";
|
|
||||||
import { PokerClock } from "./PokerClock";
|
|
||||||
import { PokerSeat } from "./PokerSeat";
|
|
||||||
import { RenameDialogGuard } from "./RenameDialogGuard";
|
import { RenameDialogGuard } from "./RenameDialogGuard";
|
||||||
import { seatPosition } from "./seatPosition";
|
|
||||||
|
|
||||||
type SeatedSnapshot = PokerSnapshot & {
|
|
||||||
readonly you: NonNullable<PokerSnapshot["you"]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PokerGameTableProps {
|
interface PokerGameTableProps {
|
||||||
readonly snapshot: SeatedSnapshot;
|
readonly snapshot: SeatedSnapshot;
|
||||||
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
||||||
readonly onStart: () => Promise<void>;
|
readonly onStart: () => Promise<void>;
|
||||||
readonly onLeave: () => Promise<void>;
|
readonly onLeave: () => Promise<void>;
|
||||||
}
|
readonly sound: PokerSoundControls;
|
||||||
|
readonly popups: readonly ActivePokerPopup[];
|
||||||
interface FeltCanvasProps {
|
|
||||||
readonly snapshot: SeatedSnapshot;
|
|
||||||
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
|
||||||
readonly handLabel: string | null;
|
|
||||||
readonly bestCardKeys: ReadonlySet<string>;
|
|
||||||
readonly fullscreen?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TableActionButtonsProps {
|
|
||||||
readonly view: SeatedSnapshot["you"];
|
|
||||||
readonly hostSeatId: number | null;
|
|
||||||
readonly starting: boolean;
|
|
||||||
readonly leaving: boolean;
|
|
||||||
readonly onStart: () => void;
|
|
||||||
readonly onLeave: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PHASE_LABELS: Record<PokerPhase, string> = {
|
const PHASE_LABELS: Record<PokerPhase, string> = {
|
||||||
|
|
@ -58,197 +32,14 @@ const PHASE_LABELS: Record<PokerPhase, string> = {
|
||||||
"game-complete": "Game complete",
|
"game-complete": "Game complete",
|
||||||
};
|
};
|
||||||
|
|
||||||
function cardKey(card: Card): string {
|
export function PokerGameTable({
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableActionButtons({
|
|
||||||
view,
|
|
||||||
hostSeatId,
|
|
||||||
starting,
|
|
||||||
leaving,
|
|
||||||
onStart,
|
|
||||||
onLeave,
|
|
||||||
}: TableActionButtonsProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{view.phase === "game-complete" && view.viewerSeat === hostSeatId && (
|
|
||||||
<Button type="button" onClick={onStart} disabled={starting}>
|
|
||||||
{starting ? "Starting..." : "Start new game"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" onClick={onLeave} disabled={leaving}>
|
|
||||||
{leaving ? "Leaving..." : "Leave table"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeltCanvas({
|
|
||||||
snapshot,
|
snapshot,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
handLabel,
|
onStart,
|
||||||
bestCardKeys,
|
onLeave,
|
||||||
fullscreen = false,
|
sound,
|
||||||
}: FeltCanvasProps) {
|
popups,
|
||||||
const view = snapshot.you;
|
}: PokerGameTableProps) {
|
||||||
const winnerSeats = new Set(view.showdown?.pots.flatMap((pot) => pot.winnerSeats) ?? []);
|
|
||||||
const result = view.showdown?.results.find((item) => item.seatId === view.viewerSeat);
|
|
||||||
const showHoleCards = view.phase !== "hand-complete" && view.phase !== "game-complete";
|
|
||||||
const hasHint = handLabel !== null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"poker-felt-table relative min-h-[36rem] overflow-hidden p-4 sm:min-h-[42rem]",
|
|
||||||
fullscreen && "poker-felt-fullscreen",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{view.players.map((player: PublicPlayerView) => {
|
|
||||||
const isViewer = player.seatId === view.viewerSeat;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={player.seatId}
|
|
||||||
className={cn(
|
|
||||||
"absolute z-10",
|
|
||||||
isViewer ? "poker-seat-south-strip" : "w-36 max-w-[44%] sm:w-52",
|
|
||||||
!isViewer && seatPosition(player.seatId),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<PokerSeat
|
|
||||||
player={player}
|
|
||||||
isCurrentUser={isViewer}
|
|
||||||
isActing={view.phase === "betting" && player.seatId === view.toActSeat}
|
|
||||||
isWinner={winnerSeats.has(player.seatId)}
|
|
||||||
showHoleCards={showHoleCards}
|
|
||||||
handLabel={isViewer ? handLabel : null}
|
|
||||||
cardKeySet={isViewer ? bestCardKeys : undefined}
|
|
||||||
compact={isViewer}
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute -top-2 right-2 z-20 flex gap-1">
|
|
||||||
{player.seatId === view.buttonSeat && (
|
|
||||||
<span className="flex size-6 items-center justify-center rounded-full border border-amber-200 bg-amber-300 text-xs font-bold text-black">
|
|
||||||
D
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{player.seatId === view.smallBlindSeat && <Badge variant="secondary">SB</Badge>}
|
|
||||||
{player.seatId === view.bigBlindSeat && <Badge variant="secondary">BB</Badge>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="absolute left-1/2 top-1/2 z-10 flex w-[min(24rem,80%)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-3 text-white">
|
|
||||||
{view.phase === "betting" && (
|
|
||||||
<PokerClock deadline={snapshot.turnDeadline} totalMs={view.config.turnTimeoutMs} />
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap justify-center gap-1.5" 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}
|
|
||||||
highlighted={hasHint && isBestCard}
|
|
||||||
dimmed={hasHint && !isBestCard}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/55">
|
|
||||||
Community cards
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/60">Pot</span>
|
|
||||||
<span className="font-mono text-2xl font-semibold tabular-nums">
|
|
||||||
{view.potTotal.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</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" && (
|
|
||||||
<div className="absolute bottom-[13%] left-1/2 z-30 max-h-[45%] w-[min(44rem,calc(100%-2rem))] -translate-x-1/2 overflow-y-auto rounded-none border border-white/15 bg-black/60 p-3 backdrop-blur-sm">
|
|
||||||
<BettingControls
|
|
||||||
tableId={snapshot.tableId}
|
|
||||||
snapshotVersion={snapshot.version}
|
|
||||||
legalActions={view.legalActions}
|
|
||||||
potTotal={view.potTotal}
|
|
||||||
yourTurn={view.yourTurn}
|
|
||||||
onSnapshot={onSnapshot}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view.phase === "game-complete" && (
|
|
||||||
<div className="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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: PokerGameTableProps) {
|
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
const [leaving, setLeaving] = useState(false);
|
const [leaving, setLeaving] = useState(false);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
@ -292,11 +83,19 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
|
||||||
|
|
||||||
const tableName = snapshot.name ?? `Table ${snapshot.tableId}`;
|
const tableName = snapshot.name ?? `Table ${snapshot.tableId}`;
|
||||||
const feltCanvas = (
|
const feltCanvas = (
|
||||||
<FeltCanvas
|
<PokerFeltCanvas
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
onSnapshot={onSnapshot}
|
onSnapshot={onSnapshot}
|
||||||
handLabel={handHint?.label ?? null}
|
handLabel={handHint?.label ?? null}
|
||||||
bestCardKeys={bestCardKeys}
|
bestCardKeys={bestCardKeys}
|
||||||
|
actionProps={{
|
||||||
|
starting,
|
||||||
|
leaving,
|
||||||
|
onStart: () => void start(),
|
||||||
|
onLeave: () => void leave(),
|
||||||
|
}}
|
||||||
|
onActionFeedback={sound.playUiClick}
|
||||||
|
popups={popups}
|
||||||
fullscreen={fullscreen}
|
fullscreen={fullscreen}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -312,11 +111,13 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
|
||||||
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
|
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
{view.phase === "game-complete" && view.viewerSeat === snapshot.hostSeatId && (
|
<PokerCardStyleToggle />
|
||||||
<Button type="button" onClick={() => void start()} disabled={starting}>
|
<PokerSoundToggle
|
||||||
{starting ? "Starting..." : "Start new game"}
|
muted={sound.muted}
|
||||||
</Button>
|
volume={sound.volume}
|
||||||
)}
|
onToggleMuted={sound.toggleMuted}
|
||||||
|
onVolumeChange={sound.setVolume}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -327,9 +128,16 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
|
||||||
>
|
>
|
||||||
<Minimize2 aria-hidden="true" />
|
<Minimize2 aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
|
{view.phase !== "game-complete" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void leave()}
|
||||||
|
disabled={leaving}
|
||||||
|
>
|
||||||
{leaving ? "Leaving..." : "Leave table"}
|
{leaving ? "Leaving..." : "Leave table"}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{feltCanvas}
|
{feltCanvas}
|
||||||
|
|
@ -360,6 +168,13 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
|
||||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
v{snapshot.version}
|
v{snapshot.version}
|
||||||
</span>
|
</span>
|
||||||
|
<PokerCardStyleToggle />
|
||||||
|
<PokerSoundToggle
|
||||||
|
muted={sound.muted}
|
||||||
|
volume={sound.volume}
|
||||||
|
onToggleMuted={sound.toggleMuted}
|
||||||
|
onVolumeChange={sound.setVolume}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -370,19 +185,14 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
|
||||||
>
|
>
|
||||||
<Maximize2 aria-hidden="true" />
|
<Maximize2 aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{view.phase !== "game-complete" && (
|
||||||
|
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
|
||||||
|
{leaving ? "Leaving..." : "Leave table"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">{feltCanvas}</CardContent>
|
<CardContent className="p-4">{feltCanvas}</CardContent>
|
||||||
<CardFooter className="flex flex-wrap justify-end gap-2 border-t p-4">
|
|
||||||
<TableActionButtons
|
|
||||||
view={view}
|
|
||||||
hostSeatId={snapshot.hostSeatId}
|
|
||||||
starting={starting}
|
|
||||||
leaving={leaving}
|
|
||||||
onStart={() => void start()}
|
|
||||||
onLeave={() => void leave()}
|
|
||||||
/>
|
|
||||||
</CardFooter>
|
|
||||||
</UiCard>
|
</UiCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ interface PokerRanking {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly example: readonly Card[];
|
readonly example: readonly Card[];
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
|
readonly color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function card(rank: Rank, suit: Suit): Card {
|
function card(rank: Rank, suit: Suit): Card {
|
||||||
|
|
@ -48,31 +49,37 @@ const RANKINGS: readonly PokerRanking[] = [
|
||||||
name: "Straight",
|
name: "Straight",
|
||||||
example: [card(5, "s"), card(6, "h"), card(7, "d"), card(8, "c"), card(9, "s")],
|
example: [card(5, "s"), card(6, "h"), card(7, "d"), card(8, "c"), card(9, "s")],
|
||||||
description: "Five cards run in rank order.",
|
description: "Five cards run in rank order.",
|
||||||
|
color: "#ffff00",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Flush",
|
name: "Flush",
|
||||||
example: [card(14, "h"), card(10, "h"), card(7, "h"), card(4, "h"), card(2, "h")],
|
example: [card(14, "h"), card(10, "h"), card(7, "h"), card(4, "h"), card(2, "h")],
|
||||||
description: "Five cards share the same suit.",
|
description: "Five cards share the same suit.",
|
||||||
|
color: "#ffff00",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Full house",
|
name: "Full house",
|
||||||
example: [card(13, "s"), card(13, "h"), card(13, "d"), card(8, "c"), card(8, "s")],
|
example: [card(13, "s"), card(13, "h"), card(13, "d"), card(8, "c"), card(8, "s")],
|
||||||
description: "Three of one rank and two of another.",
|
description: "Three of one rank and two of another.",
|
||||||
|
color: "#ffff00",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Four of a kind",
|
name: "Four of a kind",
|
||||||
example: [card(3, "s"), card(3, "h"), card(3, "d"), card(3, "c"), card(14, "s")],
|
example: [card(3, "s"), card(3, "h"), card(3, "d"), card(3, "c"), card(14, "s")],
|
||||||
description: "Four cards share the same rank.",
|
description: "Four cards share the same rank.",
|
||||||
|
color: "#ffff00",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Straight flush",
|
name: "Straight flush",
|
||||||
example: [card(5, "s"), card(6, "s"), card(7, "s"), card(8, "s"), card(9, "s")],
|
example: [card(5, "s"), card(6, "s"), card(7, "s"), card(8, "s"), card(9, "s")],
|
||||||
description: "Five cards run in order and share a suit.",
|
description: "Five cards run in order and share a suit.",
|
||||||
|
color: "#ffff00",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Royal flush",
|
name: "Royal flush",
|
||||||
example: [card(10, "h"), card(11, "h"), card(12, "h"), card(13, "h"), card(14, "h")],
|
example: [card(10, "h"), card(11, "h"), card(12, "h"), card(13, "h"), card(14, "h")],
|
||||||
description: "The ace-high straight flush, the strongest hand.",
|
description: "The ace-high straight flush, the strongest hand.",
|
||||||
|
color: "#ffff00",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -97,11 +104,13 @@ export function PokerGuide() {
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider">Hand rankings</h2>
|
<h2 className="text-sm font-semibold uppercase tracking-wider">Hand rankings</h2>
|
||||||
<ol className="grid gap-3 sm:grid-cols-2">
|
<ol className="grid gap-3 sm:grid-cols-2">
|
||||||
{RANKINGS.map((ranking, index) => (
|
{RANKINGS.map((ranking, index) => (
|
||||||
<li key={ranking.name} className="flex items-center gap-3 border border-border p-3">
|
<li key={ranking.name} className="flex items-center gap-3 border border-border p-3" style={ranking.color ? ({
|
||||||
|
borderColor: ranking.color
|
||||||
|
}) : {}}>
|
||||||
<span className="w-5 shrink-0 font-mono text-xs text-muted-foreground">
|
<span className="w-5 shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="font-medium">{ranking.name}</span>
|
<span className="font-medium">{ranking.name}</span>
|
||||||
<div className="flex gap-0.5" aria-label={`${ranking.name} example`}>
|
<div className="flex gap-0.5" aria-label={`${ranking.name} example`}>
|
||||||
|
|
@ -115,7 +124,7 @@ export function PokerGuide() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{ranking.description}</p>
|
<p className="text-xs text-muted-foreground max-w-1/2">{ranking.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Card, PublicPlayerView } from "@/lib/minigames/poker";
|
import type { PublicPlayerView } from "@/lib/minigames/poker";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { CardView } from "./CardView";
|
import { CardView } from "./CardView";
|
||||||
|
|
||||||
interface PokerSeatProps {
|
interface PokerSeatProps {
|
||||||
|
|
@ -11,14 +11,11 @@ interface PokerSeatProps {
|
||||||
readonly isActing: boolean;
|
readonly isActing: boolean;
|
||||||
readonly isWinner: boolean;
|
readonly isWinner: boolean;
|
||||||
readonly showHoleCards: boolean;
|
readonly showHoleCards: boolean;
|
||||||
readonly handLabel?: string | null;
|
|
||||||
readonly cardKeySet?: ReadonlySet<string>;
|
|
||||||
readonly compact?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cardKey(card: Card): string {
|
type StackFlash = "up" | "down" | null;
|
||||||
return `${card.rank}-${card.suit}`;
|
|
||||||
}
|
const STACK_FLASH_MS = 900;
|
||||||
|
|
||||||
export function PokerSeat({
|
export function PokerSeat({
|
||||||
player,
|
player,
|
||||||
|
|
@ -26,77 +23,104 @@ export function PokerSeat({
|
||||||
isActing,
|
isActing,
|
||||||
isWinner,
|
isWinner,
|
||||||
showHoleCards,
|
showHoleCards,
|
||||||
handLabel = null,
|
|
||||||
cardKeySet,
|
|
||||||
compact = false,
|
|
||||||
}: PokerSeatProps) {
|
}: PokerSeatProps) {
|
||||||
const cards = showHoleCards ? player.holeCards : null;
|
const revealed = showHoleCards ? player.holeCards : null;
|
||||||
const cardCount = player.cardCount > 0 ? player.cardCount : 2;
|
const inHand = showHoleCards && !player.folded && !player.eliminated && !player.left;
|
||||||
const faceCards = cards ?? Array.from({ length: cardCount }, () => null);
|
const initial = player.name.trim().charAt(0).toUpperCase() || "?";
|
||||||
|
const statusLabel = player.left
|
||||||
|
? "Left"
|
||||||
|
: isActing
|
||||||
|
? "Acting"
|
||||||
|
: player.folded
|
||||||
|
? "Folded"
|
||||||
|
: player.allIn
|
||||||
|
? "All-in"
|
||||||
|
: player.hasActed
|
||||||
|
? "Acted"
|
||||||
|
: null;
|
||||||
|
const [stackFlash, setStackFlash] = useState<StackFlash>(null);
|
||||||
|
const previousStackRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousStack = previousStackRef.current;
|
||||||
|
previousStackRef.current = player.stack;
|
||||||
|
if (previousStack === null || previousStack === player.stack) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
setStackFlash(player.stack > previousStack ? "up" : "down");
|
||||||
|
const timer = setTimeout(() => setStackFlash(null), STACK_FLASH_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [player.stack]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"poker-seat-panel flex flex-col gap-2 border p-3 text-left backdrop-blur-sm",
|
"poker-seat-panel mx-auto flex w-max items-center gap-2 px-2 py-1.5 backdrop-blur-sm",
|
||||||
compact && "flex-row flex-wrap items-center gap-2 p-2 sm:flex-nowrap sm:gap-3",
|
(player.folded || player.left) && "opacity-55",
|
||||||
player.folded && "opacity-50",
|
|
||||||
player.eliminated && "opacity-40",
|
player.eliminated && "opacity-40",
|
||||||
isActing && "ring-2 ring-emerald-400/90",
|
isActing && "poker-seat-active ring-2 ring-emerald-400/90",
|
||||||
isCurrentUser && "border-ring",
|
isCurrentUser && "border-ring",
|
||||||
isWinner && "ring-2 ring-amber-300",
|
isWinner && "ring-2 ring-amber-300",
|
||||||
)}
|
)}
|
||||||
aria-label={`${player.name}, seat ${player.seatId + 1}`}
|
aria-label={`${player.name}, seat ${player.seatId + 1}, stack ${player.stack.toLocaleString()}`}
|
||||||
>
|
>
|
||||||
<div className={cn("flex items-start justify-between gap-2", compact && "min-w-0 flex-1")}>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-semibold">
|
|
||||||
{player.name}
|
|
||||||
{isCurrentUser && <span className="ml-1 text-xs text-muted-foreground">(you)</span>}
|
|
||||||
</p>
|
|
||||||
<p className="font-mono text-xs tabular-nums text-muted-foreground">
|
|
||||||
Stack {player.stack.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
{player.allIn && <Badge variant="destructive">All-in</Badge>}
|
|
||||||
{player.folded && <Badge variant="outline">Folded</Badge>}
|
|
||||||
{isWinner && <Badge variant="secondary">Winner</Badge>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{handLabel && (
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn("poker-seat-avatar", `poker-seat-avatar-tone-${player.seatId % 4}`)}
|
||||||
"w-fit max-w-full border border-amber-300/40 bg-amber-300/10 px-2 py-1 text-xs text-amber-200",
|
aria-hidden="true"
|
||||||
compact && "max-w-[12rem] truncate",
|
|
||||||
)}
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
>
|
||||||
You have: {handLabel}
|
{initial}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="poker-seat-nameplate min-w-0">
|
||||||
|
<p className="truncate text-[0.65rem] font-medium leading-tight text-muted-foreground">
|
||||||
|
{player.name}
|
||||||
|
{isCurrentUser && <span className="ml-1">(you)</span>}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"poker-seat-stack",
|
||||||
|
stackFlash === "up" && "poker-seat-stack-flash-up",
|
||||||
|
stackFlash === "down" && "poker-seat-stack-flash-down",
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<span className="poker-seat-stack-chip" aria-hidden="true" />
|
||||||
|
{player.stack.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{(statusLabel || isWinner) && (
|
||||||
|
<div className="poker-seat-status-row">
|
||||||
|
{statusLabel && <span className="poker-seat-action-badge">{statusLabel}</span>}
|
||||||
|
{isWinner && (
|
||||||
|
<span className="poker-seat-action-badge poker-seat-action-winner">Winner</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={cn("flex items-end justify-between gap-2", compact && "shrink-0")}>
|
{revealed ? (
|
||||||
<div className="flex gap-1" aria-label="Hole cards">
|
<div className="poker-seat-reveal" aria-label="Revealed hole cards">
|
||||||
{faceCards.map((card, index) => (
|
{revealed.map((card, index) => (
|
||||||
<CardView
|
<CardView key={`${player.seatId}-hole-${index}`} card={card} faceUp size="mini" />
|
||||||
key={`${player.seatId}-hole-${index}`}
|
|
||||||
card={card}
|
|
||||||
faceUp={card !== null}
|
|
||||||
size={compact ? "mini" : "sm"}
|
|
||||||
dealIndex={index}
|
|
||||||
highlighted={card !== null && cardKeySet?.has(cardKey(card))}
|
|
||||||
dimmed={card !== null && cardKeySet !== undefined && !cardKeySet.has(cardKey(card))}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
) : (
|
||||||
<p className="font-mono text-xs tabular-nums text-muted-foreground">
|
inHand && (
|
||||||
{player.committed > 0 ? `Bet ${player.committed.toLocaleString()}` : "No bet"}
|
<div className="poker-seat-cardbacks" aria-label="Hole cards, face down">
|
||||||
</p>
|
<span aria-hidden="true" />
|
||||||
{isActing && <p className="text-xs font-semibold text-emerald-300">Acting</p>}
|
<span aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{player.committed > 0 && (
|
||||||
|
<div
|
||||||
|
className="poker-seat-commit"
|
||||||
|
aria-label={`Current bet ${player.committed.toLocaleString()}`}
|
||||||
|
>
|
||||||
|
<span className="poker-seat-commit-chip" aria-hidden="true" />
|
||||||
|
<span className="font-mono text-[0.65rem] tabular-nums">
|
||||||
|
{player.committed.toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,16 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
||||||
|
import { MIN_PLAYERS } from "@/lib/minigames/poker";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { PokerCardStyleProvider } from "./cardStyle";
|
||||||
import { PokerGameTable } from "./PokerGameTable";
|
import { PokerGameTable } from "./PokerGameTable";
|
||||||
import { RenameTableDialog } from "./RenameTableDialog";
|
import { RenameTableDialog } from "./RenameTableDialog";
|
||||||
|
import { usePokerPopups } from "./usePokerPopups";
|
||||||
|
import { usePokerSounds } from "./usePokerSounds";
|
||||||
import { seatPosition } from "./seatPosition";
|
import { seatPosition } from "./seatPosition";
|
||||||
|
|
||||||
interface PokerTableProps {
|
interface PokerTableProps {
|
||||||
|
|
@ -53,7 +57,10 @@ function WaitingTable({
|
||||||
const viewerSeat = snapshot.seats.findIndex((id) => String(id) === String(currentUserId));
|
const viewerSeat = snapshot.seats.findIndex((id) => String(id) === String(currentUserId));
|
||||||
const seated = viewerSeat >= 0;
|
const seated = viewerSeat >= 0;
|
||||||
const full = snapshot.seats.length > 0 && snapshot.seats.every((id) => id !== null);
|
const full = snapshot.seats.length > 0 && snapshot.seats.every((id) => id !== null);
|
||||||
|
const seatedCount = snapshot.seats.filter((id) => id !== null).length;
|
||||||
|
const enoughPlayers = seatedCount >= MIN_PLAYERS;
|
||||||
const isHost = seated && snapshot.hostSeatId === viewerSeat;
|
const isHost = seated && snapshot.hostSeatId === viewerSeat;
|
||||||
|
const opponentCount = snapshot.seats.length - (seated ? 1 : 0);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
setStarting(true);
|
setStarting(true);
|
||||||
|
|
@ -92,18 +99,34 @@ function WaitingTable({
|
||||||
<Badge variant="outline">Waiting room</Badge>
|
<Badge variant="outline">Waiting room</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="poker-felt-table relative min-h-[28rem] overflow-hidden p-4 sm:min-h-[34rem]">
|
<div className="poker-felt-table relative min-h-[30rem] overflow-hidden p-4 sm:min-h-[36rem]">
|
||||||
{snapshot.seats.map((userId, seatId) => (
|
{snapshot.seats.map((userId, seatId) => (
|
||||||
<div
|
<div
|
||||||
key={seatId}
|
key={seatId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"poker-waiting-seat absolute flex flex-col gap-1 border px-3 py-2 text-xs",
|
"poker-waiting-seat absolute flex flex-col items-center gap-1 border px-3 py-2 text-xs",
|
||||||
seatPosition(seatId),
|
seatId === viewerSeat
|
||||||
|
? "poker-waiting-seat-south"
|
||||||
|
: seatPosition(
|
||||||
|
snapshot.seats
|
||||||
|
.slice(0, seatId)
|
||||||
|
.filter((_, previousSeatId) => previousSeatId !== viewerSeat).length,
|
||||||
|
opponentCount,
|
||||||
|
),
|
||||||
userId !== null && "poker-waiting-seat-occupied",
|
userId !== null && "poker-waiting-seat-occupied",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="font-semibold">Seat {seatId + 1}</span>
|
<span
|
||||||
<span className="text-white/70">
|
className={cn(
|
||||||
|
"poker-seat-avatar",
|
||||||
|
userId === null ? "poker-seat-avatar-open" : "poker-seat-avatar-tone-0",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{userId === null ? "?" : String(userId).charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="poker-waiting-name font-semibold">Seat {seatId + 1}</span>
|
||||||
|
<span className="poker-waiting-status text-white/70">
|
||||||
{userId === null
|
{userId === null
|
||||||
? "Open seat"
|
? "Open seat"
|
||||||
: String(userId) === String(currentUserId)
|
: String(userId) === String(currentUserId)
|
||||||
|
|
@ -126,8 +149,16 @@ function WaitingTable({
|
||||||
{seated ? (
|
{seated ? (
|
||||||
<>
|
<>
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<Button type="button" onClick={() => void start()} disabled={starting}>
|
<Button
|
||||||
{starting ? "Starting..." : "Start game"}
|
type="button"
|
||||||
|
onClick={() => void start()}
|
||||||
|
disabled={starting || !enoughPlayers}
|
||||||
|
>
|
||||||
|
{starting
|
||||||
|
? "Starting..."
|
||||||
|
: enoughPlayers
|
||||||
|
? "Start game"
|
||||||
|
: "You need at least two players to start"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
|
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
|
||||||
|
|
@ -149,7 +180,7 @@ function WaitingTable({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PokerTable({
|
function PokerTableInner({
|
||||||
snapshot,
|
snapshot,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -160,6 +191,12 @@ export function PokerTable({
|
||||||
onLeave,
|
onLeave,
|
||||||
onBack,
|
onBack,
|
||||||
}: PokerTableProps) {
|
}: PokerTableProps) {
|
||||||
|
// Sound + popup transitions must live here, ABOVE the waiting/game split:
|
||||||
|
// the hooks have to observe the waiting-room-to-first-hand transition,
|
||||||
|
// which happens before the game table (and its seat view) mounts.
|
||||||
|
const sound = usePokerSounds(snapshot);
|
||||||
|
const popups = usePokerPopups(snapshot);
|
||||||
|
|
||||||
if (loading && !snapshot) {
|
if (loading && !snapshot) {
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-none">
|
<Card className="rounded-none">
|
||||||
|
|
@ -214,6 +251,16 @@ export function PokerTable({
|
||||||
onSnapshot={onSnapshot}
|
onSnapshot={onSnapshot}
|
||||||
onStart={onStart}
|
onStart={onStart}
|
||||||
onLeave={onLeave}
|
onLeave={onLeave}
|
||||||
|
sound={sound}
|
||||||
|
popups={popups}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PokerTable(props: PokerTableProps) {
|
||||||
|
return (
|
||||||
|
<PokerCardStyleProvider>
|
||||||
|
<PokerTableInner {...props} />
|
||||||
|
</PokerCardStyleProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,28 @@
|
||||||
const SEAT_POSITION_CLASSES = [
|
const ARC_POSITION_CLASSES = [
|
||||||
"poker-seat-south",
|
["poker-seat-arc-center"],
|
||||||
"poker-seat-west",
|
["poker-seat-arc-left", "poker-seat-arc-right"],
|
||||||
"poker-seat-north",
|
["poker-seat-arc-left", "poker-seat-arc-center", "poker-seat-arc-right"],
|
||||||
"poker-seat-east",
|
[
|
||||||
|
"poker-seat-arc-far-left",
|
||||||
|
"poker-seat-arc-center-left",
|
||||||
|
"poker-seat-arc-center-right",
|
||||||
|
"poker-seat-arc-far-right",
|
||||||
|
],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function seatPosition(seatId: number): string {
|
export function seatPosition(opponentIndex: number, opponentCount: number): string {
|
||||||
return SEAT_POSITION_CLASSES[seatId % SEAT_POSITION_CLASSES.length] ?? SEAT_POSITION_CLASSES[0];
|
const normalizedCount = Math.min(ARC_POSITION_CLASSES.length, Math.max(1, opponentCount));
|
||||||
|
const positions = ARC_POSITION_CLASSES[normalizedCount - 1] ?? ARC_POSITION_CLASSES[0];
|
||||||
|
const normalizedIndex = Math.min(positions.length - 1, Math.max(0, opponentIndex));
|
||||||
|
return positions[normalizedIndex] ?? positions[0] ?? "poker-seat-arc-center";
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOP_SEAT_CLASSES = new Set<string>([
|
||||||
|
"poker-seat-arc-center",
|
||||||
|
"poker-seat-arc-center-left",
|
||||||
|
"poker-seat-arc-center-right",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isTopSeatPosition(positionClass: string): boolean {
|
||||||
|
return TOP_SEAT_CLASSES.has(positionClass);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue