Achievements overhaul: 37 achievements, page with tabs, leagues, gating
Achievements (client + server mirror, metric-driven so the list is one source): - 37 achievements across 6 categories (Victories, Kot, Streaks, Levels, Ranks, Veterancy) incl. 7–0 sweeps, kot milestones (1/5/10/25/50/100), win streaks (3/5/10/15), level milestones every 5 (5..50), rank floors, games/tricks. - New AchievementsScreen with category tabs, progress bars, coin + sticker-unlock badges, and unlocked/locked states; summary header (unlocked count + coins). - Some achievements unlock sticker packs: Seven–Zip→Hokm, 25 Kots→Taunts, 100 Wins→Persian (ownedStickerPackIds now also honors profile.unlocked). - Prestige titles added: Expert, Professional, Captain, Leader (+ existing). - Tracks new stat shutoutWins; MatchSummary.shutout (7–0). Profile shows a 6-item preview + "view all" link. Leagues: 3 ranked entry tiers — Starter (100, lvl1), Pro (500, lvl10), Expert (1000, lvl20). Higher league stakes more, so wins/losses swing bigger; kot bonus now scales to the stake (40%). OnlineLobby shows league cards with level gating. Profile photo upload gated to level 25 (client button + server Update guard). Win animation: PostMatchRewardsModal now shows an animated coins-won count-up hero on a win. Verified: dotnet build + tsc + next build clean; sim unlocks 26 achievements over 500 matches; live server grants first_win/first_kot/shutout_1 and pays 2050 coins on an expert-league shutout+kot win. Images rebuilt on :1500/:1505. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,29 @@
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Coins, Sparkles, Star, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { sound } from "@/lib/sound";
|
||||
import { RewardResult } from "@/lib/online/types";
|
||||
|
||||
/** Animated count-up used for the coins-won hero. */
|
||||
function CountUp({ to }: { to: number }) {
|
||||
const [n, setN] = useState(0);
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const start = performance.now();
|
||||
const dur = 900;
|
||||
const tick = (now: number) => {
|
||||
const p = Math.min(1, (now - start) / dur);
|
||||
setN(Math.round(to * (1 - Math.pow(1 - p, 3)))); // ease-out
|
||||
if (p < 1) raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [to]);
|
||||
return <span className="tabular-nums">{n.toLocaleString()}</span>;
|
||||
}
|
||||
|
||||
export function PostMatchRewardsModal({
|
||||
reward,
|
||||
won,
|
||||
@@ -52,6 +70,26 @@ export function PostMatchRewardsModal({
|
||||
{won ? t("reward.win") : t("reward.lose")}
|
||||
</p>
|
||||
|
||||
{/* Coins-won hero (animated count-up) */}
|
||||
{won && reward.coinsDelta > 0 && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 170, damping: 14, delay: 0.15 }}
|
||||
className="mt-4 flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="text-4xl font-black gold-text">
|
||||
+<CountUp to={reward.coinsDelta} />
|
||||
</span>
|
||||
<motion.span
|
||||
animate={{ rotate: [0, -12, 12, 0], y: [0, -4, 0] }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
>
|
||||
<Coins className="size-8 text-gold-400" />
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{reward.ratingDelta !== 0 && (
|
||||
<RewardRow
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Check, Coins, Sticker } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import {
|
||||
ACHIEVEMENTS,
|
||||
ACHIEVEMENT_CATEGORIES,
|
||||
achievementProgress,
|
||||
stickerPackForAchievement,
|
||||
} from "@/lib/online/gamification";
|
||||
import type { AchievementCategoryId } from "@/lib/online/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export function AchievementsScreen() {
|
||||
const { t, locale } = useI18n();
|
||||
const profile = useSessionStore((s) => s.profile);
|
||||
const [tab, setTab] = useState<AchievementCategoryId>("victory");
|
||||
|
||||
if (!profile) return null;
|
||||
const stats = profile.stats;
|
||||
|
||||
const unlockedCount = ACHIEVEMENTS.filter((a) => profile.unlocked.includes(a.id)).length;
|
||||
const coinsEarned = ACHIEVEMENTS.filter((a) => profile.unlocked.includes(a.id)).reduce(
|
||||
(sum, a) => sum + a.coinReward,
|
||||
0
|
||||
);
|
||||
|
||||
const list = ACHIEVEMENTS.filter((a) => a.category === tab);
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<ScreenHeader title={t("achv.title")} />
|
||||
|
||||
{/* summary */}
|
||||
<div className="glass rounded-2xl p-4 mb-4 flex items-center justify-around text-center">
|
||||
<Stat value={`${unlockedCount}/${ACHIEVEMENTS.length}`} label={t("achv.unlocked")} />
|
||||
<div className="h-8 w-px bg-cream/10" />
|
||||
<Stat
|
||||
value={coinsEarned.toLocaleString()}
|
||||
label={t("achv.coinsEarned")}
|
||||
icon={<Coins className="size-3.5 text-gold-400" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* category tabs */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1 mb-3">
|
||||
{ACHIEVEMENT_CATEGORIES.map((c) => {
|
||||
const active = tab === c.id;
|
||||
const done = ACHIEVEMENTS.filter(
|
||||
(a) => a.category === c.id && profile.unlocked.includes(a.id)
|
||||
).length;
|
||||
const total = ACHIEVEMENTS.filter((a) => a.category === c.id).length;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setTab(c.id)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3.5 py-2 text-sm font-bold transition flex items-center gap-1.5",
|
||||
active ? "btn-gold" : "bg-navy-900/70 gold-border text-cream/70 hover:text-cream"
|
||||
)}
|
||||
>
|
||||
<span>{c.icon}</span>
|
||||
<span>{locale === "fa" ? c.nameFa : c.nameEn}</span>
|
||||
<span className={cn("text-[10px]", active ? "text-[#2a1f04]/70" : "text-cream/40")}>
|
||||
{done}/{total}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* achievement list */}
|
||||
<div className="space-y-2 mb-6">
|
||||
{list.map((a, i) => {
|
||||
const prog = achievementProgress(a, stats, profile.rating, profile.level);
|
||||
const unlocked = profile.unlocked.includes(a.id) || prog >= a.goal;
|
||||
const pct = Math.min(100, Math.round((prog / a.goal) * 100));
|
||||
const pack = stickerPackForAchievement(a.id);
|
||||
return (
|
||||
<motion.div
|
||||
key={a.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(i * 0.03, 0.3) }}
|
||||
className={cn(
|
||||
"rounded-xl p-3 flex items-center gap-3 border",
|
||||
unlocked ? "bg-gold-500/10 border-gold-500/40" : "bg-navy-900/50 border-navy-700/50"
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-3xl shrink-0", !unlocked && "grayscale opacity-50")}>
|
||||
{a.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-cream">
|
||||
{locale === "fa" ? a.nameFa : a.nameEn}
|
||||
</span>
|
||||
{pack && (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/15 text-teal-300 text-[10px] px-1.5 py-0.5">
|
||||
<Sticker className="size-3" />
|
||||
{t("achv.unlocksSticker")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-cream/55 mt-0.5">
|
||||
{locale === "fa" ? a.descFa : a.descEn}
|
||||
</div>
|
||||
{!unlocked && a.goal > 1 && (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 rounded-full bg-navy-900 overflow-hidden">
|
||||
<div className="h-full bg-gold-500/70" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-cream/45 tabular-nums">
|
||||
{prog}/{a.goal}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-end">
|
||||
{unlocked ? (
|
||||
<Check className="size-5 text-gold-400" />
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-gold-300/90 font-bold">
|
||||
+{a.coinReward}
|
||||
<Coins className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
value,
|
||||
label,
|
||||
icon,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-1 text-xl font-black gold-text">
|
||||
{icon}
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-[11px] text-cream/55 mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,8 @@ export function GameScreen() {
|
||||
tricksWon: tally.tricksTeam0,
|
||||
rounds: game.matchScore[0] + game.matchScore[1],
|
||||
trump: game.trump,
|
||||
// shutout = you won and the opponent never scored a round (e.g. 7–0)
|
||||
shutout: game.matchWinner === 0 && game.matchScore[1] === 0,
|
||||
};
|
||||
getService()
|
||||
.submitMatchResult(summary)
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Coins, Trophy, Users } from "lucide-react";
|
||||
import { Coins, Lock, Trophy, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||
import { MATCH_LEAGUES, leagueById } from "@/lib/online/gamification";
|
||||
import { useOnlineStore } from "@/lib/online-store";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useUIStore } from "@/lib/ui-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const ENTRIES = [100, 500, 1000];
|
||||
|
||||
export function OnlineLobbyScreen() {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const createRoom = useOnlineStore((s) => s.createRoom);
|
||||
const startMatchmaking = useOnlineStore((s) => s.startMatchmaking);
|
||||
const go = useUIStore((s) => s.go);
|
||||
const coins = useSessionStore((s) => s.profile?.coins ?? 0);
|
||||
const [entry, setEntry] = useState(100);
|
||||
const profile = useSessionStore((s) => s.profile);
|
||||
const coins = profile?.coins ?? 0;
|
||||
const level = profile?.level ?? 1;
|
||||
const [leagueId, setLeagueId] = useState(MATCH_LEAGUES[0].id);
|
||||
const league = leagueById(leagueId);
|
||||
const entry = league.entry;
|
||||
const lockedLeague = level < league.minLevel;
|
||||
|
||||
// Private rooms with friends are free.
|
||||
const onCreate = async () => {
|
||||
@@ -28,6 +32,7 @@ export function OnlineLobbyScreen() {
|
||||
|
||||
// Ranked random always costs the entry (you stake it).
|
||||
const onRandom = async () => {
|
||||
if (lockedLeague) return;
|
||||
if (coins < entry) {
|
||||
go("buycoins");
|
||||
return;
|
||||
@@ -48,27 +53,59 @@ export function OnlineLobbyScreen() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* entry (only for ranked) */}
|
||||
{/* league pick (only for ranked) */}
|
||||
<div className="glass rounded-2xl p-4 mb-4">
|
||||
<div className="flex items-center gap-1.5 text-sm text-cream/70 mb-2.5">
|
||||
<Coins className="size-4 text-gold-400" />
|
||||
{t("lobby.entry")}
|
||||
<Trophy className="size-4 text-gold-400" />
|
||||
{t("lobby.chooseLeague")}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{ENTRIES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setEntry(s)}
|
||||
className={cn(
|
||||
"flex-1 rounded-xl py-2.5 text-sm font-bold transition",
|
||||
entry === s ? "btn-gold" : "bg-navy-900/70 gold-border text-cream/70 hover:text-cream"
|
||||
)}
|
||||
>
|
||||
{s.toLocaleString()}
|
||||
</button>
|
||||
))}
|
||||
<div className="space-y-2">
|
||||
{MATCH_LEAGUES.map((l) => {
|
||||
const locked = level < l.minLevel;
|
||||
const active = l.id === leagueId;
|
||||
return (
|
||||
<button
|
||||
key={l.id}
|
||||
disabled={locked}
|
||||
onClick={() => setLeagueId(l.id)}
|
||||
className={cn(
|
||||
"w-full rounded-2xl p-3 flex items-center gap-3 border text-start transition",
|
||||
active
|
||||
? "border-gold-500/70 bg-gold-500/10"
|
||||
: "border-navy-700/60 bg-navy-900/50 hover:border-navy-600",
|
||||
locked && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="size-10 rounded-xl flex items-center justify-center text-xl shrink-0"
|
||||
style={{ background: l.color + "22" }}
|
||||
>
|
||||
{l.icon}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-black text-cream">
|
||||
{locale === "fa" ? l.nameFa : l.nameEn}
|
||||
</span>
|
||||
<span className="block text-[11px] text-cream/55">
|
||||
{locale === "fa" ? l.descFa : l.descEn}
|
||||
</span>
|
||||
</span>
|
||||
{locked ? (
|
||||
<span className="text-[11px] text-rose-300 flex items-center gap-1 shrink-0">
|
||||
<Lock className="size-3.5" />
|
||||
{t("lobby.lvl")} {l.minLevel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-gold-300 font-black text-sm shrink-0">
|
||||
{l.entry.toLocaleString()}
|
||||
<Coins className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{coins < entry && (
|
||||
{!lockedLeague && coins < entry && (
|
||||
<p className="text-rose-300 text-xs mt-2 text-center">{t("lobby.needCoins")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Coins, Crown, Music, Pencil, Upload, Volume2 } from "lucide-react";
|
||||
import { Check, ChevronLeft, Coins, Crown, Lock, Music, Pencil, Upload, Volume2 } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||
import { RankBadge } from "@/components/online/RankBadge";
|
||||
@@ -8,6 +8,7 @@ import { XpBar } from "@/components/online/XpBar";
|
||||
import { Avatar } from "@/components/online/Avatar";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useSoundStore } from "@/lib/sound-store";
|
||||
import { useUIStore } from "@/lib/ui-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import {
|
||||
ACHIEVEMENTS,
|
||||
@@ -18,6 +19,9 @@ import {
|
||||
ownedCardBackIds,
|
||||
ownedCardFrontIds,
|
||||
} from "@/lib/online/gamification";
|
||||
|
||||
/** Level required before a player can upload a custom profile photo. */
|
||||
const PHOTO_UPLOAD_MIN_LEVEL = 25;
|
||||
import { AVATARS } from "@/lib/online/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -26,11 +30,13 @@ export function ProfileScreen() {
|
||||
const profile = useSessionStore((s) => s.profile);
|
||||
const updateProfile = useSessionStore((s) => s.updateProfile);
|
||||
const upgradePlan = useSessionStore((s) => s.upgradePlan);
|
||||
const go = useUIStore((st) => st.go);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState(profile?.displayName ?? "");
|
||||
|
||||
if (!profile) return null;
|
||||
const canUpload = profile.level >= PHOTO_UPLOAD_MIN_LEVEL;
|
||||
const s = profile.stats;
|
||||
const winrate = s.games > 0 ? Math.round((s.wins / s.games) * 100) : 0;
|
||||
const titleDef = TITLES.find((x) => x.id === profile.title);
|
||||
@@ -45,7 +51,7 @@ export function ProfileScreen() {
|
||||
|
||||
const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file || !canUpload) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => updateProfile({ avatarImage: String(reader.result) });
|
||||
reader.readAsDataURL(file);
|
||||
@@ -62,11 +68,14 @@ export function ProfileScreen() {
|
||||
<Avatar id={profile.avatar} image={profile.avatarImage} size={profile.avatarImage ? 80 : 56} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="absolute -bottom-1 ltr:-right-1 rtl:-left-1 btn-gold rounded-full p-1.5"
|
||||
title={t("profile.upload")}
|
||||
onClick={() => (canUpload ? fileRef.current?.click() : undefined)}
|
||||
className={cn(
|
||||
"absolute -bottom-1 ltr:-right-1 rtl:-left-1 rounded-full p-1.5",
|
||||
canUpload ? "btn-gold" : "bg-navy-900 gold-border text-cream/50 cursor-not-allowed"
|
||||
)}
|
||||
title={canUpload ? t("profile.upload") : t("profile.uploadLocked")}
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
{canUpload ? <Upload className="size-3.5" /> : <Lock className="size-3.5" />}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onUpload} />
|
||||
</div>
|
||||
@@ -242,10 +251,32 @@ export function ProfileScreen() {
|
||||
|
||||
{/* achievements */}
|
||||
<div className="glass rounded-2xl p-4 mt-4 mb-6">
|
||||
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.achievements")}</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold text-cream/80">
|
||||
{t("profile.achievements")}
|
||||
<span className="text-cream/40 font-normal">
|
||||
{" "}
|
||||
({profile.unlocked.length}/{ACHIEVEMENTS.length})
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => go("achievements")}
|
||||
className="text-xs font-bold text-gold-300 flex items-center gap-0.5 hover:text-gold-200"
|
||||
>
|
||||
{t("achv.viewAll")}
|
||||
<ChevronLeft className="size-3.5 rtl:rotate-0 ltr:rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{ACHIEVEMENTS.map((a) => {
|
||||
const prog = achievementProgress(a.id, s, profile.rating);
|
||||
{[...ACHIEVEMENTS]
|
||||
.sort((a, b) => {
|
||||
const ua = profile.unlocked.includes(a.id) ? 1 : 0;
|
||||
const ub = profile.unlocked.includes(b.id) ? 1 : 0;
|
||||
return ub - ua;
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map((a) => {
|
||||
const prog = achievementProgress(a, s, profile.rating, profile.level);
|
||||
const unlocked = profile.unlocked.includes(a.id) || prog >= a.goal;
|
||||
const pct = Math.min(100, Math.round((prog / a.goal) * 100));
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user