"use client"; import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { handValue, type Card, type PublicPlayerView } from "@/lib/minigames/blackjack"; import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions"; import { Badge } from "@/components/ui/badge"; import { CardView } from "./CardView"; import { BlackjackActionToolbar } from "./BlackjackActionToolbar"; import { BlackjackChipStack, chipFlightChips, type FlightChip } from "./BlackjackChipStack"; import { BlackjackClock } from "./BlackjackClock"; import { BlackjackShoeOrigin } from "./BlackjackShoeOrigin"; import { BlackjackPopupBubble } from "./BlackjackPopups"; import type { BlackjackActionLogEntry } from "./blackjackPopups"; import { BlackjackSeat } from "./BlackjackSeat"; import type { ActiveBlackjackPopup } from "./useBlackjackPopups"; import { isTopSeatPosition, seatPosition } from "./seatPosition"; export type SeatedSnapshot = BlackjackSnapshot & { readonly you: NonNullable; }; interface BlackjackFeltCanvasProps { readonly snapshot: SeatedSnapshot; readonly onSnapshot: (snapshot: BlackjackSnapshot) => void; readonly onActionFeedback: () => void; readonly popups: readonly ActiveBlackjackPopup[]; readonly actionLog: readonly BlackjackActionLogEntry[]; readonly spectator?: boolean; readonly onJoin?: () => void; readonly superuserIds?: readonly number[]; readonly fullscreen?: boolean; } function cardKey(card: Card): string { return `${card.rank}-${card.suit}`; } function signedAmount(value: number): string { return `${value >= 0 ? "+" : ""}${value.toLocaleString()}`; } export function BlackjackFeltCanvas({ snapshot, onSnapshot, onActionFeedback, popups, actionLog, spectator = false, onJoin, superuserIds, fullscreen = false, }: BlackjackFeltCanvasProps) { const view = snapshot.you; const result = view.roundResult; const viewerPlayer = view.players.find((player) => player.seatId === view.viewerSeat); const winnerSeats = new Set( (result?.hands ?? []).filter((hand) => hand.netChange > 0).map((hand) => hand.seatId), ); const opponents = view.players.filter((player) => player.seatId !== view.viewerSeat); const popupsForSeat = (seatId: number): readonly ActiveBlackjackPopup[] => popups.filter((popup) => popup.seatId === seatId && popup.tone !== "leave"); const viewerPopups = popupsForSeat(view.viewerSeat); const tableAnnouncements = popups.filter((popup) => popup.tone === "leave"); const liveTurn = view.phase !== "round-complete"; // Narrow screens render the action toolbar below the felt in flow: the // overlay pill row is wider than the felt there and the felt clips it. const [toolbarBelow, setToolbarBelow] = useState(false); useEffect(() => { const query = window.matchMedia("(max-width: 640px)"); const update = () => setToolbarBelow(query.matches); update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); // Chip flight: on the settle transition, chips launch one after another // from the hand spot with denomination colors matching the money moved: // payouts (and pushes) toward the bankroll pill, lost bets up to the // dealer. A mixed round flies both directions at once. const previousSettleRoundRef = useRef(null); const [chipFlights, setChipFlights] = useState< { key: number; direction: "win" | "loss"; chips: FlightChip[] }[] >([]); useEffect(() => { if (view.phase !== "round-complete" || !view.roundResult) { previousSettleRoundRef.current = null; return; } if (previousSettleRoundRef.current === view.roundNumber) return; previousSettleRoundRef.current = view.roundNumber; // Result rows carry payouts but not the stake; the hands on the players // view still hold each hand's bet at settle time. const handsBySeat = new Map(view.players.map((player) => [player.seatId, player.hands])); let incoming = 0; let outgoing = 0; let touched = 0; for (const hand of view.roundResult.hands) { if (hand.seatId !== view.viewerSeat) continue; touched += 1; const bet = handsBySeat.get(hand.seatId)?.[hand.handIndex]?.bet ?? 0; if (hand.netChange > 0) incoming += bet + hand.netChange; else if (hand.netChange < 0) outgoing += bet; else incoming += bet; } for (const insurance of view.roundResult.insurance) { if (insurance.seatId !== view.viewerSeat) continue; if (insurance.won) incoming += insurance.amount * 3; else outgoing += insurance.amount; } if (touched === 0) return; const flights: { key: number; direction: "win" | "loss"; chips: FlightChip[] }[] = []; if (incoming > 0) { flights.push({ key: Date.now(), direction: "win", chips: chipFlightChips(incoming) }); } if (outgoing > 0) { flights.push({ key: Date.now() + 1, direction: "loss", chips: chipFlightChips(outgoing) }); } if (flights.length === 0) return; setChipFlights(flights); const timer = setTimeout(() => setChipFlights([]), 2400); return () => clearTimeout(timer); }, [view.phase, view.roundNumber, view.roundResult, view.viewerSeat]); return (
{opponents.map((player: PublicPlayerView, opponentIndex) => { const positionClass = seatPosition(opponentIndex, opponents.length); return (
{popupsForSeat(player.seatId).length > 0 && ( )}
); })}
{view.dealer.cards.length > 0 ? ( view.dealer.cards.map((card, index) => ( )) ) : ( Dealer )} {view.dealer.holeHidden && view.dealer.cards.length > 0 && ( )}
{view.dealer.total !== null && ( Dealer {view.dealer.total} )} {view.dealer.holeHidden && view.dealer.cards.length > 0 && ( Dealer showing{" "} {view.dealer.cards[0]?.rank === 14 ? "ace" : (view.dealer.cards[0]?.rank ?? "")} )}
Round {view.roundNumber} Bets {view.config.minBet.toLocaleString()}-{view.config.maxBet.toLocaleString()}
{liveTurn && (
)}
{view.phase !== "round-complete" && view.yourHands.length > 0 && (
{view.yourHands.map((hand, handIndex) => { const statusLabel = hand.doubled && hand.status === "active" ? " · Doubled" : hand.status === "bust" ? " · Bust" : hand.status === "blackjack" ? " · Blackjack!" : ""; return (
{hand.cards.map((card, cardIndex) => ( ))}
{handValue(hand.cards).total} {statusLabel}
); })}
)} {actionLog.length > 0 && (
{actionLog.map((entry) => (

{entry.text}

))}
)} {!spectator && !toolbarBelow && ( )} {chipFlights.map((flight) => ( ))} {viewerPopups.length > 0 && ( )} {tableAnnouncements.length > 0 && (
{tableAnnouncements.map((popup) => ( ))}
)} {view.phase === "round-complete" && result && (

{result.hands.length === 0 && view.dealer.cards.length === 0 ? "No bets this round" : result.dealerHasBlackjack ? "Dealer blackjack" : result.dealerBusted ? `Dealer busts at ${result.dealerTotal}` : `Dealer stands at ${result.dealerTotal}`}

{result.hands.length > 0 && (
{result.hands.map((hand) => { const player = view.players.find((item) => item.seatId === hand.seatId); return ( {hand.netChange > 0 && } {player?.name ?? `Seat ${hand.seatId + 1}`} {hand.total} 0 && "text-emerald-400", hand.netChange < 0 && "text-rose-400", hand.netChange === 0 && "text-white/50", )} > {hand.outcome === "blackjack" ? "BJ " : ""} {signedAmount(hand.netChange)} ); })}
)} {snapshot.nextRoundAt && ( )}
)} {viewerPlayer === undefined && onJoin && (
)}
{!spectator && toolbarBelow && ( )}
); }