feat: UNO-style table, social hub, cosmetics, speed mode, store IAB
Game table & play - UNO-style restyle: suit-aware bolder cards (+xl size), pulsing playable glow, big "YOUR TURN" pill, active-seat ring, trick-win particle burst, round confetti, match coin-rain. - Per-league turn time via turnMsForStake: 15s starter/AI, 10s pro, 7s expert; mirrored server-side in GameRoom.TurnMs. - Speed (Blitz) mode for vs-AI/private: 5s turns, race to 5, ~halved pacing. - Matchmaking waits ~15s (randomized 12-18s) then fills bots; elapsed timer + hint. Rewards / gifts - Richer post-match modal (floating coins, XP bar), celebration overlay reveals the unlocked sticker pack, boosted daily rewards (client+server synced), themed 7-day daily with special day-7. Social - Public profile modal (identity, stats, achievement board) from leaderboard / friends / discover / end-of-game roster; rate-limited add-friend (10/hour). - Social hub: Friends / Discover (player search + suggestions) / Messages inbox. - Profile gender (shown in finder/profile) + social links with public/friends/ hidden visibility, enforced server-side. Cosmetics - Distinct card backs: per-design pattern families (stripes/argyle/grid/dots/ rays/scales/crosshatch/royal/filigree/gem) + luxury motifs (lib/cardBack.ts), consistent on table/shop/profile; +Peacock/Rose-Gold backs. - Purchasable titles (shop Titles section); title shown under the seat on the table and in discover/public profile. - 10 new sticker packs (banter/kol-kol, Persian trends, court cards, moods). - Persistent level+XP bar on Home and every inner screen. Payments - Buy-coins gateway opens in a new tab (no SPA dead-end) + focus refresh. - Store IAB scaffolding: Cafe Bazaar deep-link purchase + redirect-token capture, Myket native-bridge contract, server-side IabService.Verify for both stores, config-driven via Iab__* env. POST /api/coins/iab/verify (JWT). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+318
-104
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TURN_MS, useGameStore } from "@/lib/game-store";
|
||||
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";
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@/lib/hokm/types";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { cardBackById, cardFrontById, ownedReactions, ownedStickers } from "@/lib/online/gamification";
|
||||
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";
|
||||
@@ -43,7 +43,7 @@ function useCardSkins() {
|
||||
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 },
|
||||
back: { c1: b.c1, c2: b.c2, accent: b.accent, pattern: b.pattern, motif: b.motif },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,11 +68,48 @@ export function GameTable({
|
||||
const trickScale = vw < 360 ? 0.5 : vw < 460 ? 0.64 : 1;
|
||||
const { phase, players, hakem, trump, turn, currentTrick } = game;
|
||||
|
||||
const legalIds = new Set(
|
||||
phase === "playing" && turn === 0
|
||||
? legalMoves(game, 0).map((c) => c.id)
|
||||
: []
|
||||
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 (
|
||||
<main className="persian-pattern relative h-dvh w-full overflow-hidden">
|
||||
@@ -80,6 +117,7 @@ export function GameTable({
|
||||
<div className="absolute top-0 inset-x-0 z-30 flex items-start justify-between gap-2 safe-top safe-x pb-3 sm:p-4">
|
||||
<Scoreboard />
|
||||
<div className="flex items-center gap-2">
|
||||
<SpeedBadge />
|
||||
{trump && <TrumpBadge trump={trump} />}
|
||||
<button
|
||||
onClick={toggleAll}
|
||||
@@ -170,6 +208,7 @@ export function GameTable({
|
||||
<TurnTimer />
|
||||
<DisconnectBanner />
|
||||
<Reactions />
|
||||
<ShortcutsHint />
|
||||
|
||||
{/* Overlays */}
|
||||
<AnimatePresence>
|
||||
@@ -237,6 +276,25 @@ function ScoreCol({
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- 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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Trump badge ---------------------------- */
|
||||
|
||||
function TrumpBadge({ trump }: { trump: Suit }) {
|
||||
@@ -268,6 +326,7 @@ function TrumpBadge({ trump }: { trump: Suit }) {
|
||||
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) ||
|
||||
@@ -275,30 +334,33 @@ function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
|
||||
const isHakem = game.hakem === seat;
|
||||
const team = teamOf(seat);
|
||||
const name = sp?.name ?? player.name;
|
||||
const titleDef = titleById(sp?.title);
|
||||
const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null;
|
||||
|
||||
return (
|
||||
<div className={cn("z-20 flex flex-col items-center gap-1", className)}>
|
||||
<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)" }
|
||||
}
|
||||
<div
|
||||
className={cn(
|
||||
"relative size-10 sm:size-12 rounded-full flex items-center justify-center font-bold text-lg sm:text-xl",
|
||||
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100"
|
||||
"relative size-12 sm:size-14 rounded-full flex items-center justify-center font-bold text-xl sm:text-2xl transition-all",
|
||||
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100",
|
||||
active && "active-player-ring"
|
||||
)}
|
||||
style={!active ? { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" } : undefined}
|
||||
>
|
||||
{sp?.avatar ?? name.charAt(0)}
|
||||
{isHakem && (
|
||||
<Crown className="absolute -top-3 size-4 text-gold-400 fill-gold-500" />
|
||||
<Crown className="absolute -top-4 size-5 text-gold-400 fill-gold-500 drop-shadow" />
|
||||
)}
|
||||
</motion.div>
|
||||
{active && (
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 size-2.5 rounded-full bg-gold-400 ring-2 ring-navy-900" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-cream max-w-20 truncate hud-shadow">{name}</span>
|
||||
{titleName && (
|
||||
<span className="text-[9px] font-bold gold-text leading-none max-w-24 truncate hud-shadow">{titleName}</span>
|
||||
)}
|
||||
{sp && sp.level > 0 && (
|
||||
<span className="text-[9px] text-gold-300 leading-none hud-shadow">
|
||||
{`Lv ${sp.level}`}
|
||||
</span>
|
||||
<span className="text-[10px] text-gold-300/80 leading-none hud-shadow">{`Lv ${sp.level}`}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -318,22 +380,26 @@ function OpponentHand({
|
||||
const count = useGameStore((s) => s.game.players[seat].hand.length);
|
||||
const { back } = useCardSkins();
|
||||
const cards = Array.from({ length: count });
|
||||
const mid = (count - 1) / 2;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
horizontal ? "flex-row" : "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 className={cn("flex", horizontal ? "flex-row items-end" : "flex-col items-center", className)}>
|
||||
{cards.map((_, i) => {
|
||||
const rot = horizontal ? (i - mid) * 4 : 0;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
...(horizontal
|
||||
? { marginInlineStart: i === 0 ? 0 : -30 }
|
||||
: { marginTop: i === 0 ? 0 : -46 }),
|
||||
transform: rot ? `rotate(${rot}deg)` : undefined,
|
||||
transformOrigin: "bottom center",
|
||||
}}
|
||||
>
|
||||
<PlayingCard faceDown size="sm" back={back} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -367,29 +433,23 @@ function TrickArea({
|
||||
const { front } = useCardSkins();
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative size-1 ">
|
||||
<div className="relative size-1">
|
||||
<AnimatePresence>
|
||||
{trick.map((pc) => {
|
||||
const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale };
|
||||
const enter = TRICK_ENTER[pc.seat];
|
||||
const isWinner =
|
||||
phase === "trick-complete" && winner === pc.seat;
|
||||
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,
|
||||
}}
|
||||
animate={{ x: off.x, y: off.y, opacity: 1, scale: isWinner ? 1.14 : 1 }}
|
||||
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.25 } }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 26 }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
filter: isWinner
|
||||
? "drop-shadow(0 0 14px rgba(212,175,55,0.9))"
|
||||
? "drop-shadow(0 0 18px rgba(212,175,55,1)) drop-shadow(0 0 6px rgba(255,240,120,0.8))"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
@@ -398,11 +458,55 @@ function TrickArea({
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
{/* Burst particles when trick is won */}
|
||||
<AnimatePresence>
|
||||
{phase === "trick-complete" && winner != null && (
|
||||
<TrickBurst key={`burst-${winner}`} seat={winner} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* particles that fly out from center when you win a trick */
|
||||
const BURST_ANGLES = Array.from({ length: 10 }, (_, i) => {
|
||||
const a = (i / 10) * 2 * Math.PI;
|
||||
const d = 55 + (i % 3) * 22;
|
||||
return { id: i, x: Math.cos(a) * d, y: Math.sin(a) * d, size: 7 + (i % 3) * 5 };
|
||||
});
|
||||
|
||||
function TrickBurst({ seat }: { seat: Seat }) {
|
||||
const team = teamOf(seat);
|
||||
const gradient = team === 0
|
||||
? "radial-gradient(circle,#2dd4bf,#0d9488)"
|
||||
: "radial-gradient(circle,#fb7185,#e11d48)";
|
||||
const glowColor = team === 0 ? "rgba(45,212,191,0.55)" : "rgba(251,113,133,0.55)";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* centre flash */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0.85 }}
|
||||
animate={{ scale: 3.5, opacity: 0 }}
|
||||
transition={{ duration: 0.38 }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none"
|
||||
style={{ width: 44, height: 44, background: glowColor }}
|
||||
/>
|
||||
{BURST_ANGLES.map(p => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}
|
||||
animate={{ x: p.x, y: p.y, opacity: 0, scale: 0 }}
|
||||
transition={{ duration: 0.55, ease: [0.2, 0.8, 0.4, 1] }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none"
|
||||
style={{ width: p.size, height: p.size, background: gradient }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Player hand ---------------------------- */
|
||||
|
||||
function useViewportWidth() {
|
||||
@@ -430,18 +534,21 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||
|
||||
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;
|
||||
|
||||
// Compress the fan so every card fits the screen width (no overflow/scroll).
|
||||
const small = vw < 560;
|
||||
const size = vw < 360 ? "sm" : vw < 560 ? "md" : "lg";
|
||||
const cardW = size === "sm" ? 44 : size === "md" ? 60 : 74;
|
||||
const size = vw < 360 ? "sm" : vw < 480 ? "md" : vw < 640 ? "lg" : "xl";
|
||||
const cardW = size === "sm" ? 44 : size === "md" ? 62 : size === "lg" ? 78 : 92;
|
||||
const avail = Math.min(vw - 12, 620);
|
||||
const step = n > 1 ? Math.min(cardW * 0.94, Math.max(15, (avail - cardW) / (n - 1))) : 0;
|
||||
const overlap = step - cardW; // negative inline-start margin
|
||||
|
||||
// Desktop (with a keyboard) gets numbered shortcut badges on playable cards.
|
||||
const showShortcutBadges = vw >= 768;
|
||||
let playableSeq = 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -456,6 +563,8 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||
const mid = (n - 1) / 2;
|
||||
const rot = (i - mid) * (small ? 2 : 3.2);
|
||||
const lift = Math.abs(i - mid) * (small ? 2 : 4);
|
||||
const shortcutNum = playable ? ++playableSeq : 0;
|
||||
const badge = shortcutNum >= 1 && shortcutNum <= 10 ? (shortcutNum === 10 ? "0" : String(shortcutNum)) : null;
|
||||
return (
|
||||
<motion.button
|
||||
key={card.id}
|
||||
@@ -471,8 +580,9 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||
data-playable={playable ? "1" : "0"}
|
||||
style={{ marginInlineStart: i === 0 ? 0 : overlap }}
|
||||
className={cn(
|
||||
"origin-bottom shrink-0",
|
||||
playable ? "cursor-pointer relative z-30" : "cursor-default"
|
||||
"origin-bottom shrink-0 relative",
|
||||
playable ? "cursor-pointer z-30" : "cursor-default",
|
||||
playable && "card-playable"
|
||||
)}
|
||||
>
|
||||
<PlayingCard
|
||||
@@ -480,8 +590,12 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||
size={size}
|
||||
dimmed={dimmed}
|
||||
front={front}
|
||||
className={cn(playable && "ring-2 ring-gold-400/80")}
|
||||
/>
|
||||
{showShortcutBadges && badge && (
|
||||
<span className="absolute -top-2 left-1/2 -translate-x-1/2 z-40 size-5 rounded-full btn-gold text-[11px] font-black grid place-items-center shadow-md">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
@@ -490,6 +604,52 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------- 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>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------- Turn indicator --------------------------- */
|
||||
|
||||
function TurnIndicator() {
|
||||
@@ -502,19 +662,28 @@ function TurnIndicator() {
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={game.turn}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute bottom-[120px] sm:bottom-[150px] left-1/2 -translate-x-1/2 z-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 }}
|
||||
className="absolute bottom-[136px] sm:bottom-[168px] 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>
|
||||
{isYou ? (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.055, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 1.05, ease: "easeInOut" }}
|
||||
className="btn-gold press-3d rounded-full px-7 py-2.5 font-black text-[15px] tracking-wide"
|
||||
style={{
|
||||
boxShadow: "0 0 0 3px rgba(212,175,55,0.55), 0 0 28px rgba(212,175,55,0.45), 0 5px 0 rgba(0,0,0,0.32)",
|
||||
}}
|
||||
>
|
||||
✨ {t("turn.you")}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="glass rounded-full px-5 py-2 text-sm font-semibold text-cream/70">
|
||||
{t("turn.other", { name })}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
@@ -525,10 +694,12 @@ function TurnIndicator() {
|
||||
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()) / TURN_MS));
|
||||
const pct = Math.max(0, Math.min(1, (deadline - Date.now()) / turnMsForStake(stake, speed)));
|
||||
const danger = secs <= 5;
|
||||
return (
|
||||
<div className="absolute bottom-[156px] sm:bottom-[190px] left-1/2 -translate-x-1/2 z-30 w-36 sm:w-40 text-center">
|
||||
@@ -808,6 +979,15 @@ function TrumpChooser() {
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -817,43 +997,68 @@ function RoundOverlay() {
|
||||
return (
|
||||
<Backdrop>
|
||||
<motion.div
|
||||
initial={{ scale: 0.85, y: 20 }}
|
||||
initial={{ scale: 0.82, y: 24 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
className="glass rounded-3xl p-8 text-center max-w-sm w-full"
|
||||
transition={{ type: "spring", stiffness: 220, damping: 20 }}
|
||||
className="glass rounded-3xl p-8 text-center max-w-sm w-full relative overflow-hidden"
|
||||
>
|
||||
{/* confetti on win */}
|
||||
{weWon && CONFETTI_SPECS.map(p => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
initial={{ y: -10, opacity: 1, rotate: p.rot }}
|
||||
animate={{ y: 160, opacity: 0, rotate: p.rot + 450 }}
|
||||
transition={{ duration: 1.5 + p.delay * 0.4, delay: p.delay, ease: "linear" }}
|
||||
className="absolute pointer-events-none rounded-sm"
|
||||
style={{ width: p.size, height: p.size, background: p.color, left: `${p.left}%`, top: 0 }}
|
||||
/>
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -10 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 16 }}
|
||||
className="text-5xl mb-1"
|
||||
>
|
||||
{weWon ? "🎉" : "😤"}
|
||||
</motion.div>
|
||||
<h2 className="gold-text text-3xl font-black">{t("round.over")}</h2>
|
||||
|
||||
{r.kot && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
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"
|
||||
initial={{ scale: 0, rotate: -15 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 350, damping: 14, delay: 0.15 }}
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-full btn-gold press-3d px-6 py-2 text-xl font-black"
|
||||
>
|
||||
{t("round.kot")}🔥
|
||||
{t("round.kot")} 🔥
|
||||
</motion.div>
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
"mt-4 text-xl font-bold",
|
||||
weWon ? "text-teal-300" : "text-rose-300"
|
||||
)}
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.22 }}
|
||||
className={cn("mt-4 text-2xl font-black", weWon ? "text-teal-300" : "text-rose-300")}
|
||||
>
|
||||
{t("round.won", { team: weWon ? t("team.0") : t("team.1") })}
|
||||
</motion.p>
|
||||
<p className="text-cream/70 mt-2 font-semibold">
|
||||
{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}
|
||||
</p>
|
||||
<p className="text-cream/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>
|
||||
<p className="text-cream/40 text-sm mt-5 animate-pulse">{t("round.next")}</p>
|
||||
</motion.div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
const WIN_COINS = Array.from({ length: 14 }, (_, i) => ({
|
||||
id: i,
|
||||
left: 5 + ((i * 6.8) % 88),
|
||||
delay: (i * 0.11) % 1.6,
|
||||
fontSize: 18 + (i % 3) * 10,
|
||||
}));
|
||||
|
||||
function MatchOverlay({ onExit }: { onExit: () => void }) {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const { t } = useI18n();
|
||||
@@ -861,37 +1066,46 @@ function MatchOverlay({ onExit }: { onExit: () => void }) {
|
||||
return (
|
||||
<Backdrop>
|
||||
<motion.div
|
||||
initial={{ scale: 0.85 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="glass rounded-3xl p-9 text-center max-w-sm w-full"
|
||||
initial={{ scale: 0.82, y: 16 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 18 }}
|
||||
className="glass rounded-3xl p-9 text-center max-w-sm w-full relative overflow-hidden"
|
||||
>
|
||||
{/* coin rain on win */}
|
||||
{youWin && WIN_COINS.map(c => (
|
||||
<motion.div
|
||||
key={c.id}
|
||||
initial={{ top: "-30px", opacity: 0, rotate: 0 }}
|
||||
animate={{ top: "110%", opacity: [0, 1, 1, 0], rotate: 540 }}
|
||||
transition={{ duration: 2.2 + c.delay * 0.3, delay: c.delay, ease: "easeIn" }}
|
||||
className="absolute pointer-events-none select-none"
|
||||
style={{ left: `${c.left}%`, fontSize: c.fontSize }}
|
||||
>
|
||||
🪙
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
initial={{ rotate: -15, scale: 0 }}
|
||||
initial={{ rotate: -20, scale: 0 }}
|
||||
animate={{ rotate: 0, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 160 }}
|
||||
className="text-6xl mb-3"
|
||||
transition={{ type: "spring", stiffness: 180, damping: 14 }}
|
||||
className="text-7xl mb-3 relative"
|
||||
>
|
||||
{youWin ? "🏆" : "🎴"}
|
||||
</motion.div>
|
||||
|
||||
<h2 className="gold-text text-3xl font-black">{t("match.over")}</h2>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-3 text-2xl font-bold",
|
||||
youWin ? "text-gold-300" : "text-rose-300"
|
||||
)}
|
||||
>
|
||||
<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],
|
||||
})}
|
||||
{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}
|
||||
</p>
|
||||
|
||||
<MatchPlayersList />
|
||||
|
||||
<div className="mt-7 flex gap-3">
|
||||
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3">
|
||||
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3 font-black text-lg">
|
||||
{t("match.menu")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user