2026-06-04 10:11:00 +03:30
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import { AnimatePresence, motion } from "framer-motion";
|
2026-06-06 18:39:24 +03:30
|
|
|
|
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff, Zap } from "lucide-react";
|
|
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
|
|
import { useGameStore } from "@/lib/game-store";
|
2026-06-04 11:49:19 +03:30
|
|
|
|
import { useSoundStore } from "@/lib/sound-store";
|
2026-06-04 10:11:00 +03:30
|
|
|
|
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";
|
2026-06-04 10:49:54 +03:30
|
|
|
|
import { useSessionStore } from "@/lib/session-store";
|
2026-06-06 18:39:24 +03:30
|
|
|
|
import { cardBackById, cardFrontById, ownedReactions, ownedStickers, titleById, turnMsForStake } from "@/lib/online/gamification";
|
2026-06-04 11:02:25 +03:30
|
|
|
|
import { getService } from "@/lib/online/service";
|
2026-06-04 10:11:00 +03:30
|
|
|
|
import { cn } from "@/lib/cn";
|
|
|
|
|
|
import { PlayingCard } from "./PlayingCard";
|
2026-06-04 11:15:28 +03:30
|
|
|
|
import { Sticker } from "./online/Sticker";
|
2026-06-05 10:40:14 +03:30
|
|
|
|
import { MatchPlayersList } from "./online/MatchPlayersList";
|
2026-06-04 10:11:00 +03:30
|
|
|
|
|
2026-06-04 10:49:54 +03:30
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 11:49:19 +03:30
|
|
|
|
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 },
|
2026-06-06 18:39:24 +03:30
|
|
|
|
back: { c1: b.c1, c2: b.c2, accent: b.accent, pattern: b.pattern, motif: b.motif },
|
2026-06-04 11:49:19 +03:30
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 22:47:36 +03:30
|
|
|
|
export function GameTable({
|
|
|
|
|
|
onExit,
|
|
|
|
|
|
onForfeit,
|
|
|
|
|
|
}: { onExit?: () => void; onForfeit?: () => void } = {}) {
|
2026-06-04 10:11:00 +03:30
|
|
|
|
const game = useGameStore((s) => s.game);
|
|
|
|
|
|
const reset = useGameStore((s) => s.reset);
|
|
|
|
|
|
const mode = useGameStore((s) => s.mode);
|
|
|
|
|
|
const { t } = useI18n();
|
2026-06-04 22:47:36 +03:30
|
|
|
|
const [askFf, setAskFf] = useState(false);
|
2026-06-04 10:11:00 +03:30
|
|
|
|
|
2026-06-04 11:49:19 +03:30
|
|
|
|
const sfx = useSoundStore((s) => s.sfx);
|
2026-06-04 12:46:51 +03:30
|
|
|
|
const music = useSoundStore((s) => s.music);
|
|
|
|
|
|
const toggleAll = useSoundStore((s) => s.toggleAll);
|
|
|
|
|
|
const muted = !sfx && !music;
|
2026-06-04 11:49:19 +03:30
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
const exit = onExit ?? reset;
|
2026-06-04 23:07:51 +03:30
|
|
|
|
const vw = useViewportWidth();
|
|
|
|
|
|
// Pull the played-card pile inward on narrow screens so it clears the side stacks.
|
2026-06-07 07:27:34 +03:30
|
|
|
|
const trickScale = vw < 400 ? 0.82 : 1;
|
2026-06-07 07:08:12 +03:30
|
|
|
|
// Smaller played cards on phones so the center pile stays clear of the side seats.
|
|
|
|
|
|
const trickCardSize: "sm" | "md" = vw < 480 ? "sm" : "md";
|
2026-06-04 10:11:00 +03:30
|
|
|
|
const { phase, players, hakem, trump, turn, currentTrick } = game;
|
|
|
|
|
|
|
2026-06-06 18:39:24 +03:30
|
|
|
|
const legalMovesList = useMemo(
|
|
|
|
|
|
() => (phase === "playing" && turn === 0 ? legalMoves(game, 0) : []),
|
|
|
|
|
|
[phase, turn, game]
|
2026-06-04 10:11:00 +03:30
|
|
|
|
);
|
2026-06-06 18:39:24 +03:30
|
|
|
|
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]);
|
2026-06-04 10:11:00 +03:30
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<main className="persian-pattern relative h-dvh w-full overflow-hidden">
|
|
|
|
|
|
{/* Top HUD */}
|
2026-06-05 06:38:46 +03:30
|
|
|
|
<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">
|
2026-06-04 10:11:00 +03:30
|
|
|
|
<Scoreboard />
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-06-06 18:39:24 +03:30
|
|
|
|
<SpeedBadge />
|
2026-06-04 10:11:00 +03:30
|
|
|
|
{trump && <TrumpBadge trump={trump} />}
|
2026-06-04 11:49:19 +03:30
|
|
|
|
<button
|
2026-06-04 12:46:51 +03:30
|
|
|
|
onClick={toggleAll}
|
2026-06-05 06:38:46 +03:30
|
|
|
|
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition"
|
2026-06-04 12:46:51 +03:30
|
|
|
|
title={t("settings.audio")}
|
2026-06-04 11:49:19 +03:30
|
|
|
|
>
|
2026-06-04 12:46:51 +03:30
|
|
|
|
{muted ? (
|
2026-06-04 11:49:19 +03:30
|
|
|
|
<VolumeX className="size-4 text-cream/60" />
|
2026-06-04 12:46:51 +03:30
|
|
|
|
) : (
|
|
|
|
|
|
<Volume2 className="size-4 text-gold-400" />
|
2026-06-04 11:49:19 +03:30
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
2026-06-04 22:47:36 +03:30
|
|
|
|
{onForfeit && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setAskFf(true)}
|
2026-06-05 06:38:46 +03:30
|
|
|
|
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition"
|
2026-06-04 22:47:36 +03:30
|
|
|
|
title={t("forfeit.title")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Flag className="size-4 text-rose-300/90" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
<button
|
|
|
|
|
|
onClick={exit}
|
2026-06-05 06:38:46 +03:30
|
|
|
|
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition"
|
2026-06-04 10:11:00 +03:30
|
|
|
|
title={t("hud.quit")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<LogOut className="size-4 text-cream/80" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-04 22:47:36 +03:30
|
|
|
|
{/* 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>
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
{/* 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 */}
|
2026-06-04 23:07:51 +03:30
|
|
|
|
<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" />
|
2026-06-04 10:11:00 +03:30
|
|
|
|
|
2026-06-04 23:07:51 +03:30
|
|
|
|
{/* center trick area (offsets scale down on narrow screens) */}
|
2026-06-07 07:08:12 +03:30
|
|
|
|
<TrickArea trick={currentTrick} winner={game.lastTrickWinner} phase={phase} scale={trickScale} cardSize={trickCardSize} />
|
2026-06-04 10:11:00 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Your hand */}
|
|
|
|
|
|
<PlayerHand legalIds={legalIds} />
|
|
|
|
|
|
|
2026-06-06 21:58:54 +03:30
|
|
|
|
{/* 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>
|
2026-06-04 10:49:54 +03:30
|
|
|
|
<DisconnectBanner />
|
2026-06-04 11:02:25 +03:30
|
|
|
|
<Reactions />
|
2026-06-06 18:39:24 +03:30
|
|
|
|
<ShortcutsHint />
|
2026-06-04 10:11:00 +03:30
|
|
|
|
|
|
|
|
|
|
{/* 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 (
|
2026-06-04 16:11:30 +03:30
|
|
|
|
<div className="glass rounded-2xl px-2.5 py-1.5 gap-2.5 sm:px-4 sm:py-2.5 sm:gap-4 flex items-center">
|
2026-06-04 10:11:00 +03:30
|
|
|
|
<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 (
|
2026-06-04 16:11:30 +03:30
|
|
|
|
<div className="text-center min-w-10 sm:min-w-14">
|
|
|
|
|
|
<div className={cn("text-[11px] sm:text-xs font-semibold", accent)}>{label}</div>
|
|
|
|
|
|
<div className="text-lg sm:text-2xl font-black leading-none">{score}</div>
|
2026-06-04 10:11:00 +03:30
|
|
|
|
<div className="text-[10px] text-cream/45 mt-0.5">
|
|
|
|
|
|
{t("score.tricks")}: {tricks}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 18:39:24 +03:30
|
|
|
|
/* ----------------------------- 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.5 py-2 flex items-center gap-1 text-gold-300"
|
|
|
|
|
|
title={t("speed.label")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Zap className="size-4 fill-gold-400 text-gold-400" />
|
|
|
|
|
|
<span className="text-[10px] font-black uppercase tracking-wide">{t("speed.label")}</span>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
/* ----------------------------- 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]);
|
2026-06-06 18:39:24 +03:30
|
|
|
|
const { locale } = useI18n();
|
2026-06-04 10:11:00 +03:30
|
|
|
|
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;
|
2026-06-06 18:39:24 +03:30
|
|
|
|
const titleDef = titleById(sp?.title);
|
|
|
|
|
|
const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null;
|
2026-06-04 10:11:00 +03:30
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-04 23:07:51 +03:30
|
|
|
|
<div className={cn("z-20 flex flex-col items-center gap-1", className)}>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
<div
|
2026-06-04 10:11:00 +03:30
|
|
|
|
className={cn(
|
2026-06-06 18:39:24 +03:30
|
|
|
|
"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"
|
2026-06-04 10:11:00 +03:30
|
|
|
|
)}
|
2026-06-06 18:39:24 +03:30
|
|
|
|
style={!active ? { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" } : undefined}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
>
|
|
|
|
|
|
{sp?.avatar ?? name.charAt(0)}
|
|
|
|
|
|
{isHakem && (
|
2026-06-06 18:39:24 +03:30
|
|
|
|
<Crown className="absolute -top-4 size-5 text-gold-400 fill-gold-500 drop-shadow" />
|
2026-06-04 10:11:00 +03:30
|
|
|
|
)}
|
2026-06-06 18:39:24 +03:30
|
|
|
|
{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>
|
2026-06-05 06:38:46 +03:30
|
|
|
|
<span className="text-[11px] font-semibold text-cream max-w-20 truncate hud-shadow">{name}</span>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
{titleName && (
|
|
|
|
|
|
<span className="text-[9px] font-bold gold-text leading-none max-w-24 truncate hud-shadow">{titleName}</span>
|
|
|
|
|
|
)}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
{sp && sp.level > 0 && (
|
2026-06-06 18:39:24 +03:30
|
|
|
|
<span className="text-[10px] text-gold-300/80 leading-none hud-shadow">{`Lv ${sp.level}`}</span>
|
2026-06-04 10:11:00 +03:30
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* --------------------------- Opponent hands --------------------------- */
|
|
|
|
|
|
|
|
|
|
|
|
function OpponentHand({
|
|
|
|
|
|
seat,
|
|
|
|
|
|
className,
|
|
|
|
|
|
horizontal,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
seat: Seat;
|
|
|
|
|
|
className?: string;
|
|
|
|
|
|
horizontal?: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const count = useGameStore((s) => s.game.players[seat].hand.length);
|
2026-06-04 11:49:19 +03:30
|
|
|
|
const { back } = useCardSkins();
|
2026-06-04 10:11:00 +03:30
|
|
|
|
const cards = Array.from({ length: count });
|
2026-06-06 18:39:24 +03:30
|
|
|
|
const mid = (count - 1) / 2;
|
2026-06-04 10:11:00 +03:30
|
|
|
|
return (
|
2026-06-06 18:39:24 +03:30
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ----------------------------- Trick area ----------------------------- */
|
|
|
|
|
|
|
2026-06-07 07:27:34 +03:30
|
|
|
|
// 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.
|
2026-06-04 10:11:00 +03:30
|
|
|
|
const TRICK_OFFSET: Record<Seat, { x: number; y: number }> = {
|
2026-06-07 07:27:34 +03:30
|
|
|
|
0: { x: 0, y: 52 },
|
|
|
|
|
|
1: { x: 50, y: 0 },
|
|
|
|
|
|
2: { x: 0, y: -52 },
|
|
|
|
|
|
3: { x: -50, y: 0 },
|
2026-06-04 10:11:00 +03:30
|
|
|
|
};
|
|
|
|
|
|
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,
|
2026-06-04 23:07:51 +03:30
|
|
|
|
scale = 1,
|
2026-06-07 07:08:12 +03:30
|
|
|
|
cardSize = "md",
|
2026-06-04 10:11:00 +03:30
|
|
|
|
}: {
|
|
|
|
|
|
trick: { seat: Seat; card: Card }[];
|
|
|
|
|
|
winner: Seat | null;
|
|
|
|
|
|
phase: string;
|
2026-06-04 23:07:51 +03:30
|
|
|
|
scale?: number;
|
2026-06-07 07:08:12 +03:30
|
|
|
|
cardSize?: "sm" | "md" | "lg";
|
2026-06-04 10:11:00 +03:30
|
|
|
|
}) {
|
2026-06-04 11:49:19 +03:30
|
|
|
|
const { front } = useCardSkins();
|
2026-06-04 10:11:00 +03:30
|
|
|
|
return (
|
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
2026-06-06 18:39:24 +03:30
|
|
|
|
<div className="relative size-1">
|
2026-06-04 10:11:00 +03:30
|
|
|
|
<AnimatePresence>
|
|
|
|
|
|
{trick.map((pc) => {
|
2026-06-04 23:07:51 +03:30
|
|
|
|
const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale };
|
2026-06-04 10:11:00 +03:30
|
|
|
|
const enter = TRICK_ENTER[pc.seat];
|
2026-06-06 18:39:24 +03:30
|
|
|
|
const isWinner = phase === "trick-complete" && winner === pc.seat;
|
2026-06-04 10:11:00 +03:30
|
|
|
|
return (
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
key={pc.card.id}
|
|
|
|
|
|
initial={{ x: enter.x, y: enter.y, opacity: 0, scale: 0.7 }}
|
2026-06-06 18:39:24 +03:30
|
|
|
|
animate={{ x: off.x, y: off.y, opacity: 1, scale: isWinner ? 1.14 : 1 }}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
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
|
2026-06-06 18:39:24 +03:30
|
|
|
|
? "drop-shadow(0 0 18px rgba(212,175,55,1)) drop-shadow(0 0 6px rgba(255,240,120,0.8))"
|
2026-06-04 10:11:00 +03:30
|
|
|
|
: undefined,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-06-07 07:08:12 +03:30
|
|
|
|
<PlayingCard card={pc.card} size={cardSize} front={front} />
|
2026-06-04 10:11:00 +03:30
|
|
|
|
</motion.div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</AnimatePresence>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
{/* Burst particles when trick is won */}
|
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
|
{phase === "trick-complete" && winner != null && (
|
|
|
|
|
|
<TrickBurst key={`burst-${winner}`} seat={winner} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</AnimatePresence>
|
2026-06-04 10:11:00 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 18:39:24 +03:30
|
|
|
|
/* 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 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
/* ----------------------------- Player hand ---------------------------- */
|
|
|
|
|
|
|
2026-06-04 16:00:00 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
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);
|
2026-06-04 11:49:19 +03:30
|
|
|
|
const { front } = useCardSkins();
|
2026-06-04 16:00:00 +03:30
|
|
|
|
const vw = useViewportWidth();
|
2026-06-04 10:11:00 +03:30
|
|
|
|
|
|
|
|
|
|
const sorted = sortHand(hand);
|
|
|
|
|
|
const myTurn = phase === "playing" && turn === 0;
|
2026-06-04 11:02:25 +03:30
|
|
|
|
const choosing = phase === "choosing-trump";
|
2026-06-04 10:11:00 +03:30
|
|
|
|
const n = sorted.length;
|
|
|
|
|
|
|
2026-06-04 16:00:00 +03:30
|
|
|
|
// Compress the fan so every card fits the screen width (no overflow/scroll).
|
|
|
|
|
|
const small = vw < 560;
|
2026-06-06 18:39:24 +03:30
|
|
|
|
const size = vw < 360 ? "sm" : vw < 480 ? "md" : vw < 640 ? "lg" : "xl";
|
|
|
|
|
|
const cardW = size === "sm" ? 44 : size === "md" ? 62 : size === "lg" ? 78 : 92;
|
2026-06-04 16:00:00 +03:30
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-06 18:39:24 +03:30
|
|
|
|
// Desktop (with a keyboard) gets numbered shortcut badges on playable cards.
|
|
|
|
|
|
const showShortcutBadges = vw >= 768;
|
|
|
|
|
|
let playableSeq = 0;
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
return (
|
2026-06-04 11:02:25 +03:30
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
2026-06-05 06:38:46 +03:30
|
|
|
|
"absolute bottom-0 inset-x-0 flex justify-center safe-bottom pointer-events-none",
|
2026-06-04 11:02:25 +03:30
|
|
|
|
choosing ? "z-50" : "z-20"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-06-04 16:00:00 +03:30
|
|
|
|
<div className="relative flex items-end justify-center pointer-events-auto max-w-full">
|
2026-06-04 10:11:00 +03:30
|
|
|
|
{sorted.map((card, i) => {
|
|
|
|
|
|
const playable = myTurn && legalIds.has(card.id);
|
|
|
|
|
|
const dimmed = myTurn && !playable;
|
|
|
|
|
|
const mid = (n - 1) / 2;
|
2026-06-04 16:00:00 +03:30
|
|
|
|
const rot = (i - mid) * (small ? 2 : 3.2);
|
|
|
|
|
|
const lift = Math.abs(i - mid) * (small ? 2 : 4);
|
2026-06-06 18:39:24 +03:30
|
|
|
|
const shortcutNum = playable ? ++playableSeq : 0;
|
|
|
|
|
|
const badge = shortcutNum >= 1 && shortcutNum <= 10 ? (shortcutNum === 10 ? "0" : String(shortcutNum)) : null;
|
2026-06-04 10:11:00 +03:30
|
|
|
|
return (
|
|
|
|
|
|
<motion.button
|
|
|
|
|
|
key={card.id}
|
|
|
|
|
|
layout
|
|
|
|
|
|
initial={{ y: 120, opacity: 0 }}
|
|
|
|
|
|
animate={{ y: lift, opacity: 1, rotate: rot }}
|
2026-06-04 16:00:00 +03:30
|
|
|
|
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 } : {}}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
onClick={() => playable && playHuman(card)}
|
|
|
|
|
|
disabled={!playable}
|
|
|
|
|
|
data-card={card.id}
|
|
|
|
|
|
data-playable={playable ? "1" : "0"}
|
2026-06-04 16:00:00 +03:30
|
|
|
|
style={{ marginInlineStart: i === 0 ? 0 : overlap }}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
className={cn(
|
2026-06-06 18:39:24 +03:30
|
|
|
|
"origin-bottom shrink-0 relative",
|
|
|
|
|
|
playable ? "cursor-pointer z-30" : "cursor-default",
|
|
|
|
|
|
playable && "card-playable"
|
2026-06-04 10:11:00 +03:30
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<PlayingCard
|
|
|
|
|
|
card={card}
|
2026-06-04 16:00:00 +03:30
|
|
|
|
size={size}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
dimmed={dimmed}
|
2026-06-04 11:49:19 +03:30
|
|
|
|
front={front}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
/>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
</motion.button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 18:39:24 +03:30
|
|
|
|
/* --------------------------- 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="1–9 / 0" v={t("keys.play")} />
|
|
|
|
|
|
<Row k="Space" v={t("keys.first")} />
|
|
|
|
|
|
<Row k="1–4" 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
/* --------------------------- 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}
|
2026-06-06 18:39:24 +03:30
|
|
|
|
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 }}
|
2026-06-06 21:58:54 +03:30
|
|
|
|
className="pointer-events-none"
|
2026-06-04 10:11:00 +03:30
|
|
|
|
>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
</motion.div>
|
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 10:49:54 +03:30
|
|
|
|
/* ----------------------------- Turn timer ----------------------------- */
|
|
|
|
|
|
|
|
|
|
|
|
function TurnTimer() {
|
|
|
|
|
|
const deadline = useGameStore((s) => s.turnDeadline);
|
|
|
|
|
|
const phase = useGameStore((s) => s.game.phase);
|
2026-06-06 18:39:24 +03:30
|
|
|
|
const stake = useGameStore((s) => s.matchMeta.stake);
|
|
|
|
|
|
const speed = useGameStore((s) => s.matchMeta.speed);
|
2026-06-04 10:49:54 +03:30
|
|
|
|
const secs = useCountdown(deadline);
|
|
|
|
|
|
if (deadline == null || secs == null) return null;
|
|
|
|
|
|
if (phase !== "playing" && phase !== "choosing-trump") return null;
|
2026-06-06 18:39:24 +03:30
|
|
|
|
const pct = Math.max(0, Math.min(1, (deadline - Date.now()) / turnMsForStake(stake, speed)));
|
2026-06-04 10:49:54 +03:30
|
|
|
|
const danger = secs <= 5;
|
|
|
|
|
|
return (
|
2026-06-06 21:58:54 +03:30
|
|
|
|
<div className="w-36 sm:w-40 text-center">
|
2026-06-04 10:49:54 +03:30
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 11:02:25 +03:30
|
|
|
|
/* ----------------------------- 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 11:15:28 +03:30
|
|
|
|
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>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 11:02:25 +03:30
|
|
|
|
function Reactions() {
|
|
|
|
|
|
const profile = useSessionStore((s) => s.profile);
|
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
|
const [open, setOpen] = useState(false);
|
2026-06-04 11:15:28 +03:30
|
|
|
|
const [tab, setTab] = useState<"emoji" | "sticker">("emoji");
|
2026-06-04 11:02:25 +03:30
|
|
|
|
const [bubbles, setBubbles] = useState<Bubble[]>([]);
|
2026-06-04 11:15:28 +03:30
|
|
|
|
const emojis = profile ? ownedReactions(profile) : [];
|
|
|
|
|
|
const stickers = profile ? ownedStickers(profile) : [];
|
2026-06-04 11:02:25 +03:30
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-06-04 11:15:28 +03:30
|
|
|
|
const send = (value: string) => {
|
|
|
|
|
|
getService().sendReaction(value);
|
2026-06-04 11:02:25 +03:30
|
|
|
|
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])}
|
|
|
|
|
|
>
|
2026-06-04 11:15:28 +03:30
|
|
|
|
<ReactionBubble value={b.emoji} />
|
2026-06-04 11:02:25 +03:30
|
|
|
|
</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 }}
|
2026-06-07 00:51:36 +03:30
|
|
|
|
className="absolute bottom-20 ltr:right-4 rtl:left-4 z-50 glass rounded-2xl p-2 w-[min(270px,86vw)]"
|
2026-06-04 11:02:25 +03:30
|
|
|
|
>
|
2026-06-04 11:15:28 +03:30
|
|
|
|
<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>
|
2026-06-04 11:02:25 +03:30
|
|
|
|
<button
|
2026-06-04 11:15:28 +03:30
|
|
|
|
onClick={() => setTab("sticker")}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex-1 rounded-lg py-1.5 text-xs font-bold transition",
|
|
|
|
|
|
tab === "sticker" ? "btn-gold" : "text-cream/60"
|
|
|
|
|
|
)}
|
2026-06-04 11:02:25 +03:30
|
|
|
|
>
|
2026-06-04 11:15:28 +03:30
|
|
|
|
{t("stickers.title")}
|
2026-06-04 11:02:25 +03:30
|
|
|
|
</button>
|
2026-06-04 11:15:28 +03:30
|
|
|
|
</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>
|
|
|
|
|
|
)}
|
2026-06-04 11:02:25 +03:30
|
|
|
|
</motion.div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
|
|
|
|
|
|
{/* button */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setOpen((o) => !o)}
|
2026-06-05 06:38:46 +03:30
|
|
|
|
className="absolute bottom-[max(1rem,env(safe-area-inset-bottom))] ltr:right-4 rtl:left-4 z-50 glass rounded-full min-h-12 min-w-12 grid place-items-center hover:bg-navy-800 transition"
|
2026-06-04 11:02:25 +03:30
|
|
|
|
title={t("reactions.title")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SmilePlus className="size-5 text-gold-400" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
/* ------------------------------ 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();
|
2026-06-04 11:49:19 +03:30
|
|
|
|
const { front } = useCardSkins();
|
2026-06-04 10:11:00 +03:30
|
|
|
|
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 }}
|
|
|
|
|
|
>
|
2026-06-04 11:49:19 +03:30
|
|
|
|
<PlayingCard card={pc.card} size="sm" front={front} />
|
2026-06-04 10:11:00 +03:30
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 18:39:24 +03:30
|
|
|
|
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,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
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
|
2026-06-06 18:39:24 +03:30
|
|
|
|
initial={{ scale: 0.82, y: 24 }}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
animate={{ scale: 1, y: 0 }}
|
2026-06-06 18:39:24 +03:30
|
|
|
|
transition={{ type: "spring", stiffness: 220, damping: 20 }}
|
|
|
|
|
|
className="glass rounded-3xl p-8 text-center max-w-sm w-full relative overflow-hidden"
|
2026-06-04 10:11:00 +03:30
|
|
|
|
>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
{/* 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>
|
2026-06-04 10:11:00 +03:30
|
|
|
|
<h2 className="gold-text text-3xl font-black">{t("round.over")}</h2>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
{r.kot && (
|
|
|
|
|
|
<motion.div
|
2026-06-06 18:39:24 +03:30
|
|
|
|
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"
|
2026-06-04 10:11:00 +03:30
|
|
|
|
>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
{t("round.kot")} 🔥
|
2026-06-04 10:11:00 +03:30
|
|
|
|
</motion.div>
|
|
|
|
|
|
)}
|
2026-06-06 18:39:24 +03:30
|
|
|
|
|
|
|
|
|
|
<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")}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
>
|
|
|
|
|
|
{t("round.won", { team: weWon ? t("team.0") : t("team.1") })}
|
2026-06-06 18:39:24 +03:30
|
|
|
|
</motion.p>
|
|
|
|
|
|
<p className="text-cream/70 mt-2 font-semibold">
|
|
|
|
|
|
{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
</p>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
<p className="text-cream/40 text-sm mt-5 animate-pulse">{t("round.next")}</p>
|
2026-06-04 10:11:00 +03:30
|
|
|
|
</motion.div>
|
|
|
|
|
|
</Backdrop>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 18:39:24 +03:30
|
|
|
|
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,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
function MatchOverlay({ onExit }: { onExit: () => void }) {
|
|
|
|
|
|
const game = useGameStore((s) => s.game);
|
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
|
const youWin = game.matchWinner === 0;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Backdrop>
|
|
|
|
|
|
<motion.div
|
2026-06-06 18:39:24 +03:30
|
|
|
|
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"
|
2026-06-04 10:11:00 +03:30
|
|
|
|
>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
{/* 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>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
<motion.div
|
2026-06-06 18:39:24 +03:30
|
|
|
|
initial={{ rotate: -20, scale: 0 }}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
animate={{ rotate: 0, scale: 1 }}
|
2026-06-06 18:39:24 +03:30
|
|
|
|
transition={{ type: "spring", stiffness: 180, damping: 14 }}
|
|
|
|
|
|
className="text-7xl mb-3 relative"
|
2026-06-04 10:11:00 +03:30
|
|
|
|
>
|
|
|
|
|
|
{youWin ? "🏆" : "🎴"}
|
|
|
|
|
|
</motion.div>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
<h2 className="gold-text text-3xl font-black">{t("match.over")}</h2>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
<p className={cn("mt-3 text-2xl font-bold", youWin ? "text-gold-300" : "text-rose-300")}>
|
2026-06-04 10:11:00 +03:30
|
|
|
|
{youWin ? t("match.youWin") : t("match.youLose")}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-cream/70 mt-2">
|
2026-06-06 18:39:24 +03:30
|
|
|
|
{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}
|
2026-06-04 10:11:00 +03:30
|
|
|
|
</p>
|
2026-06-06 18:39:24 +03:30
|
|
|
|
|
2026-06-05 10:40:14 +03:30
|
|
|
|
<MatchPlayersList />
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
<div className="mt-7 flex gap-3">
|
2026-06-06 18:39:24 +03:30
|
|
|
|
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3 font-black text-lg">
|
2026-06-04 10:11:00 +03:30
|
|
|
|
{t("match.menu")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
</Backdrop>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|