1
0
Fork 0
polaris-task-force/src/components/frontend/minigames/blackjack/BlackjackFeltCanvas.tsx
Z8MB1E c4b19bf8e5 fix(blackjack): multiplayer seat overlaps, log above time bar, mobile toolbar clipping
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>
2026-09-14 19:17:26 -04:00

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>
);
}