"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): 1โ€“9 / 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: 1โ€“4 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 (
{/* Top HUD */}
{trump && } {onForfeit && ( )}
{/* forfeit confirm (requester) */} {askFf && (
๐Ÿณ๏ธ

{t("forfeit.title")}

{t("forfeit.ask")}

{t("forfeit.rule")}

)}
{/* Felt table โ€” portrait proportions (tall, centered between HUD and hand) */}
{/* 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. */}
{/* opponent + partner seats */} {/* opponents' face-down hands */} {/* center trick area (offsets scale down on narrow screens) */}
{/* Your hand */} {/* Turn indicator + timer โ€” stacked so they never overlap */}
{/* Overlays */} {phase === "selecting-hakem" && } {phase === "choosing-trump" && players[hakem!]?.isHuman && ( )} {phase === "round-over" && } {phase === "match-over" && mode === "ai" && ( )}
); } /* ----------------------------- Scoreboard ----------------------------- */ function Scoreboard() { const game = useGameStore((s) => s.game); const { t } = useI18n(); return (
ยท {game.targetScore}
); } function ScoreCol({ label, tricks, score, accent, }: { label: string; tricks: number; score: number; accent: string; }) { return (
{label}
{score} ({tricks})
); } /* ----------------------------- Speed badge ---------------------------- */ function SpeedBadge() { const speed = useGameStore((s) => s.matchMeta.speed); const { t } = useI18n(); if (!speed) return null; return ( {t("speed.label")} ); } /* ----------------------------- Trump badge ---------------------------- */ function TrumpBadge({ trump }: { trump: Suit }) { const { t } = useI18n(); const red = SUIT_IS_RED[trump]; return ( {t("trump.label")} {SUIT_SYMBOL[trump]} ); } /* ----------------------------- 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 (
{sp?.avatarId || sp?.avatarImage ? ( ) : ( sp?.avatar ?? name.charAt(0) )} {isHakem && ( )} {active && ( )}
{name} {titleName && ( {titleName} )} {sp && sp.level > 0 && ( {`Lv ${sp.level}`} )}
); } /* --------------------------- 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 (
{cards.map((_, i) => { const rot = horizontal ? (i - mid) * 4 : 0; return (
); })}
); } /* ----------------------------- 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 = { 0: { x: 0, y: 50 }, 1: { x: 48, y: 0 }, 2: { x: 0, y: -50 }, 3: { x: -48, y: 0 }, }; const TRICK_ENTER: Record = { 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 (
{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 ( `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, }} > ); })} {/* Burst particles when trick is won (centered on the felt) */} {phase === "trick-complete" && winner != null && (
)}
); } /* 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 */} {BURST_ANGLES.map(p => ( ))} ); } /* ----------------------------- 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 }) { 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 (
{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 ( { 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" )} > {showShortcutBadges && badge && ( {badge} )} ); })}
); } /* --------------------------- 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 (
{open && ( )}
); } function Row({ k, v }: { k: string; v: string }) { return (
{k} {v}
); } /* --------------------------- 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 ( {isYou ? ( โœจ {t("turn.you")} ) : (
{t("turn.other", { name })}
)}
); } /* ----------------------------- 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 (
{secs}
); } /* ----------------------------- Reactions ------------------------------ */ const REACTION_POS: Record = { 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 ; } return {value}; } 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([]); 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) => ( ))} {/* tray */} {open && (
{tab === "emoji" ? (
{emojis.map((emoji, i) => ( ))}
) : (
{stickers.map((id) => ( ))}
)}
)}
{/* button โ€” raised above the hand so it doesn't overlap the cards on mobile */} ); } /* ------------------------------ Overlays ------------------------------ */ function Backdrop({ children }: { children: React.ReactNode }) { return ( {/* min-h-full + centering: centers short panels, scrolls tall ones (no clip). */}
{children}
); } 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 (

{t("hakem.title")}

{t("hakem.desc")}

{game.hakemDraw.map((pc, i) => ( ))}
{t("hakem.is", { name: hakemName })}
); } function TrumpChooser() { const choose = useGameStore((s) => s.chooseTrump); const { t } = useI18n(); return (

{t("trump.title")}

{t("trump.desc")}

{SUITS.map((suit) => { const red = SUIT_IS_RED[suit]; return ( choose(suit)} className="rounded-2xl bg-navy-900/80 gold-border py-6 flex items-center justify-center hover:bg-navy-800 transition" > {SUIT_SYMBOL[suit]} ); })}
); } 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 ( {/* confetti on win */} {weWon && CONFETTI_SPECS.map(p => ( ))} {weWon ? "๐ŸŽ‰" : "๐Ÿ˜ค"}

{t("round.over")}

{r.kot && ( {t("round.kot")} ๐Ÿ”ฅ )} {t("round.won", { team: weWon ? t("team.0") : t("team.1") })}

{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}

{t("round.next")}

); } 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 ( {/* coin rain on win */} {youWin && WIN_COINS.map(c => ( ๐Ÿช™ ))} {youWin ? "๐Ÿ†" : "๐ŸŽด"}

{t("match.over")}

{youWin ? t("match.youWin") : t("match.youLose")}

{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}

); }