Seat commit pill renders in the panel row (was painting over the bankroll on poker-shared absolute offsets), cardless hand reveals no longer render, corner-seat reveals shift outward clear of the dealer block, the action log tops out below the time bar, and on 640px-and-narrower screens the toolbar renders below the felt in flow instead of clipped inside it. Ships the v0.2.38 changelog entry. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
"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<BlackjackSnapshot["you"]>;
|
|
};
|
|
|
|
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<number | null>(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 (
|
|
<div className="poker-table-stage">
|
|
<div
|
|
className={cn(
|
|
"poker-felt-table blackjack-felt relative aspect-[4/3] overflow-hidden p-4",
|
|
fullscreen ? "blackjack-felt-fullscreen" : "min-h-[40rem] w-full max-w-[56rem]",
|
|
)}
|
|
>
|
|
{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-44 max-w-[34%] sm:w-60",
|
|
positionClass,
|
|
)}
|
|
>
|
|
<BlackjackSeat
|
|
player={player}
|
|
isCurrentUser={false}
|
|
isActing={liveTurn && player.seatId === view.activeSeat}
|
|
isWinner={winnerSeats.has(player.seatId)}
|
|
isSuperuser={superuserIds?.includes(Number(snapshot.seats[player.seatId])) ?? false}
|
|
/>
|
|
{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) => (
|
|
<BlackjackPopupBubble key={popup.key} popup={popup} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<div className="poker-table-center absolute left-1/2 top-[26%] z-10 flex w-[min(26rem,72%)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-2 text-white">
|
|
<div className="flex items-start gap-3" aria-label="Dealer hand">
|
|
<BlackjackShoeOrigin />
|
|
<div className="flex flex-col items-center gap-1">
|
|
<div className="flex flex-wrap justify-center gap-1">
|
|
{view.dealer.cards.length > 0 ? (
|
|
view.dealer.cards.map((card, index) => (
|
|
<CardView
|
|
key={`dealer-${cardKey(card)}-${index}`}
|
|
card={card}
|
|
faceUp
|
|
size="md"
|
|
dealIndex={index + 1}
|
|
/>
|
|
))
|
|
) : (
|
|
<span className="font-mono text-sm uppercase tracking-[0.2em] text-white/60">
|
|
Dealer
|
|
</span>
|
|
)}
|
|
{view.dealer.holeHidden && view.dealer.cards.length > 0 && (
|
|
<CardView card={null} faceUp={false} size="md" />
|
|
)}
|
|
</div>
|
|
{view.dealer.total !== null && (
|
|
<Badge variant="outline" className="font-mono text-sm tabular-nums">
|
|
Dealer {view.dealer.total}
|
|
</Badge>
|
|
)}
|
|
{view.dealer.holeHidden && view.dealer.cards.length > 0 && (
|
|
<span className="font-mono text-sm uppercase tracking-[0.2em] text-white/60">
|
|
Dealer showing{" "}
|
|
{view.dealer.cards[0]?.rank === 14 ? "ace" : (view.dealer.cards[0]?.rank ?? "")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="poker-pot-plaque">
|
|
<span className="poker-console-kicker">Round</span>
|
|
<strong className="font-mono text-lg tabular-nums">{view.roundNumber}</strong>
|
|
<span className="poker-console-kicker">
|
|
Bets {view.config.minBet.toLocaleString()}-{view.config.maxBet.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
{liveTurn && (
|
|
<div className="poker-table-turn-clock">
|
|
<BlackjackClock
|
|
deadline={snapshot.turnDeadline}
|
|
totalMs={view.config.turnTimeoutMs}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{view.phase !== "round-complete" && view.yourHands.length > 0 && (
|
|
<div className="poker-viewer-cards" aria-label="Your hands">
|
|
{view.yourHands.map((hand, handIndex) => {
|
|
const statusLabel =
|
|
hand.doubled && hand.status === "active"
|
|
? " · Doubled"
|
|
: hand.status === "bust"
|
|
? " · Bust"
|
|
: hand.status === "blackjack"
|
|
? " · Blackjack!"
|
|
: "";
|
|
return (
|
|
<div
|
|
key={`felt-hand-${handIndex}`}
|
|
className={cn(
|
|
"flex flex-col items-center gap-1",
|
|
hand.status === "bust" && "opacity-45",
|
|
)}
|
|
>
|
|
<div className="flex">
|
|
{hand.cards.map((card, cardIndex) => (
|
|
<CardView
|
|
key={`felt-hand-${handIndex}-${cardIndex}`}
|
|
card={card}
|
|
size="md"
|
|
dealIndex={cardIndex + 1}
|
|
className={cn(
|
|
cardIndex === 0 ? "poker-card-viewer-left" : "poker-card-viewer-right",
|
|
view.activeHandIndex !== handIndex &&
|
|
hand.status === "active" &&
|
|
"opacity-60",
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
<Badge variant="outline" className="font-mono text-sm tabular-nums">
|
|
{handValue(hand.cards).total}
|
|
{statusLabel}
|
|
</Badge>
|
|
<BlackjackChipStack amount={hand.bet} className="pointer-events-auto" />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{actionLog.length > 0 && (
|
|
<div className="poker-action-log" aria-label="Recent actions" aria-live="polite">
|
|
{actionLog.map((entry) => (
|
|
<p key={entry.key} className={`poker-action-log-line poker-log-${entry.tone}`}>
|
|
{entry.text}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!spectator && !toolbarBelow && (
|
|
<BlackjackActionToolbar
|
|
snapshot={snapshot}
|
|
onSnapshot={onSnapshot}
|
|
onActionFeedback={onActionFeedback}
|
|
/>
|
|
)}
|
|
|
|
{chipFlights.map((flight) => (
|
|
<div
|
|
key={flight.key}
|
|
className={cn("bj-chip-flight", flight.direction === "loss" && "bj-chip-flight-out")}
|
|
aria-hidden="true"
|
|
>
|
|
{flight.chips.map((chip, chipIndex) => (
|
|
<span
|
|
key={chip.key}
|
|
className={cn("bj-chip", chip.tone)}
|
|
style={{ animationDelay: `${chipIndex * 130}ms` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
|
|
{viewerPopups.length > 0 && (
|
|
<div className="poker-popup-stack poker-popup-viewer-spot" aria-hidden="true">
|
|
{viewerPopups.map((popup) => (
|
|
<BlackjackPopupBubble 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) => (
|
|
<BlackjackPopupBubble key={popup.key} popup={popup} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{view.phase === "round-complete" && result && (
|
|
<div
|
|
className="absolute left-1/2 top-1/2 z-30 flex max-w-[80%] -translate-x-1/2 -translate-y-1/2 justify-center"
|
|
aria-live="polite"
|
|
>
|
|
<div className="flex max-w-[90%] flex-col items-center gap-1.5 rounded border border-white/15 bg-black/55 px-5 py-2.5 backdrop-blur-sm">
|
|
<p className="font-mono text-sm uppercase tracking-wider text-white/80">
|
|
{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}`}
|
|
</p>
|
|
{result.hands.length > 0 && (
|
|
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-1 text-sm">
|
|
{result.hands.map((hand) => {
|
|
const player = view.players.find((item) => item.seatId === hand.seatId);
|
|
return (
|
|
<span
|
|
key={`${hand.seatId}-${hand.handIndex}`}
|
|
className="flex items-center gap-1.5"
|
|
>
|
|
{hand.netChange > 0 && <BlackjackChipStack amount={hand.netChange} />}
|
|
<span className="text-white/90">
|
|
{player?.name ?? `Seat ${hand.seatId + 1}`}
|
|
</span>
|
|
<span className="font-mono tabular-nums text-white/60">{hand.total}</span>
|
|
<span
|
|
className={cn(
|
|
"font-mono text-[0.95rem] tabular-nums",
|
|
hand.netChange > 0 && "text-emerald-400",
|
|
hand.netChange < 0 && "text-rose-400",
|
|
hand.netChange === 0 && "text-white/50",
|
|
)}
|
|
>
|
|
{hand.outcome === "blackjack" ? "BJ " : ""}
|
|
{signedAmount(hand.netChange)}
|
|
</span>
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{snapshot.nextRoundAt && (
|
|
<BlackjackClock deadline={snapshot.nextRoundAt} mode="text" label="Next round in" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{viewerPlayer === undefined && onJoin && (
|
|
<div className="absolute inset-x-0 bottom-24 z-20 flex justify-center">
|
|
<button type="button" className="poker-chip poker-chip-call" onClick={onJoin}>
|
|
Take a seat
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!spectator && toolbarBelow && (
|
|
<BlackjackActionToolbar
|
|
snapshot={snapshot}
|
|
onSnapshot={onSnapshot}
|
|
onActionFeedback={onActionFeedback}
|
|
below
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|