Build Hokm card game: offline vs-AI + online social/gamification (mock backend)
- Pure-TS Hokm engine (deal, hakem, trump, tricks, scoring, Kot) + AI bots - Persian-luxury RTL UI (Next 16 / React 19 / Tailwind v4 / Framer Motion / Zustand) - Online platform behind OnlineService seam (mock now, .NET SignalR later): auth (phone OTP + email/Google), profiles, friends, private rooms with partner pick, ranked matchmaking, leaderboard, shop - Gamification: ranks/leagues, coins, XP/levels, daily rewards, achievements - i18n fa/en, PWA manifest, engine + gamification sims Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ScreenShell } from "@/components/online/ScreenHeader";
|
||||
import { useGameStore } from "@/lib/game-store";
|
||||
import { useOnlineStore } from "@/lib/online-store";
|
||||
import { useUIStore } from "@/lib/ui-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { getService } from "@/lib/online/service";
|
||||
import { avatarEmoji } from "@/lib/online/types";
|
||||
|
||||
export function MatchmakingScreen() {
|
||||
const { t } = useI18n();
|
||||
const mm = useOnlineStore((s) => s.matchmaking);
|
||||
const cancelMatchmaking = useOnlineStore((s) => s.cancelMatchmaking);
|
||||
const newOnlineMatch = useGameStore((s) => s.newOnlineMatch);
|
||||
const goGame = useUIStore((s) => s.goGame);
|
||||
const go = useUIStore((s) => s.go);
|
||||
|
||||
const ready = mm.phase === "ready";
|
||||
const slots = [0, 1, 2, 3];
|
||||
|
||||
const cancel = async () => {
|
||||
await cancelMatchmaking();
|
||||
go("online");
|
||||
};
|
||||
|
||||
const enter = () => {
|
||||
const players = getService().getMatchPlayers();
|
||||
if (!players) return;
|
||||
newOnlineMatch({
|
||||
players: players.map((p) => ({
|
||||
displayName: p.displayName,
|
||||
avatar: p.avatar,
|
||||
level: p.level,
|
||||
})),
|
||||
targetScore: 7,
|
||||
stake: mm.stake,
|
||||
ranked: mm.ranked,
|
||||
});
|
||||
goGame("home");
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<div className="flex flex-col items-center justify-center min-h-[80dvh] text-center">
|
||||
<motion.div
|
||||
animate={ready ? {} : { rotate: 360 }}
|
||||
transition={{ repeat: ready ? 0 : Infinity, duration: 2, ease: "linear" }}
|
||||
className="mb-6"
|
||||
>
|
||||
{ready ? (
|
||||
<span className="text-5xl">✅</span>
|
||||
) : (
|
||||
<Loader2 className="size-12 text-gold-400" />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<h1 className="gold-text text-2xl font-black">
|
||||
{ready ? t("mm.ready") : mm.phase === "found" ? t("mm.found") : t("mm.searching")}
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 mt-8">
|
||||
{slots.map((i) => {
|
||||
const p = mm.players[i];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-16 h-20 rounded-2xl glass flex flex-col items-center justify-center gap-1"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{p ? (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="flex flex-col items-center gap-0.5"
|
||||
>
|
||||
<span className="text-2xl">{avatarEmoji(p.avatar)}</span>
|
||||
<span className="text-[9px] text-cream/70 max-w-14 truncate">
|
||||
{p.displayName}
|
||||
</span>
|
||||
<span className="text-[8px] text-gold-400/70">
|
||||
{t("common.level")} {p.level}
|
||||
</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.span
|
||||
key="empty"
|
||||
className="text-cream/20 text-2xl"
|
||||
animate={{ opacity: [0.2, 0.5, 0.2] }}
|
||||
transition={{ repeat: Infinity, duration: 1.4 }}
|
||||
>
|
||||
?
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex gap-3">
|
||||
<button onClick={cancel} className="glass rounded-xl px-6 py-3 text-cream/70 hover:text-cream">
|
||||
{t("mm.cancel")}
|
||||
</button>
|
||||
{ready && (
|
||||
<motion.button
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
onClick={enter}
|
||||
className="btn-gold rounded-xl px-8 py-3 text-lg"
|
||||
>
|
||||
{t("mm.start")}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user