feat(games): refactor blackjack for continuous play, split/double, and table UI
Some checks failed
Deploy to Production / test (push) Failing after 32s
Some checks failed
Deploy to Production / test (push) Failing after 32s
Transform blackjack from single-round to continuous-play table sessions with round lifecycle (betting → playing → resolved → betting), split/double down actions, per-hand bet tracking, leave/join table mid-session, and a responsive felt-style table UI with arc-positioned player seats. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,7 +42,7 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
||||
|
||||
const {
|
||||
gameState, players, spectators, roomStatus,
|
||||
isSpectator, gameOver, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom, startGame, roomOptions,
|
||||
isSpectator, gameOver, roundResult, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom, startGame, roomOptions,
|
||||
} = useGameRoom(roomId!, userId, role, preferAs);
|
||||
|
||||
const betAmount = roomOptions.betAmount ?? 0;
|
||||
@@ -219,6 +219,8 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
||||
isSpectator={isSpectator}
|
||||
onAction={sendAction}
|
||||
players={players}
|
||||
roundResult={roundResult}
|
||||
roomOptions={roomOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState, useEffect, useCallback } from "react";
|
||||
import type { GameUIProps } from "../registry";
|
||||
import { Hand } from "lucide-react";
|
||||
import { Hand, Square, ArrowLeftRight, ChevronsUp, LogOut, Check, Coins } from "lucide-react";
|
||||
|
||||
// ── Types matching server views ──
|
||||
|
||||
@@ -15,25 +15,35 @@ interface PlayerHandView {
|
||||
status: "playing" | "stood" | "bust" | "blackjack";
|
||||
result: "win" | "blackjack" | "push" | "lose" | null;
|
||||
resultReason: string | null;
|
||||
bet: number;
|
||||
fromSplit: boolean;
|
||||
}
|
||||
|
||||
interface PlayerSeatView {
|
||||
hands: PlayerHandView[];
|
||||
activeHandIndex: number;
|
||||
hasBet: boolean;
|
||||
}
|
||||
|
||||
interface BlackjackViewBase {
|
||||
dealerHand: Card[];
|
||||
dealerVisibleValue: number;
|
||||
dealerFullValue: number | null;
|
||||
hands: Record<string, PlayerHandView>;
|
||||
seats: Record<string, PlayerSeatView>;
|
||||
turnOrder: string[];
|
||||
activePlayerId: string | null;
|
||||
phase: "player_turns" | "resolved";
|
||||
activeHandIndex: number;
|
||||
phase: "betting" | "player_turns" | "resolved";
|
||||
roundNumber: number;
|
||||
}
|
||||
|
||||
interface PlayerView extends BlackjackViewBase {
|
||||
myPlayerId: string;
|
||||
canAct: boolean;
|
||||
canSplit: boolean;
|
||||
canDoubleDown: boolean;
|
||||
}
|
||||
|
||||
interface SpectatorView extends BlackjackViewBase {}
|
||||
|
||||
function isPlayerView(state: unknown): state is PlayerView {
|
||||
return typeof state === "object" && state !== null && "canAct" in state;
|
||||
}
|
||||
@@ -51,16 +61,31 @@ function cardImageUrl(card: Card): string {
|
||||
return `/cards/${rank}_of_${card.suit}.svg`;
|
||||
}
|
||||
|
||||
// ── Card rendering ──
|
||||
// ── Seat layout positions (percentage-based for desktop arc) ──
|
||||
|
||||
function PlayingCard({ card, faceDown, compact }: { card: Card; faceDown?: boolean; compact?: boolean }) {
|
||||
const SEAT_POSITIONS = [
|
||||
{ left: "10%", top: "72%" }, // seat 1 — bottom-left
|
||||
{ left: "2%", top: "42%" }, // seat 2 — mid-left
|
||||
{ left: "16%", top: "16%" }, // seat 3 — top-left
|
||||
{ left: "84%", top: "16%" }, // seat 4 — top-right (mirrored)
|
||||
{ left: "98%", top: "42%" }, // seat 5 — mid-right
|
||||
{ left: "90%", top: "72%" }, // seat 6 — bottom-right
|
||||
] as const;
|
||||
|
||||
// ── Card component ──
|
||||
|
||||
function PlayingCard({ card, faceDown, size = "normal" }: {
|
||||
card: Card;
|
||||
faceDown?: boolean;
|
||||
size?: "small" | "normal";
|
||||
}) {
|
||||
const isFaceDown = faceDown || card.rank === "?";
|
||||
const sizeClass = compact
|
||||
? "w-14 h-20 sm:w-16 sm:h-[5.5rem]"
|
||||
: "w-18 h-25 sm:w-20 sm:h-28";
|
||||
const sizeClass = size === "small"
|
||||
? "w-9 h-[3.25rem] sm:w-11 sm:h-[3.875rem]"
|
||||
: "w-12 h-[4.25rem] sm:w-14 sm:h-[5rem]";
|
||||
|
||||
return (
|
||||
<div className={`relative ${sizeClass} rounded-lg overflow-hidden shadow-lg shrink-0`}>
|
||||
<div className={`relative ${sizeClass} rounded-md overflow-hidden shadow-md shrink-0`}>
|
||||
{isFaceDown ? (
|
||||
<img
|
||||
src="/cards/back.png"
|
||||
@@ -72,7 +97,7 @@ function PlayingCard({ card, faceDown, compact }: { card: Card; faceDown?: boole
|
||||
<img
|
||||
src={cardImageUrl(card)}
|
||||
alt={`${card.rank} of ${card.suit}`}
|
||||
className="w-full h-full object-contain bg-white rounded-lg"
|
||||
className="w-full h-full object-contain bg-white rounded-md"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
@@ -80,18 +105,36 @@ function PlayingCard({ card, faceDown, compact }: { card: Card; faceDown?: boole
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card fan (overlapping cards) ──
|
||||
|
||||
function CardFan({ cards, faceDown, size = "normal" }: {
|
||||
cards: Card[];
|
||||
faceDown?: boolean;
|
||||
size?: "small" | "normal";
|
||||
}) {
|
||||
return (
|
||||
<div className="flex">
|
||||
{cards.map((card, i) => (
|
||||
<div key={`${card.suit}-${card.rank}-${i}`} className={i > 0 ? "-ml-5 sm:-ml-6" : ""}>
|
||||
<PlayingCard card={card} faceDown={faceDown} size={size} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Value badge ──
|
||||
|
||||
function ValueBadge({ value, status }: { value: number; status?: string }) {
|
||||
const colorClass =
|
||||
value === 21 || status === "blackjack"
|
||||
? "bg-success/15 text-success font-bold"
|
||||
? "bg-emerald-500/20 text-emerald-400 font-bold"
|
||||
: value > 21 || status === "bust"
|
||||
? "bg-destructive/15 text-destructive font-bold"
|
||||
: "bg-card text-text-secondary";
|
||||
? "bg-red-500/20 text-red-400 font-bold"
|
||||
: "bg-black/30 text-white/80";
|
||||
|
||||
return (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-md ${colorClass}`}>
|
||||
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${colorClass}`}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
@@ -101,158 +144,496 @@ function ValueBadge({ value, status }: { value: number; status?: string }) {
|
||||
|
||||
function ResultBadge({ result, reason }: { result: string; reason: string | null }) {
|
||||
const config: Record<string, { label: string; color: string }> = {
|
||||
blackjack: { label: "Blackjack!", color: "bg-warning/15 text-warning" },
|
||||
win: { label: "Win", color: "bg-success/15 text-success" },
|
||||
push: { label: "Push", color: "bg-info/15 text-info" },
|
||||
lose: { label: "Lose", color: "bg-destructive/15 text-destructive" },
|
||||
blackjack: { label: "Blackjack!", color: "bg-yellow-500/20 text-yellow-300" },
|
||||
win: { label: "Win", color: "bg-emerald-500/20 text-emerald-400" },
|
||||
push: { label: "Push", color: "bg-blue-500/20 text-blue-400" },
|
||||
lose: { label: "Lose", color: "bg-red-500/20 text-red-400" },
|
||||
};
|
||||
const c = config[result] ?? { label: "—", color: "bg-card text-text-tertiary" };
|
||||
const c = config[result] ?? { label: "-", color: "bg-black/20 text-white/50" };
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg px-2.5 py-1 text-xs font-semibold ${c.color}`} title={reason ?? undefined}>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${c.color}`} title={reason ?? undefined}>
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bet chip indicator ──
|
||||
|
||||
function BetChip({ bet, betAmount }: { bet: number; betAmount: number }) {
|
||||
if (betAmount <= 0) return null;
|
||||
const total = bet * betAmount;
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 bg-yellow-500/20 text-yellow-300 rounded px-1.5 py-0.5">
|
||||
<Coins className="w-2.5 h-2.5" />
|
||||
<span className="text-[9px] font-bold">{total}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Player hand section ──
|
||||
// ── Single hand display ──
|
||||
|
||||
function PlayerHandSection({ hand, playerName, isActive, isMe, compact }: {
|
||||
function HandDisplay({ hand, isActive, betAmount, size = "normal" }: {
|
||||
hand: PlayerHandView;
|
||||
playerName: string;
|
||||
isActive: boolean;
|
||||
isMe: boolean;
|
||||
compact: boolean;
|
||||
betAmount: number;
|
||||
size?: "small" | "normal";
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-xl px-3 py-3 transition-colors ${
|
||||
isActive ? "bg-primary/5 ring-1 ring-primary/20" : "bg-card/50"
|
||||
<div className={`flex flex-col items-center gap-1 rounded-lg px-1.5 py-1 transition-all ${
|
||||
isActive ? "ring-1 ring-primary/40 bg-primary/5" : ""
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`text-sm font-label font-semibold truncate ${
|
||||
isActive ? "text-primary" : "text-text-tertiary"
|
||||
}`}>
|
||||
{playerName}{isMe ? " (You)" : ""}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<ValueBadge value={hand.value} status={hand.status} />
|
||||
{hand.result && <ResultBadge result={hand.result} reason={hand.resultReason} />}
|
||||
</div>
|
||||
<CardFan cards={hand.cards} size={size} />
|
||||
<div className="flex items-center gap-1 flex-wrap justify-center">
|
||||
<ValueBadge value={hand.value} status={hand.status} />
|
||||
{hand.bet > 1 && <BetChip bet={hand.bet} betAmount={betAmount} />}
|
||||
{hand.result && <ResultBadge result={hand.result} reason={hand.resultReason} />}
|
||||
</div>
|
||||
{/* Cards */}
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{hand.cards.map((card, i) => (
|
||||
<PlayingCard key={`${card.suit}-${card.rank}-${i}`} card={card} compact={compact} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Player seat ──
|
||||
|
||||
function Seat({ seat, playerName, isMe, isActivePlayer, activeHandIdx, betAmount }: {
|
||||
seat: PlayerSeatView;
|
||||
playerName: string;
|
||||
isMe: boolean;
|
||||
isActivePlayer: boolean;
|
||||
activeHandIdx: number;
|
||||
betAmount: number;
|
||||
}) {
|
||||
const hasBet = seat.hasBet;
|
||||
const hasHands = seat.hands.length > 0;
|
||||
const compact = seat.hands.length > 2;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center gap-1.5 transition-all ${
|
||||
isActivePlayer ? "scale-105" : ""
|
||||
}`}>
|
||||
{/* Player label */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold ${
|
||||
isActivePlayer ? "bg-primary/30 text-primary ring-2 ring-primary/50" : "bg-white/10 text-white/70"
|
||||
}`}>
|
||||
{playerName[0]?.toUpperCase() ?? "?"}
|
||||
</div>
|
||||
<span className={`text-xs font-medium truncate max-w-20 ${
|
||||
isActivePlayer ? "text-primary" : "text-white/70"
|
||||
}`}>
|
||||
{playerName}{isMe ? "" : ""}
|
||||
</span>
|
||||
{isActivePlayer && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bet status (during betting phase, no hands yet) */}
|
||||
{!hasHands && (
|
||||
<div className={`flex items-center gap-1 text-[10px] rounded-full px-2 py-0.5 ${
|
||||
hasBet ? "bg-emerald-500/20 text-emerald-400" : "bg-white/5 text-white/40"
|
||||
}`}>
|
||||
{hasBet ? <><Check className="w-2.5 h-2.5" /> Ready</> : "Waiting..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hands */}
|
||||
{hasHands && (
|
||||
<div className={`flex gap-1 ${seat.hands.length > 1 ? "flex-row" : "flex-col"}`}>
|
||||
{seat.hands.map((hand, hi) => (
|
||||
<HandDisplay
|
||||
key={hi}
|
||||
hand={hand}
|
||||
isActive={isActivePlayer && hi === activeHandIdx}
|
||||
betAmount={betAmount}
|
||||
size={compact ? "small" : "normal"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bet amount indicator */}
|
||||
{hasHands && betAmount > 0 && (
|
||||
<BetChip bet={seat.hands.reduce((sum, h) => sum + h.bet, 0)} betAmount={betAmount} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty seat ──
|
||||
|
||||
function EmptySeat({ canSit, onSit }: { canSit: boolean; onSit: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className={`w-6 h-6 rounded-full border border-dashed flex items-center justify-center text-[10px] ${
|
||||
canSit ? "border-white/30 text-white/30" : "border-white/10 text-white/10"
|
||||
}`}>
|
||||
+
|
||||
</div>
|
||||
{canSit ? (
|
||||
<button
|
||||
onClick={onSit}
|
||||
className="text-[10px] text-primary/70 hover:text-primary transition-colors"
|
||||
>
|
||||
Sit
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[10px] text-white/15">Empty</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dealer area ──
|
||||
|
||||
function DealerArea({ dealerHand, visibleValue, fullValue }: {
|
||||
dealerHand: Card[];
|
||||
visibleValue: number;
|
||||
fullValue: number | null;
|
||||
}) {
|
||||
if (dealerHand.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-white/50">Dealer</span>
|
||||
<div className="w-12 h-[4.25rem] sm:w-14 sm:h-[5rem] rounded-md border border-dashed border-white/10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayValue = fullValue ?? visibleValue;
|
||||
const isBust = fullValue !== null && fullValue > 21;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-white/60">Dealer</span>
|
||||
{displayValue > 0 && (
|
||||
<ValueBadge value={displayValue} status={isBust ? "bust" : undefined} />
|
||||
)}
|
||||
</div>
|
||||
<CardFan cards={dealerHand} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Round result banner ──
|
||||
|
||||
function RoundResultBanner({ roundNumber, roundResult, myPlayerId, betAmount }: {
|
||||
roundNumber: number;
|
||||
roundResult: GameUIProps["roundResult"];
|
||||
myPlayerId: string;
|
||||
betAmount: number;
|
||||
}) {
|
||||
const myPayout = roundResult?.payouts[myPlayerId];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 bg-black/30 rounded-xl px-4 py-2">
|
||||
<span className="text-xs text-white/50">Round {roundNumber}</span>
|
||||
{myPayout && betAmount > 0 && (
|
||||
<span className={`text-xs font-semibold ${
|
||||
myPayout.net > betAmount ? "text-emerald-400" : myPayout.net === betAmount ? "text-blue-400" : "text-white/60"
|
||||
}`}>
|
||||
+{myPayout.net} AU
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ──
|
||||
|
||||
export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) {
|
||||
const view = state as PlayerView | SpectatorView;
|
||||
export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, players, roundResult, roomOptions }: GameUIProps) {
|
||||
const view = state as PlayerView | BlackjackViewBase;
|
||||
const playerView = isPlayerView(state) ? state : null;
|
||||
const betAmount = roomOptions?.betAmount ?? 0;
|
||||
const isResolved = view.phase === "resolved";
|
||||
const compact = view.turnOrder.length > 3;
|
||||
const isBetting = view.phase === "betting";
|
||||
const isPlaying = view.phase === "player_turns";
|
||||
|
||||
const getPlayerName = useMemo(() => {
|
||||
const nameMap = new Map(players.map(p => [p.discordId, p.username]));
|
||||
return (id: string) => nameMap.get(id) ?? id.slice(0, 8);
|
||||
}, [players]);
|
||||
|
||||
const mySeat = view.seats[myPlayerId];
|
||||
const myBetPlaced = mySeat?.hasBet ?? false;
|
||||
|
||||
// Determine who can sit (spectators during betting phase)
|
||||
const canSitDown = isSpectator && isBetting && view.turnOrder.length < 6;
|
||||
|
||||
// Build the seat map for rendering (up to 6 seats)
|
||||
const seatSlots = useMemo(() => {
|
||||
const slots: Array<{ playerId: string | null; seat: PlayerSeatView | null }> = [];
|
||||
// Fill with seated players
|
||||
for (const pid of view.turnOrder) {
|
||||
slots.push({ playerId: pid, seat: view.seats[pid] ?? null });
|
||||
}
|
||||
// Fill remaining with empty seats up to 6
|
||||
while (slots.length < 6) {
|
||||
slots.push({ playerId: null, seat: null });
|
||||
}
|
||||
return slots;
|
||||
}, [view.turnOrder, view.seats]);
|
||||
|
||||
// Auto-place bet after resolved phase (with delay for UX)
|
||||
const [showNextRound, setShowNextRound] = useState(false);
|
||||
useEffect(() => {
|
||||
if (isResolved) {
|
||||
const timer = setTimeout(() => setShowNextRound(true), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setShowNextRound(false);
|
||||
}, [isResolved, view.roundNumber]);
|
||||
|
||||
const handlePlaceBet = useCallback(() => onAction({ type: "place_bet" }), [onAction]);
|
||||
const handleHit = useCallback(() => onAction({ type: "hit" }), [onAction]);
|
||||
const handleStand = useCallback(() => onAction({ type: "stand" }), [onAction]);
|
||||
const handleSplit = useCallback(() => onAction({ type: "split" }), [onAction]);
|
||||
const handleDouble = useCallback(() => onAction({ type: "double_down" }), [onAction]);
|
||||
const handleLeave = useCallback(() => onAction({ type: "leave_table" }), [onAction]);
|
||||
const handleSit = useCallback(() => onAction({ type: "sit_down" }), [onAction]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 max-w-2xl mx-auto">
|
||||
{/* Dealer section */}
|
||||
<div className="rounded-xl bg-card/50 px-3 py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-label font-semibold text-text-tertiary">
|
||||
Dealer
|
||||
</span>
|
||||
{(view.dealerFullValue !== null || view.dealerVisibleValue) && (
|
||||
<ValueBadge
|
||||
value={view.dealerFullValue ?? view.dealerVisibleValue}
|
||||
status={view.dealerFullValue !== null && view.dealerFullValue > 21 ? "bust" : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{view.dealerHand.map((card, i) => (
|
||||
<PlayingCard
|
||||
key={`dealer-${card.suit}-${card.rank}-${i}`}
|
||||
card={card}
|
||||
faceDown={card.rank === "?"}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
{/* Player hands */}
|
||||
<div className="space-y-2">
|
||||
{view.turnOrder.map(pid => {
|
||||
const hand = view.hands[pid];
|
||||
if (!hand) return null;
|
||||
const isActive = view.activePlayerId === pid;
|
||||
const isMe = !isSpectator && pid === myPlayerId;
|
||||
return (
|
||||
<PlayerHandSection
|
||||
key={pid}
|
||||
hand={hand}
|
||||
playerName={getPlayerName(pid)}
|
||||
isActive={isActive}
|
||||
isMe={isMe}
|
||||
compact={compact}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action buttons (only for current player on their turn) */}
|
||||
{!isSpectator && !isResolved && playerView?.canAct && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col gap-3 w-full max-w-4xl mx-auto">
|
||||
{/* Leave table button */}
|
||||
{!isSpectator && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => onAction({ type: "hit" })}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-3 text-sm font-label font-semibold hover:opacity-90 transition-colors"
|
||||
onClick={handleLeave}
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium bg-white/5 text-white/50 hover:text-white/80 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Hand className="w-4 h-4" /> Hit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction({ type: "stand" })}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-raised text-foreground px-4 py-3 text-sm font-label font-semibold hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
Stand
|
||||
<LogOut className="w-3 h-3" /> Leave Table
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting for other player */}
|
||||
{!isSpectator && !isResolved && playerView && !playerView.canAct && (
|
||||
<div className="rounded-xl bg-card px-4 py-2.5 text-sm text-text-tertiary text-center">
|
||||
{view.activePlayerId
|
||||
? `Waiting for ${getPlayerName(view.activePlayerId)}...`
|
||||
: "Waiting..."}
|
||||
{/* Table felt */}
|
||||
<div className="relative bg-gradient-to-b from-emerald-800 to-emerald-900 rounded-2xl sm:rounded-[2rem] border-2 sm:border-4 border-emerald-950/60 shadow-[inset_0_0_60px_rgba(0,0,0,0.3)] overflow-hidden">
|
||||
|
||||
{/* ── Desktop layout (md+): absolute positioned seats ── */}
|
||||
<div className="hidden md:block relative" style={{ aspectRatio: "16/10" }}>
|
||||
{/* Dealer — top center */}
|
||||
<div className="absolute top-[6%] left-1/2 -translate-x-1/2 z-10">
|
||||
<DealerArea
|
||||
dealerHand={view.dealerHand}
|
||||
visibleValue={view.dealerVisibleValue}
|
||||
fullValue={view.dealerFullValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table text */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center pointer-events-none select-none">
|
||||
<div className="text-white/[0.06] font-display text-4xl font-bold tracking-widest uppercase">
|
||||
Blackjack
|
||||
</div>
|
||||
{isBetting && (
|
||||
<div className="text-white/20 text-xs mt-1">
|
||||
Round {view.roundNumber} — Place your bets
|
||||
</div>
|
||||
)}
|
||||
{isResolved && (
|
||||
<div className="text-white/20 text-xs mt-1">
|
||||
Round {view.roundNumber} — Results
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Seats in arc */}
|
||||
{seatSlots.map((slot, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2 z-10"
|
||||
style={{ left: SEAT_POSITIONS[i]!.left, top: SEAT_POSITIONS[i]!.top }}
|
||||
>
|
||||
{slot.playerId && slot.seat ? (
|
||||
<Seat
|
||||
seat={slot.seat}
|
||||
playerName={getPlayerName(slot.playerId)}
|
||||
isMe={slot.playerId === myPlayerId}
|
||||
isActivePlayer={view.activePlayerId === slot.playerId}
|
||||
activeHandIdx={view.activePlayerId === slot.playerId ? view.activeHandIndex : -1}
|
||||
betAmount={betAmount}
|
||||
/>
|
||||
) : (
|
||||
<EmptySeat canSit={canSitDown} onSit={handleSit} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Mobile layout (<md): vertical stack ── */}
|
||||
<div className="md:hidden flex flex-col gap-4 p-4">
|
||||
{/* Dealer */}
|
||||
<DealerArea
|
||||
dealerHand={view.dealerHand}
|
||||
visibleValue={view.dealerVisibleValue}
|
||||
fullValue={view.dealerFullValue}
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-white/10" />
|
||||
|
||||
{/* Phase label */}
|
||||
{isBetting && (
|
||||
<div className="text-center text-white/30 text-xs">
|
||||
Round {view.roundNumber} — Place your bets
|
||||
</div>
|
||||
)}
|
||||
{isResolved && (
|
||||
<div className="text-center text-white/30 text-xs">
|
||||
Round {view.roundNumber} — Results
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Player seats */}
|
||||
<div className="space-y-3">
|
||||
{seatSlots.map((slot, i) => {
|
||||
if (!slot.playerId || !slot.seat) {
|
||||
if (canSitDown && i === view.turnOrder.length) {
|
||||
return (
|
||||
<div key={i} className="flex justify-center">
|
||||
<EmptySeat canSit={true} onSit={handleSit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={slot.playerId}
|
||||
className={`rounded-xl px-3 py-2 transition-colors ${
|
||||
view.activePlayerId === slot.playerId
|
||||
? "bg-white/5 ring-1 ring-primary/20"
|
||||
: "bg-white/[0.02]"
|
||||
}`}
|
||||
>
|
||||
<Seat
|
||||
seat={slot.seat}
|
||||
playerName={getPlayerName(slot.playerId)}
|
||||
isMe={slot.playerId === myPlayerId}
|
||||
isActivePlayer={view.activePlayerId === slot.playerId}
|
||||
activeHandIdx={view.activePlayerId === slot.playerId ? view.activeHandIndex : -1}
|
||||
betAmount={betAmount}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Round result banner */}
|
||||
{isResolved && roundResult && (
|
||||
<RoundResultBanner
|
||||
roundNumber={view.roundNumber}
|
||||
roundResult={roundResult}
|
||||
myPlayerId={myPlayerId}
|
||||
betAmount={betAmount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Action bar ── */}
|
||||
{!isSpectator && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Betting phase: place bet button */}
|
||||
{isBetting && !myBetPlaced && (
|
||||
<button
|
||||
onClick={handlePlaceBet}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-3 text-sm font-label font-semibold hover:opacity-90 transition-colors"
|
||||
>
|
||||
<Coins className="w-4 h-4" />
|
||||
{betAmount > 0 ? `Place Bet (${betAmount} AU)` : "Ready Up"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Betting phase: waiting for others */}
|
||||
{isBetting && myBetPlaced && (
|
||||
<div className="rounded-xl bg-card px-4 py-2.5 text-sm text-text-tertiary text-center">
|
||||
Waiting for other players to bet...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playing phase: action buttons */}
|
||||
{isPlaying && playerView?.canAct && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleHit}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-3 text-sm font-label font-semibold hover:opacity-90 transition-colors"
|
||||
>
|
||||
<Hand className="w-4 h-4" /> Hit
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStand}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-raised text-foreground px-4 py-3 text-sm font-label font-semibold hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" /> Stand
|
||||
</button>
|
||||
{playerView.canSplit && (
|
||||
<button
|
||||
onClick={handleSplit}
|
||||
className="flex items-center justify-center gap-2 rounded-xl bg-raised text-foreground px-4 py-3 text-sm font-label font-semibold hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4" /> Split
|
||||
</button>
|
||||
)}
|
||||
{playerView.canDoubleDown && (
|
||||
<button
|
||||
onClick={handleDouble}
|
||||
className="flex items-center justify-center gap-2 rounded-xl bg-raised text-foreground px-4 py-3 text-sm font-label font-semibold hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
<ChevronsUp className="w-4 h-4" /> Double
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playing phase: waiting for other player */}
|
||||
{isPlaying && playerView && !playerView.canAct && (
|
||||
<div className="rounded-xl bg-card px-4 py-2.5 text-sm text-text-tertiary text-center">
|
||||
{view.activePlayerId
|
||||
? `Waiting for ${getPlayerName(view.activePlayerId)}...`
|
||||
: "Waiting..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolved phase: next round button */}
|
||||
{isResolved && showNextRound && (
|
||||
<button
|
||||
onClick={handlePlaceBet}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-primary text-on-primary px-4 py-3 text-sm font-label font-semibold hover:opacity-90 transition-colors"
|
||||
>
|
||||
<Coins className="w-4 h-4" />
|
||||
{betAmount > 0 ? `Next Round (${betAmount} AU)` : "Next Round"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Resolved phase: waiting for countdown */}
|
||||
{isResolved && !showNextRound && (
|
||||
<div className="rounded-xl bg-card px-4 py-2.5 text-sm text-text-tertiary text-center">
|
||||
Round complete — next round starting soon...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spectator indicator */}
|
||||
{isSpectator && !isResolved && (
|
||||
{isSpectator && !canSitDown && (
|
||||
<div className="rounded-xl bg-card px-4 py-2.5 text-xs text-text-tertiary text-center">
|
||||
{view.activePlayerId
|
||||
{isPlaying && view.activePlayerId
|
||||
? `${getPlayerName(view.activePlayerId)}'s turn`
|
||||
: "Watching..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spectator sit-down prompt */}
|
||||
{canSitDown && (
|
||||
<button
|
||||
onClick={handleSit}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-primary/10 text-primary px-4 py-3 text-sm font-label font-semibold hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Sit Down & Join
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface GameUIProps {
|
||||
isSpectator: boolean;
|
||||
onAction: (action: unknown) => void;
|
||||
players: { discordId: string; username: string }[];
|
||||
roundResult?: { payouts: Record<string, { net: number }> } | null;
|
||||
roomOptions?: { betAmount?: number };
|
||||
}
|
||||
|
||||
export interface GameUIPlugin {
|
||||
|
||||
@@ -7,6 +7,10 @@ interface PlayerInfo {
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface RoundResult {
|
||||
payouts: Record<string, { net: number }>;
|
||||
}
|
||||
|
||||
interface GameRoomState {
|
||||
gameState: unknown;
|
||||
players: PlayerInfo[];
|
||||
@@ -14,6 +18,7 @@ interface GameRoomState {
|
||||
roomStatus: "connecting" | "waiting" | "playing" | "finished" | "not_found";
|
||||
isSpectator: boolean;
|
||||
gameOver: { winner: string | null; reason: string; payout?: { amount: number; refunded?: boolean } } | null;
|
||||
roundResult: RoundResult | null;
|
||||
error: string | null;
|
||||
sessionReplaced: boolean;
|
||||
roomOptions: { betAmount?: number };
|
||||
@@ -36,6 +41,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
|
||||
roomStatus: "connecting",
|
||||
isSpectator: false,
|
||||
gameOver: null,
|
||||
roundResult: null,
|
||||
error: null,
|
||||
sessionReplaced: false,
|
||||
roomOptions: {},
|
||||
@@ -64,11 +70,17 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
|
||||
|
||||
case "GAME_STATE":
|
||||
// Authoritative player view — sent directly to this player
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
gameState: msg.state,
|
||||
roomStatus: prev.roomStatus === "finished" ? "finished" : "playing",
|
||||
}));
|
||||
setState(prev => {
|
||||
// Clear round result when a new betting phase starts
|
||||
const phase = (msg.state as any)?.phase;
|
||||
const roundResult = phase === "betting" ? null : prev.roundResult;
|
||||
return {
|
||||
...prev,
|
||||
gameState: msg.state,
|
||||
roundResult,
|
||||
roomStatus: prev.roomStatus === "finished" ? "finished" : "playing",
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case "GAME_STARTED":
|
||||
@@ -116,6 +128,13 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
|
||||
}));
|
||||
break;
|
||||
|
||||
case "ROUND_SETTLED":
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
roundResult: { payouts: msg.payouts },
|
||||
}));
|
||||
break;
|
||||
|
||||
case "GAME_ENDED":
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
|
||||
Reference in New Issue
Block a user