Files
HokmPlay/src/components/GameTable.tsx
T
soroush.asadi 2aac6257d6
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 1m1s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m12s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m7s
fix(online): rotate server state to viewer's seat — non-seat-0 players can play
The server is authoritative with ABSOLUTE seats and tells each client its own
seat via mySeat, but the client copied seats verbatim — so any player not at
absolute seat 0 had their hand at players[mySeat] while the table read players[0]
and "your turn" checked turn===0. Result: they couldn't play (server auto-played
after the timeout → "hang"), and the turn highlight was identical for everyone
instead of rotating per viewer.

Fix (client-only; server was correct): new viewerRot(mySeat) rotates every
seat-indexed value into the viewer's frame (viewer → local seat 0): players/hands,
turn, hakem, leadSeat, lastTrickWinner, currentTrick, hakemDraw, seat roster,
disconnectedSeat, and the team arrays (matchScore/roundTricks/lastRoundResult/
matchWinner — odd seats swap team order). Store mySeat and rotate reaction bubbles
too (they carried absolute seats).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 08:59:03 +03:30

1109 lines
40 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, Zap } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useGameStore, viewerRot } from "@/lib/game-store";
import { useSoundStore } from "@/lib/sound-store";
import { Avatar } from "@/components/online/Avatar";
import { legalMoves } from "@/lib/hokm/engine";
import { sortHand } from "@/lib/hokm/deck";
import {
Card,
Seat,
Suit,
SUITS,
SUIT_IS_RED,
SUIT_SYMBOL,
teamOf,
} from "@/lib/hokm/types";
import { useI18n } from "@/lib/i18n";
import { useSessionStore } from "@/lib/session-store";
import { cardBackById, cardFrontById, ownedReactions, ownedStickers, titleById, turnMsForStake } from "@/lib/online/gamification";
import { getService } from "@/lib/online/service";
import { cn } from "@/lib/cn";
import { PlayingCard } from "./PlayingCard";
import { Sticker } from "./online/Sticker";
import { MatchPlayersList } from "./online/MatchPlayersList";
function useCountdown(deadline: number | null) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (deadline == null) return;
const id = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(id);
}, [deadline]);
if (deadline == null) return null;
return Math.max(0, Math.ceil((deadline - now) / 1000));
}
function useCardSkins() {
const frontId = useSessionStore((s) => s.profile?.cardFront ?? "classic");
const backId = useSessionStore((s) => s.profile?.cardBack ?? "classic");
const f = cardFrontById(frontId);
const b = cardBackById(backId);
return {
front: { bg1: f.bg1, bg2: f.bg2, border: f.border },
back: { c1: b.c1, c2: b.c2, accent: b.accent, pattern: b.pattern, motif: b.motif },
};
}
export function GameTable({
onExit,
onForfeit,
}: { onExit?: () => void; onForfeit?: () => void } = {}) {
const game = useGameStore((s) => s.game);
const reset = useGameStore((s) => s.reset);
const mode = useGameStore((s) => s.mode);
const { t } = useI18n();
const [askFf, setAskFf] = useState(false);
const sfx = useSoundStore((s) => s.sfx);
const music = useSoundStore((s) => s.music);
const toggleAll = useSoundStore((s) => s.toggleAll);
const muted = !sfx && !music;
const exit = onExit ?? reset;
const vw = useViewportWidth();
// Pull the played-card pile inward on narrow screens so it clears the side stacks.
const trickScale = vw < 400 ? 0.82 : 1;
// Smaller played cards on phones so the center pile stays clear of the side seats.
const trickCardSize: "sm" | "md" = vw < 480 ? "sm" : "md";
const { phase, players, hakem, trump, turn, currentTrick } = game;
const legalMovesList = useMemo(
() => (phase === "playing" && turn === 0 ? legalMoves(game, 0) : []),
[phase, turn, game]
);
const legalIds = new Set(legalMovesList.map((c) => c.id));
// Keyboard shortcuts (desktop): 19 / 0 play the Nth playable card in hand
// order, Space/Enter play the first playable card, M mutes, F forfeits,
// Esc/Q quits. A floating hint lists them.
const playHuman = useGameStore((s) => s.playHuman);
const chooseTrump = useGameStore((s) => s.chooseTrump);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement | null;
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
const k = e.key.toLowerCase();
// Hakem choosing trump: 14 pick a suit.
if (phase === "choosing-trump" && players[hakem!]?.isHuman) {
const idx = "1234".indexOf(e.key);
if (idx >= 0) { e.preventDefault(); chooseTrump(SUITS[idx]); return; }
}
if (phase === "playing" && turn === 0) {
const playable = sortHand(game.players[0].hand).filter((c) => legalIds.has(c.id));
if (k === " " || k === "enter") {
if (playable[0]) { e.preventDefault(); playHuman(playable[0]); }
return;
}
// 1-9 then 0 → 10th
const digit = e.key === "0" ? 9 : "123456789".indexOf(e.key);
if (digit >= 0 && playable[digit]) { e.preventDefault(); playHuman(playable[digit]); return; }
}
if (k === "m") { e.preventDefault(); toggleAll(); }
else if (k === "f" && onForfeit) { e.preventDefault(); setAskFf(true); }
else if (k === "escape" || k === "q") { e.preventDefault(); exit(); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, turn, hakem, game.players, legalMovesList]);
return (
<main className="persian-pattern relative h-dvh w-full overflow-hidden">
{/* Top HUD */}
<div className="absolute top-0 inset-x-0 z-30 flex items-start justify-between gap-2 safe-top safe-x pb-3 sm:p-4">
<Scoreboard />
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
<SpeedBadge />
{trump && <TrumpBadge trump={trump} />}
<button
onClick={toggleAll}
className="glass rounded-full min-h-10 min-w-10 sm:min-h-11 sm:min-w-11 grid place-items-center hover:bg-navy-800 transition"
title={t("settings.audio")}
>
{muted ? (
<VolumeX className="size-4 text-cream/60" />
) : (
<Volume2 className="size-4 text-gold-400" />
)}
</button>
{onForfeit && (
<button
onClick={() => setAskFf(true)}
className="glass rounded-full min-h-10 min-w-10 sm:min-h-11 sm:min-w-11 grid place-items-center hover:bg-navy-800 transition"
title={t("forfeit.title")}
>
<Flag className="size-4 text-rose-300/90" />
</button>
)}
<button
onClick={exit}
className="glass rounded-full min-h-10 min-w-10 sm:min-h-11 sm:min-w-11 grid place-items-center hover:bg-navy-800 transition"
title={t("hud.quit")}
>
<LogOut className="size-4 text-cream/80" />
</button>
</div>
</div>
{/* forfeit confirm (requester) */}
<AnimatePresence>
{askFf && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[70] flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
>
<div className="glass rounded-3xl p-6 w-full max-w-xs text-center">
<div className="text-4xl mb-2">🏳</div>
<h2 className="gold-text text-xl font-black">{t("forfeit.title")}</h2>
<p className="text-cream/70 text-sm mt-2">{t("forfeit.ask")}</p>
<p className="text-cream/45 text-xs mt-1">{t("forfeit.rule")}</p>
<div className="flex gap-2 mt-5">
<button
onClick={() => {
setAskFf(false);
onForfeit?.();
}}
className="flex-1 rounded-xl py-3 bg-rose-500/80 text-white font-bold"
>
{t("forfeit.confirm")}
</button>
<button onClick={() => setAskFf(false)} className="flex-1 btn-gold rounded-xl py-3">
{t("forfeit.keepPlaying")}
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Felt table — portrait proportions (tall, centered between HUD and hand) */}
<div className="absolute inset-x-0 top-16 bottom-44 flex items-center justify-center p-3">
{/* max-w-full so the felt never exceeds its padded container — otherwise an
overflowing flex item under justify-center pins to the (RTL) start and
the trick area, centered on the felt, drifts off-center. */}
<div className="felt relative w-[min(96vw,560px)] max-w-full h-full max-h-[680px] rounded-[44%]">
{/* opponent + partner seats */}
<SeatAvatar seat={2} className="absolute top-3 left-1/2 -translate-x-1/2" />
<SeatAvatar seat={1} className="absolute top-1/2 right-3 -translate-y-1/2" />
<SeatAvatar seat={3} className="absolute top-1/2 left-3 -translate-y-1/2" />
{/* opponents' face-down hands */}
<OpponentHand seat={2} className="absolute top-16 sm:top-20 left-1/2 -translate-x-1/2" horizontal />
<OpponentHand seat={1} className="absolute top-1/2 right-14 sm:right-16 -translate-y-1/2" />
<OpponentHand seat={3} className="absolute top-1/2 left-14 sm:left-16 -translate-y-1/2" />
{/* center trick area (offsets scale down on narrow screens) */}
<TrickArea trick={currentTrick} winner={game.lastTrickWinner} phase={phase} scale={trickScale} cardSize={trickCardSize} />
</div>
</div>
{/* Your hand */}
<PlayerHand legalIds={legalIds} />
{/* Turn indicator + timer — stacked so they never overlap */}
<div className="absolute bottom-[140px] sm:bottom-[172px] left-1/2 -translate-x-1/2 z-30 flex flex-col items-center gap-2.5 pointer-events-none">
<TurnTimer />
<TurnIndicator />
</div>
<Reactions />
<ShortcutsHint />
{/* Overlays */}
<AnimatePresence>
{phase === "selecting-hakem" && <HakemOverlay key="hakem" />}
{phase === "choosing-trump" && players[hakem!]?.isHuman && (
<TrumpChooser key="trump" />
)}
{phase === "round-over" && <RoundOverlay key="round" />}
{phase === "match-over" && mode === "ai" && (
<MatchOverlay key="match" onExit={exit} />
)}
</AnimatePresence>
</main>
);
}
/* ----------------------------- Scoreboard ----------------------------- */
function Scoreboard() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
return (
<div className="glass rounded-xl px-2.5 py-1 flex items-center gap-2 shrink min-w-0">
<ScoreCol label={t("team.us")} tricks={game.roundTricks[0]} score={game.matchScore[0]} accent="text-teal-300" />
<span className="text-cream/25 text-xs shrink-0">·</span>
<ScoreCol label={t("team.them")} tricks={game.roundTricks[1]} score={game.matchScore[1]} accent="text-rose-300" />
<span className="ltr:border-l rtl:border-r border-gold-500/15 ltr:pl-2 rtl:pr-2 text-[9px] text-gold-300/70 leading-none text-center shrink-0">
{game.targetScore}
</span>
</div>
);
}
function ScoreCol({
label,
tricks,
score,
accent,
}: {
label: string;
tricks: number;
score: number;
accent: string;
}) {
return (
<div className="text-center shrink-0 leading-none">
<div className={cn("text-[9px] font-semibold", accent)}>{label}</div>
<div className="text-sm font-black leading-tight mt-0.5">
{score}
<span className="text-[9px] font-normal text-cream/40 ltr:ml-0.5 rtl:mr-0.5">({tricks})</span>
</div>
</div>
);
}
/* ----------------------------- Speed badge ---------------------------- */
function SpeedBadge() {
const speed = useGameStore((s) => s.matchMeta.speed);
const { t } = useI18n();
if (!speed) return null;
return (
<motion.div
initial={{ scale: 0, rotate: -15 }}
animate={{ scale: 1, rotate: 0 }}
className="glass rounded-2xl px-2 py-1.5 sm:px-2.5 sm:py-2 flex items-center gap-1 text-gold-300 shrink-0"
title={t("speed.label")}
>
<Zap className="size-4 fill-gold-400 text-gold-400" />
<span className="hidden sm:inline text-[10px] font-black uppercase tracking-wide">{t("speed.label")}</span>
</motion.div>
);
}
/* ----------------------------- Trump badge ---------------------------- */
function TrumpBadge({ trump }: { trump: Suit }) {
const { t } = useI18n();
const red = SUIT_IS_RED[trump];
return (
<motion.div
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
className="glass rounded-2xl px-2 py-1.5 sm:px-3 sm:py-2 flex items-center gap-1.5 sm:gap-2 shrink-0"
>
<span className="hidden sm:inline text-[10px] text-gold-400 font-semibold">
{t("trump.label")}
</span>
<span
className={cn(
"text-xl sm:text-2xl leading-none",
red ? "text-rose-400" : "text-cream"
)}
>
{SUIT_SYMBOL[trump]}
</span>
</motion.div>
);
}
/* ----------------------------- Seat avatar ---------------------------- */
function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
const game = useGameStore((s) => s.game);
const sp = useGameStore((s) => s.seatPlayers[seat]);
const { locale } = useI18n();
const player = game.players[seat];
const active =
(game.phase === "playing" && game.turn === seat) ||
(game.phase === "choosing-trump" && game.hakem === seat);
const isHakem = game.hakem === seat;
const team = teamOf(seat);
const name = sp?.name ?? player.name;
const titleDef = titleById(sp?.title);
const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null;
return (
<div className={cn("z-20 flex flex-col items-center gap-1", className)}>
<div
className={cn(
"relative size-12 sm:size-14 rounded-full flex items-center justify-center font-bold text-xl sm:text-2xl transition-all",
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100",
active && "active-player-ring"
)}
style={!active ? { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" } : undefined}
>
{sp?.avatarId || sp?.avatarImage ? (
<Avatar id={sp.avatarId ?? "a-fox"} image={sp.avatarImage} size={44} />
) : (
sp?.avatar ?? name.charAt(0)
)}
{isHakem && (
<Crown className="absolute -top-4 size-5 text-gold-400 fill-gold-500 drop-shadow" />
)}
{active && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 size-2.5 rounded-full bg-gold-400 ring-2 ring-navy-900" />
)}
</div>
<span className="text-[11px] font-semibold text-cream max-w-20 truncate hud-shadow">{name}</span>
{titleName && (
<span className="text-[9px] font-bold gold-text leading-none max-w-24 truncate hud-shadow">{titleName}</span>
)}
{sp && sp.level > 0 && (
<span className="text-[10px] text-gold-300/80 leading-none hud-shadow">{`Lv ${sp.level}`}</span>
)}
</div>
);
}
/* --------------------------- Opponent hands --------------------------- */
function OpponentHand({
seat,
className,
horizontal,
}: {
seat: Seat;
className?: string;
horizontal?: boolean;
}) {
const count = useGameStore((s) => s.game.players[seat].hand.length);
const { back } = useCardSkins();
const cards = Array.from({ length: count });
const mid = (count - 1) / 2;
return (
<div className={cn("flex", horizontal ? "flex-row items-end" : "flex-col items-center", className)}>
{cards.map((_, i) => {
const rot = horizontal ? (i - mid) * 4 : 0;
return (
<div
key={i}
style={{
...(horizontal
? { marginInlineStart: i === 0 ? 0 : -30 }
: { marginTop: i === 0 ? 0 : -46 }),
transform: rot ? `rotate(${rot}deg)` : undefined,
transformOrigin: "bottom center",
}}
>
<PlayingCard faceDown size="sm" back={back} />
</div>
);
})}
</div>
);
}
/* ----------------------------- Trick area ----------------------------- */
// Compact, centered cross — small magnitudes keep the played pile in the middle of
// the felt (clear of the side seats/stacks). Each card still nudges toward its player.
const TRICK_OFFSET: Record<Seat, { x: number; y: number }> = {
0: { x: 0, y: 50 },
1: { x: 48, y: 0 },
2: { x: 0, y: -50 },
3: { x: -48, y: 0 },
};
const TRICK_ENTER: Record<Seat, { x: number; y: number }> = {
0: { x: 0, y: 260 },
1: { x: 360, y: 0 },
2: { x: 0, y: -260 },
3: { x: -360, y: 0 },
};
function TrickArea({
trick,
winner,
phase,
scale = 1,
cardSize = "md",
}: {
trick: { seat: Seat; card: Card }[];
winner: Seat | null;
phase: string;
scale?: number;
cardSize?: "sm" | "md" | "lg";
}) {
const { front } = useCardSkins();
return (
<div className="absolute inset-0">
<AnimatePresence>
{trick.map((pc) => {
const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale };
const enter = TRICK_ENTER[pc.seat];
const isWinner = phase === "trick-complete" && winner === pc.seat;
return (
<motion.div
key={pc.card.id}
initial={{ x: enter.x, y: enter.y, opacity: 0, scale: 0.7 }}
animate={{ x: off.x, y: off.y, opacity: 1, scale: isWinner ? 1.14 : 1 }}
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.25 } }}
transition={{ type: "spring", stiffness: 260, damping: 26 }}
className="absolute left-1/2 top-1/2"
// Bake the -50% centering into Framer's transform — Framer owns the
// `transform` (from x/y/scale), so a Tailwind -translate-x-1/2 class
// gets clobbered (in RTL the card then anchors right → drifts left).
transformTemplate={(t) =>
`translate(-50%, -50%) translate(${t.x ?? "0px"}, ${t.y ?? "0px"}) scale(${t.scale ?? 1})`}
style={{
filter: isWinner
? "drop-shadow(0 0 18px rgba(212,175,55,1)) drop-shadow(0 0 6px rgba(255,240,120,0.8))"
: undefined,
}}
>
<PlayingCard card={pc.card} size={cardSize} front={front} />
</motion.div>
);
})}
</AnimatePresence>
{/* Burst particles when trick is won (centered on the felt) */}
<AnimatePresence>
{phase === "trick-complete" && winner != null && (
<div key={`burst-${winner}`} className="absolute left-1/2 top-1/2 size-0">
<TrickBurst seat={winner} />
</div>
)}
</AnimatePresence>
</div>
);
}
/* particles that fly out from center when you win a trick */
const BURST_ANGLES = Array.from({ length: 10 }, (_, i) => {
const a = (i / 10) * 2 * Math.PI;
const d = 55 + (i % 3) * 22;
return { id: i, x: Math.cos(a) * d, y: Math.sin(a) * d, size: 7 + (i % 3) * 5 };
});
function TrickBurst({ seat }: { seat: Seat }) {
const team = teamOf(seat);
const gradient = team === 0
? "radial-gradient(circle,#2dd4bf,#0d9488)"
: "radial-gradient(circle,#fb7185,#e11d48)";
const glowColor = team === 0 ? "rgba(45,212,191,0.55)" : "rgba(251,113,133,0.55)";
return (
<>
{/* centre flash */}
<motion.div
initial={{ scale: 0, opacity: 0.85 }}
animate={{ scale: 3.5, opacity: 0 }}
transition={{ duration: 0.38 }}
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none"
style={{ width: 44, height: 44, background: glowColor }}
/>
{BURST_ANGLES.map(p => (
<motion.div
key={p.id}
initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}
animate={{ x: p.x, y: p.y, opacity: 0, scale: 0 }}
transition={{ duration: 0.55, ease: [0.2, 0.8, 0.4, 1] }}
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none"
style={{ width: p.size, height: p.size, background: gradient }}
/>
))}
</>
);
}
/* ----------------------------- Player hand ---------------------------- */
function useViewportWidth() {
const [vw, setVw] = useState(typeof window !== "undefined" ? window.innerWidth : 390);
useEffect(() => {
const f = () => setVw(window.innerWidth);
f();
window.addEventListener("resize", f);
window.addEventListener("orientationchange", f);
return () => {
window.removeEventListener("resize", f);
window.removeEventListener("orientationchange", f);
};
}, []);
return vw;
}
function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
const hand = useGameStore((s) => s.game.players[0].hand);
const phase = useGameStore((s) => s.game.phase);
const turn = useGameStore((s) => s.game.turn);
const playHuman = useGameStore((s) => s.playHuman);
const { front } = useCardSkins();
const vw = useViewportWidth();
const sorted = sortHand(hand);
const myTurn = phase === "playing" && turn === 0;
const choosing = phase === "choosing-trump";
const n = sorted.length;
// Compress the fan so every card fits the screen width (no overflow/scroll).
const small = vw < 560;
const size = vw < 360 ? "sm" : vw < 480 ? "md" : vw < 640 ? "lg" : "xl";
const cardW = size === "sm" ? 44 : size === "md" ? 62 : size === "lg" ? 78 : 92;
const avail = Math.min(vw - 12, 620);
const step = n > 1 ? Math.min(cardW * 0.94, Math.max(15, (avail - cardW) / (n - 1))) : 0;
const overlap = step - cardW; // negative inline-start margin
// Desktop (with a keyboard) gets numbered shortcut badges on playable cards.
const showShortcutBadges = vw >= 768;
let playableSeq = 0;
return (
<div
className={cn(
"absolute bottom-0 inset-x-0 flex justify-center safe-bottom pointer-events-none",
choosing ? "z-50" : "z-20"
)}
>
<div className="relative flex items-end justify-center pointer-events-auto max-w-full">
{sorted.map((card, i) => {
const playable = myTurn && legalIds.has(card.id);
const dimmed = myTurn && !playable;
const mid = (n - 1) / 2;
const rot = (i - mid) * (small ? 2 : 3.2);
const lift = Math.abs(i - mid) * (small ? 2 : 4);
const shortcutNum = playable ? ++playableSeq : 0;
const badge = shortcutNum >= 1 && shortcutNum <= 10 ? (shortcutNum === 10 ? "0" : String(shortcutNum)) : null;
return (
<motion.button
key={card.id}
layout
initial={{ y: 120, opacity: 0 }}
animate={{ y: lift, opacity: 1, rotate: rot }}
transition={{ type: "spring", stiffness: 280, damping: 28, delay: i * 0.012 }}
whileHover={playable ? { y: lift - 26, scale: 1.08, zIndex: 50 } : {}}
whileTap={playable ? { scale: 1.05 } : {}}
// Drag a playable card up toward the board center to play it (tap also works).
drag={playable}
dragSnapToOrigin
dragElastic={0.6}
dragMomentum={false}
whileDrag={{ scale: 1.12, rotate: 0, zIndex: 60, cursor: "grabbing" }}
onDragEnd={(_e, info) => {
if (playable && info.offset.y < -90) playHuman(card);
}}
onClick={() => playable && playHuman(card)}
disabled={!playable}
data-card={card.id}
data-playable={playable ? "1" : "0"}
style={{ marginInlineStart: i === 0 ? 0 : overlap, touchAction: "none" }}
className={cn(
"origin-bottom shrink-0 relative",
playable ? "cursor-grab z-30" : "cursor-default",
playable && "card-playable"
)}
>
<PlayingCard
card={card}
size={size}
dimmed={dimmed}
front={front}
/>
{showShortcutBadges && badge && (
<span className="absolute -top-2 left-1/2 -translate-x-1/2 z-40 size-5 rounded-full btn-gold text-[11px] font-black grid place-items-center shadow-md">
{badge}
</span>
)}
</motion.button>
);
})}
</div>
</div>
);
}
/* --------------------------- Shortcuts hint --------------------------- */
function ShortcutsHint() {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const vw = useViewportWidth();
if (vw < 768) return null; // keyboard shortcuts are desktop-only
return (
<div className="absolute bottom-[max(1rem,env(safe-area-inset-bottom))] ltr:left-4 rtl:right-4 z-50">
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.96 }}
className="glass rounded-2xl p-3 mb-2 w-56 text-xs text-cream/80 space-y-1.5"
>
<Row k="19 / 0" v={t("keys.play")} />
<Row k="Space" v={t("keys.first")} />
<Row k="14" v={t("keys.trump")} />
<Row k="M" v={t("keys.mute")} />
<Row k="F" v={t("keys.forfeit")} />
<Row k="Esc / Q" v={t("keys.quit")} />
</motion.div>
)}
</AnimatePresence>
<button
onClick={() => setOpen((o) => !o)}
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition text-gold-400 font-black"
title={t("keys.title")}
>
</button>
</div>
);
}
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex items-center justify-between gap-3">
<kbd className="rounded-md bg-navy-900/80 gold-border px-1.5 py-0.5 font-mono text-[10px] text-gold-300">{k}</kbd>
<span className="text-cream/70">{v}</span>
</div>
);
}
/* --------------------------- Turn indicator --------------------------- */
function TurnIndicator() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
if (game.phase !== "playing" || game.turn == null) return null;
const isYou = game.turn === 0;
const name = game.players[game.turn].name;
return (
<AnimatePresence mode="wait">
<motion.div
key={game.turn}
initial={{ opacity: 0, scale: 0.75, y: 18 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.85, y: -8 }}
transition={{ type: "spring", stiffness: 340, damping: 24 }}
className="pointer-events-none"
>
{isYou ? (
<motion.div
animate={{ scale: [1, 1.055, 1] }}
transition={{ repeat: Infinity, duration: 1.05, ease: "easeInOut" }}
className="btn-gold press-3d rounded-full px-7 py-2.5 font-black text-[15px] tracking-wide"
style={{
boxShadow: "0 0 0 3px rgba(212,175,55,0.55), 0 0 28px rgba(212,175,55,0.45), 0 5px 0 rgba(0,0,0,0.32)",
}}
>
{t("turn.you")}
</motion.div>
) : (
<div className="glass rounded-full px-5 py-2 text-sm font-semibold text-cream/70">
{t("turn.other", { name })}
</div>
)}
</motion.div>
</AnimatePresence>
);
}
/* ----------------------------- Turn timer ----------------------------- */
function TurnTimer() {
const deadline = useGameStore((s) => s.turnDeadline);
const phase = useGameStore((s) => s.game.phase);
const stake = useGameStore((s) => s.matchMeta.stake);
const speed = useGameStore((s) => s.matchMeta.speed);
const secs = useCountdown(deadline);
if (deadline == null || secs == null) return null;
if (phase !== "playing" && phase !== "choosing-trump") return null;
const pct = Math.max(0, Math.min(1, (deadline - Date.now()) / turnMsForStake(stake, speed)));
const danger = secs <= 5;
return (
<div className="w-36 sm:w-40 text-center">
<span
className={cn(
"block text-sm font-black tabular-nums mb-1",
danger ? "text-rose-300 animate-pulse" : "text-gold-300"
)}
>
{secs}
</span>
<div className="h-1.5 rounded-full bg-navy-900/70 overflow-hidden gold-border">
<div
className="h-full rounded-full"
style={{
width: `${pct * 100}%`,
background: danger
? "#fb7185"
: "linear-gradient(90deg, var(--gold-500), var(--gold-300))",
}}
/>
</div>
</div>
);
}
/* ----------------------------- Reactions ------------------------------ */
const REACTION_POS: Record<number, string> = {
0: "bottom-44 left-1/2 -translate-x-1/2",
1: "top-1/2 right-20 -translate-y-1/2",
2: "top-28 left-1/2 -translate-x-1/2",
3: "top-1/2 left-20 -translate-y-1/2",
};
interface Bubble {
id: string;
seat: number;
emoji: string;
}
function ReactionBubble({ value }: { value: string }) {
if (value.startsWith("sticker:")) {
return <Sticker id={value.slice(8)} size={72} className="drop-shadow-xl" />;
}
return <span className="text-4xl drop-shadow-lg">{value}</span>;
}
function Reactions() {
const profile = useSessionStore((s) => s.profile);
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<"emoji" | "sticker">("emoji");
const [bubbles, setBubbles] = useState<Bubble[]>([]);
const emojis = profile ? ownedReactions(profile) : [];
const stickers = profile ? ownedStickers(profile) : [];
useEffect(() => {
const unsub = getService().onReaction((seat, emoji) => {
// Reactions carry the sender's ABSOLUTE seat — rotate into this viewer's frame.
const local = viewerRot(useGameStore.getState().mySeat).seat(seat) ?? seat;
const id = `${local}-${Date.now()}-${Math.random()}`;
setBubbles((b) => [...b, { id, seat: local, emoji }]);
setTimeout(() => setBubbles((b) => b.filter((x) => x.id !== id)), 2600);
});
return unsub;
}, []);
const send = (value: string) => {
getService().sendReaction(value);
setOpen(false);
};
return (
<>
{/* floating bubbles */}
{bubbles.map((b) => (
<motion.div
key={b.id}
initial={{ opacity: 0, scale: 0.4, y: 10 }}
animate={{ opacity: 1, scale: 1, y: -18 }}
exit={{ opacity: 0 }}
className={cn("absolute z-40 pointer-events-none", REACTION_POS[b.seat])}
>
<ReactionBubble value={b.emoji} />
</motion.div>
))}
{/* tray */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.95 }}
className="absolute bottom-60 ltr:right-3 rtl:left-3 z-50 glass rounded-2xl p-2 w-[min(270px,86vw)]"
>
<div className="flex gap-1 p-1 rounded-xl bg-navy-900/70 mb-2">
<button
onClick={() => setTab("emoji")}
className={cn(
"flex-1 rounded-lg py-1.5 text-xs font-bold transition",
tab === "emoji" ? "btn-gold" : "text-cream/60"
)}
>
{t("reactions.title")}
</button>
<button
onClick={() => setTab("sticker")}
className={cn(
"flex-1 rounded-lg py-1.5 text-xs font-bold transition",
tab === "sticker" ? "btn-gold" : "text-cream/60"
)}
>
{t("stickers.title")}
</button>
</div>
{tab === "emoji" ? (
<div className="grid grid-cols-5 gap-1">
{emojis.map((emoji, i) => (
<button
key={`${emoji}-${i}`}
onClick={() => send(emoji)}
className="size-10 rounded-xl hover:bg-navy-800 transition flex items-center justify-center text-2xl"
>
{emoji}
</button>
))}
</div>
) : (
<div className="grid grid-cols-4 gap-1">
{stickers.map((id) => (
<button
key={id}
onClick={() => send(`sticker:${id}`)}
className="rounded-xl hover:bg-navy-800 transition flex items-center justify-center p-1"
>
<Sticker id={id} size={48} />
</button>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
{/* button — raised above the hand so it doesn't overlap the cards on mobile */}
<button
onClick={() => setOpen((o) => !o)}
className="absolute bottom-44 ltr:right-3 rtl:left-3 z-50 glass rounded-full min-h-12 min-w-12 grid place-items-center hover:bg-navy-800 transition"
title={t("reactions.title")}
>
<SmilePlus className="size-5 text-gold-400" />
</button>
</>
);
}
/* ------------------------------ Overlays ------------------------------ */
function Backdrop({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-40 overflow-y-auto overscroll-contain bg-navy-950/70 backdrop-blur-sm"
>
{/* min-h-full + centering: centers short panels, scrolls tall ones (no clip). */}
<div className="flex min-h-full items-center justify-center p-4 py-8">{children}</div>
</motion.div>
);
}
function HakemOverlay() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
const { front } = useCardSkins();
const hakemName = game.hakem != null ? game.players[game.hakem].name : "";
return (
<Backdrop>
<motion.div
initial={{ scale: 0.9, y: 10 }}
animate={{ scale: 1, y: 0 }}
className="glass rounded-3xl p-7 text-center max-w-sm w-full"
>
<h2 className="gold-text text-2xl font-black">{t("hakem.title")}</h2>
<p className="text-cream/60 text-sm mt-1">{t("hakem.desc")}</p>
<div className="flex flex-wrap justify-center gap-1.5 mt-5">
{game.hakemDraw.map((pc, i) => (
<motion.div
key={pc.card.id}
initial={{ opacity: 0, y: -20, rotateY: 90 }}
animate={{ opacity: 1, y: 0, rotateY: 0 }}
transition={{ delay: i * 0.12 }}
>
<PlayingCard card={pc.card} size="sm" front={front} />
</motion.div>
))}
</div>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: game.hakemDraw.length * 0.12 + 0.2 }}
className="mt-5 text-gold-300 font-bold text-lg flex items-center justify-center gap-2"
>
<Crown className="size-5 text-gold-400 fill-gold-500" />
{t("hakem.is", { name: hakemName })}
</motion.p>
</motion.div>
</Backdrop>
);
}
function TrumpChooser() {
const choose = useGameStore((s) => s.chooseTrump);
const { t } = useI18n();
return (
<Backdrop>
<motion.div
initial={{ scale: 0.9, y: 10 }}
animate={{ scale: 1, y: 0 }}
className="glass rounded-3xl p-7 text-center max-w-sm w-full"
>
<h2 className="gold-text text-2xl font-black">{t("trump.title")}</h2>
<p className="text-cream/60 text-sm mt-1">{t("trump.desc")}</p>
<div className="grid grid-cols-2 gap-3 mt-6">
{SUITS.map((suit) => {
const red = SUIT_IS_RED[suit];
return (
<motion.button
key={suit}
whileHover={{ scale: 1.05, y: -2 }}
whileTap={{ scale: 0.96 }}
onClick={() => choose(suit)}
className="rounded-2xl bg-navy-900/80 gold-border py-6 flex items-center justify-center hover:bg-navy-800 transition"
>
<span
className={cn(
"text-5xl",
red ? "text-rose-400" : "text-cream"
)}
>
{SUIT_SYMBOL[suit]}
</span>
</motion.button>
);
})}
</div>
</motion.div>
</Backdrop>
);
}
const CONFETTI_SPECS = Array.from({ length: 22 }, (_, i) => ({
id: i,
left: 4 + ((i * 4.3) % 92),
delay: (i * 0.07) % 1.2,
color: i % 4 === 0 ? "#d4af37" : i % 4 === 1 ? "#2dd4bf" : i % 4 === 2 ? "#f5ecd6" : "#fb7185",
size: 6 + (i % 4) * 3,
rot: (i * 41) % 360,
}));
function RoundOverlay() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
const r = game.lastRoundResult;
if (!r) return null;
const weWon = r.winningTeam === 0;
return (
<Backdrop>
<motion.div
initial={{ scale: 0.82, y: 24 }}
animate={{ scale: 1, y: 0 }}
transition={{ type: "spring", stiffness: 220, damping: 20 }}
className="glass rounded-3xl p-8 text-center max-w-sm w-full relative overflow-hidden"
>
{/* confetti on win */}
{weWon && CONFETTI_SPECS.map(p => (
<motion.div
key={p.id}
initial={{ y: -10, opacity: 1, rotate: p.rot }}
animate={{ y: 160, opacity: 0, rotate: p.rot + 450 }}
transition={{ duration: 1.5 + p.delay * 0.4, delay: p.delay, ease: "linear" }}
className="absolute pointer-events-none rounded-sm"
style={{ width: p.size, height: p.size, background: p.color, left: `${p.left}%`, top: 0 }}
/>
))}
<motion.div
initial={{ scale: 0, rotate: -10 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 16 }}
className="text-5xl mb-1"
>
{weWon ? "🎉" : "😤"}
</motion.div>
<h2 className="gold-text text-3xl font-black">{t("round.over")}</h2>
{r.kot && (
<motion.div
initial={{ scale: 0, rotate: -15 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 350, damping: 14, delay: 0.15 }}
className="mt-3 inline-flex items-center gap-1.5 rounded-full btn-gold press-3d px-6 py-2 text-xl font-black"
>
{t("round.kot")} 🔥
</motion.div>
)}
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.22 }}
className={cn("mt-4 text-2xl font-black", weWon ? "text-teal-300" : "text-rose-300")}
>
{t("round.won", { team: weWon ? t("team.0") : t("team.1") })}
</motion.p>
<p className="text-cream/70 mt-2 font-semibold">
{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}
</p>
<p className="text-cream/40 text-sm mt-5 animate-pulse">{t("round.next")}</p>
</motion.div>
</Backdrop>
);
}
const WIN_COINS = Array.from({ length: 14 }, (_, i) => ({
id: i,
left: 5 + ((i * 6.8) % 88),
delay: (i * 0.11) % 1.6,
fontSize: 18 + (i % 3) * 10,
}));
function MatchOverlay({ onExit }: { onExit: () => void }) {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
const youWin = game.matchWinner === 0;
return (
<Backdrop>
<motion.div
initial={{ scale: 0.82, y: 16 }}
animate={{ scale: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 18 }}
className="glass rounded-3xl p-9 text-center max-w-sm w-full relative overflow-hidden"
>
{/* coin rain on win */}
{youWin && WIN_COINS.map(c => (
<motion.div
key={c.id}
initial={{ top: "-30px", opacity: 0, rotate: 0 }}
animate={{ top: "110%", opacity: [0, 1, 1, 0], rotate: 540 }}
transition={{ duration: 2.2 + c.delay * 0.3, delay: c.delay, ease: "easeIn" }}
className="absolute pointer-events-none select-none"
style={{ left: `${c.left}%`, fontSize: c.fontSize }}
>
🪙
</motion.div>
))}
<motion.div
initial={{ rotate: -20, scale: 0 }}
animate={{ rotate: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 180, damping: 14 }}
className="text-7xl mb-3 relative"
>
{youWin ? "🏆" : "🎴"}
</motion.div>
<h2 className="gold-text text-3xl font-black">{t("match.over")}</h2>
<p className={cn("mt-3 text-2xl font-bold", youWin ? "text-gold-300" : "text-rose-300")}>
{youWin ? t("match.youWin") : t("match.youLose")}
</p>
<p className="text-cream/70 mt-2">
{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}
</p>
<MatchPlayersList />
<div className="mt-7 flex gap-3">
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3 font-black text-lg">
{t("match.menu")}
</button>
</div>
</motion.div>
</Backdrop>
);
}