"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff, Zap } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useGameStore } from "@/lib/game-store";
import { useSoundStore } from "@/lib/sound-store";
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 < 360 ? 0.5 : vw < 460 ? 0.64 : 1;
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 */}
{/* 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 (
/
{t("home.target")}
{game.targetScore}
);
}
function ScoreCol({
label,
tricks,
score,
accent,
}: {
label: string;
tricks: number;
score: number;
accent: string;
}) {
const { t } = useI18n();
return (
{label}
{score}
{t("score.tricks")}: {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?.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 ----------------------------- */
const TRICK_OFFSET: Record = {
0: { x: 0, y: 70 },
1: { x: 96, y: 0 },
2: { x: 0, y: -70 },
3: { x: -96, 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,
}: {
trick: { seat: Seat; card: Card }[];
winner: Seat | null;
phase: string;
scale?: number;
}) {
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 (
);
})}
{/* Burst particles when trick is won */}
{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 (
playable && playHuman(card)}
disabled={!playable}
data-card={card.id}
data-playable={playable ? "1" : "0"}
style={{ marginInlineStart: i === 0 ? 0 : overlap }}
className={cn(
"origin-bottom shrink-0 relative",
playable ? "cursor-pointer 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 (
);
}
function DisconnectBanner() {
const seat = useGameStore((s) => s.disconnectedSeat);
const deadline = useGameStore((s) => s.reconnectDeadline);
const name = useGameStore((s) => (seat != null ? s.seatPlayers[seat]?.name : null));
const secs = useCountdown(deadline);
const { t } = useI18n();
return (
{seat != null && (
{t("dc.waiting", { name: name ?? "", s: secs ?? 0 })}
)}
);
}
/* ----------------------------- 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) => {
const id = `${seat}-${Date.now()}-${Math.random()}`;
setBubbles((b) => [...b, { id, seat, 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 */}
>
);
}
/* ------------------------------ Overlays ------------------------------ */
function Backdrop({ children }: { children: React.ReactNode }) {
return (
{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] })}
);
}