Files
HokmPlay/src/components/GameTable.tsx
T

795 lines
25 KiB
TypeScript
Raw Normal View History

"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Crown, LogOut, SmilePlus, WifiOff } from "lucide-react";
import { useEffect, useState } from "react";
import { TURN_MS, useGameStore } from "@/lib/game-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 { cardStyleById, ownedReactions, ownedStickers } from "@/lib/online/gamification";
import { getService } from "@/lib/online/service";
import { cn } from "@/lib/cn";
import { PlayingCard } from "./PlayingCard";
import { Sticker } from "./online/Sticker";
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));
}
export function GameTable({ onExit }: { onExit?: () => void } = {}) {
const game = useGameStore((s) => s.game);
const reset = useGameStore((s) => s.reset);
const mode = useGameStore((s) => s.mode);
const { t } = useI18n();
const exit = onExit ?? reset;
const { phase, players, hakem, trump, turn, currentTrick } = game;
const legalIds = new Set(
phase === "playing" && turn === 0
? legalMoves(game, 0).map((c) => c.id)
: []
);
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 p-3 sm:p-4">
<Scoreboard />
<div className="flex items-center gap-2">
{trump && <TrumpBadge trump={trump} />}
<button
onClick={exit}
className="glass rounded-full p-2.5 hover:bg-navy-800 transition"
title={t("hud.quit")}
>
<LogOut className="size-4 text-cream/80" />
</button>
</div>
</div>
{/* Felt table */}
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="felt relative w-[min(94vw,1100px)] h-[min(82vh,720px)] rounded-[42%]">
{/* 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-20 left-1/2 -translate-x-1/2" horizontal />
<OpponentHand seat={1} className="absolute top-1/2 right-16 -translate-y-1/2" />
<OpponentHand seat={3} className="absolute top-1/2 left-16 -translate-y-1/2" />
{/* center trick area */}
<TrickArea trick={currentTrick} winner={game.lastTrickWinner} phase={phase} />
</div>
</div>
{/* Your hand */}
<PlayerHand legalIds={legalIds} />
{/* Turn indicator */}
<TurnIndicator />
<TurnTimer />
<DisconnectBanner />
<Reactions />
{/* 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-2xl px-4 py-2.5 flex items-center gap-4">
<ScoreCol
label={t("team.us")}
tricks={game.roundTricks[0]}
score={game.matchScore[0]}
accent="text-teal-400"
/>
<div className="text-cream/30 text-lg font-light">/</div>
<ScoreCol
label={t("team.them")}
tricks={game.roundTricks[1]}
score={game.matchScore[1]}
accent="text-rose-400"
/>
<div className="ltr:ml-2 rtl:mr-2 ltr:border-l rtl:border-r border-gold-500/20 ltr:pl-3 rtl:pr-3">
<div className="text-[10px] text-cream/50">{t("home.target")}</div>
<div className="gold-text font-bold text-center">{game.targetScore}</div>
</div>
</div>
);
}
function ScoreCol({
label,
tricks,
score,
accent,
}: {
label: string;
tricks: number;
score: number;
accent: string;
}) {
const { t } = useI18n();
return (
<div className="text-center min-w-14">
<div className={cn("text-xs font-semibold", accent)}>{label}</div>
<div className="text-2xl font-black leading-none">{score}</div>
<div className="text-[10px] text-cream/45 mt-0.5">
{t("score.tricks")}: {tricks}
</div>
</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-3 py-2 flex items-center gap-2"
>
<span className="text-[10px] text-gold-400 font-semibold">
{t("trump.label")}
</span>
<span
className={cn(
"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 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;
return (
<div className={cn("flex flex-col items-center gap-1", className)}>
<motion.div
animate={
active
? { boxShadow: "0 0 0 3px rgba(212,175,55,0.9), 0 0 24px rgba(212,175,55,0.5)" }
: { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" }
}
className={cn(
"relative size-12 rounded-full flex items-center justify-center font-bold text-xl",
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100"
)}
>
{sp?.avatar ?? name.charAt(0)}
{isHakem && (
<Crown className="absolute -top-3 size-4 text-gold-400 fill-gold-500" />
)}
</motion.div>
<span className="text-[11px] text-cream/80 max-w-20 truncate">{name}</span>
{sp && sp.level > 0 && (
<span className="text-[9px] text-gold-400/80 leading-none">
{team === 0 ? "" : ""}
{`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 styleId = useSessionStore((s) => s.profile?.cardStyle ?? "classic");
const cs = cardStyleById(styleId);
const back = { c1: cs.c1, c2: cs.c2, accent: cs.accent };
const cards = Array.from({ length: count });
return (
<div
className={cn(
"flex",
horizontal ? "flex-row" : "flex-col",
className
)}
>
{cards.map((_, i) => (
<div
key={i}
style={horizontal ? { marginInlineStart: i === 0 ? 0 : -34 } : { marginTop: i === 0 ? 0 : -48 }}
>
<PlayingCard faceDown size="sm" back={back} />
</div>
))}
</div>
);
}
/* ----------------------------- Trick area ----------------------------- */
const TRICK_OFFSET: Record<Seat, { x: number; y: number }> = {
0: { x: 0, y: 70 },
1: { x: 96, y: 0 },
2: { x: 0, y: -70 },
3: { x: -96, 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,
}: {
trick: { seat: Seat; card: Card }[];
winner: Seat | null;
phase: string;
}) {
return (
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative size-1 ">
<AnimatePresence>
{trick.map((pc) => {
const off = TRICK_OFFSET[pc.seat];
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.12 : 1,
}}
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.25 } }}
transition={{ type: "spring", stiffness: 260, damping: 26 }}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{
filter: isWinner
? "drop-shadow(0 0 14px rgba(212,175,55,0.9))"
: undefined,
}}
>
<PlayingCard card={pc.card} size="md" />
</motion.div>
);
})}
</AnimatePresence>
</div>
</div>
);
}
/* ----------------------------- Player hand ---------------------------- */
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 sorted = sortHand(hand);
const myTurn = phase === "playing" && turn === 0;
// While choosing trump the hakem must see their cards above the chooser overlay.
const choosing = phase === "choosing-trump";
const n = sorted.length;
return (
<div
className={cn(
"absolute bottom-0 inset-x-0 flex justify-center pb-3 pointer-events-none",
choosing ? "z-50" : "z-20"
)}
>
<div className="relative flex items-end justify-center pointer-events-auto">
{sorted.map((card, i) => {
const playable = myTurn && legalIds.has(card.id);
const dimmed = myTurn && !playable;
const mid = (n - 1) / 2;
const rot = (i - mid) * 3.2;
const lift = Math.abs(i - mid) * 4;
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.015 }}
whileHover={playable ? { y: lift - 26, scale: 1.06, zIndex: 50 } : {}}
onClick={() => playable && playHuman(card)}
disabled={!playable}
data-card={card.id}
data-playable={playable ? "1" : "0"}
style={{ marginInlineStart: i === 0 ? 0 : -22 }}
className={cn(
"origin-bottom",
playable && "cursor-pointer",
!myTurn && "cursor-default"
)}
>
<PlayingCard
card={card}
size="lg"
dimmed={dimmed}
className={cn(playable && "ring-2 ring-gold-400/70")}
/>
</motion.button>
);
})}
</div>
</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, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="absolute bottom-[150px] left-1/2 -translate-x-1/2 z-30"
>
<div
className={cn(
"rounded-full px-4 py-1.5 text-sm font-semibold glass",
isYou ? "text-gold-300" : "text-cream/70"
)}
>
{isYou ? t("turn.you") : 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 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()) / TURN_MS));
const danger = secs <= 5;
return (
<div className="absolute bottom-[190px] left-1/2 -translate-x-1/2 z-30 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>
);
}
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 (
<AnimatePresence>
{seat != null && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-40"
>
<div className="glass rounded-2xl px-4 py-3 flex items-center gap-2.5 text-sm">
<WifiOff className="size-5 text-rose-300 animate-pulse" />
<span className="text-cream/90">
{t("dc.waiting", { name: name ?? "", s: secs ?? 0 })}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
/* ----------------------------- 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) => {
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) => (
<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-20 ltr:right-4 rtl:left-4 z-50 glass rounded-2xl p-2 w-[270px]"
>
<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 */}
<button
onClick={() => setOpen((o) => !o)}
className="absolute bottom-4 ltr:right-4 rtl:left-4 z-50 glass rounded-full p-3 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 flex items-center justify-center bg-navy-950/70 backdrop-blur-sm p-5"
>
{children}
</motion.div>
);
}
function HakemOverlay() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
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" />
</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>
);
}
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.85, y: 20 }}
animate={{ scale: 1, y: 0 }}
className="glass rounded-3xl p-8 text-center max-w-sm w-full"
>
<h2 className="gold-text text-3xl font-black">{t("round.over")}</h2>
{r.kot && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, delay: 0.15 }}
className="mt-3 inline-block rounded-full btn-gold px-5 py-1.5 text-lg font-black"
>
{t("round.kot")}🔥
</motion.div>
)}
<p
className={cn(
"mt-4 text-xl font-bold",
weWon ? "text-teal-300" : "text-rose-300"
)}
>
{t("round.won", { team: weWon ? t("team.0") : t("team.1") })}
</p>
<p className="text-cream/70 mt-2">
{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>
);
}
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.85 }}
animate={{ scale: 1 }}
className="glass rounded-3xl p-9 text-center max-w-sm w-full"
>
<motion.div
initial={{ rotate: -15, scale: 0 }}
animate={{ rotate: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 160 }}
className="text-6xl mb-3"
>
{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>
<div className="mt-7 flex gap-3">
<button onClick={onExit} className="btn-gold flex-1 rounded-xl py-3">
{t("match.menu")}
</button>
</div>
</motion.div>
</Backdrop>
);
}