Match intro "players joining" loading screen + i18n fix; checkpoint
- MatchIntroOverlay: UNO-style pre-game reveal — the 4 seats animate into the table (with "?" placeholders until each player's data streams in for live matches), a 3-2-1-GO countdown, then the table shows. Wired via game-store matchIntroPending/consumeIntro, rendered online-only in GameScreen. - Fix: intro.found / intro.getReady / intro.go existed only in the Persian dict; added the English strings (would have shown raw keys to EN users). - Checkpoint of the in-progress UI/social batch (CoinsPill, shop titles section, friend-request rate limit, etc.) — all green. Verified: tsc + next build + scripts/sim.ts + dotnet build server/Hokm.slnx all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -63,7 +63,7 @@ npm run build # next static export
|
||||
- Online multiplayer over SignalR: matchmaking (pro skips queue, bots fill after wait), live server-run ranked games, server-authoritative entry/rewards. **Matchmaking waits ~15s (randomized 12–18s) for humans, then bots fill** (`GameManager.NextQueueWaitMs`; mock mirrors it in `beginSearch`). MatchmakingScreen shows the elapsed timer + a bot-fill hint.
|
||||
- **Per-league turn time** (think faster in higher leagues): Starter/vs-AI/private → **15s**, Pro (stake ≥500) → **10s**, Expert (stake ≥1000) → **7s**. Single source: `turnMsForStake(stake, speed?)` in `gamification.ts`; the live server mirrors it in `GameRoom.TurnMs`. The turn-timer bar reads it from `matchMeta.stake`.
|
||||
- **Speed (Blitz) mode** — CLIENT-ONLY (vs-AI + private rooms; ranked stays standard). Flat **5s** turn clock (`SPEED_TURN_MS`), races to **5** points (`SPEED_TARGET_SCORE`), and ~½ pacing on animations/pauses (the `fast()` scaler in `game-store.scheduleAuto`). Threaded via `matchMeta.speed` + `GameSettings.speed`/`OnlineMatchConfig.speed`. Toggle on Home's vs-Computer card; a `SpeedBadge` (⚡) shows on the table HUD. No server change needed — private rooms are client-driven even in live mode, and ranked is intentionally excluded.
|
||||
- **Economy:** coins; ranked entry = stake (win +stake [+kot 40], lose −stake); free vs-computer/private rooms. Buy-coins via **ZarinPal sandbox** (merchant `299685fb-cadf-4dfc-98e2-d4af5d81528d`, config-driven). Coin packs: starter 50k/95,000﷼, … Stores (Bazaar/Myket) must use their **IAB** (`/api/coins/iab/verify` scaffolded; token verification TODO).
|
||||
- **Economy:** coins; ranked entry = stake (win +stake [+kot 40], lose −stake); free vs-computer/private rooms. Buy-coins via **ZarinPal sandbox** (merchant `299685fb-cadf-4dfc-98e2-d4af5d81528d`, config-driven) + store IAB. **⚠️ Economy is balanced as one system — keep ALL of these in sync client↔server when tuning** (`gamification.ts` ↔ `Profiles/Gamification.cs` + `ProfileService`): coin packs (`p1` 5,000c/99k﷼ · `p2` 12,000c/199k · `p3` 28,000c/399k · `p4` 65,000c/799k — a starter pack ≈ one premium cosmetic, NOT "buy everything"), item prices (cosmetics 600–6,000c, XP packs 1,500/4,000/8,000c), achievement coin reward `min(1500, max(50, round((40+goal·6)/50)·50))`, rank rewards 150/300/500/900/1500, daily `[100,150,200,300,400,600,1500]`. Stores (Bazaar/Myket) **IAB** — see §6.
|
||||
- **XP/levels:** every game grants XP, **winner ×2**; **premium (pro) ×1.5**; max level 100; curve `100*lvl + 15*lvl²`. **Store sells XP packs** (xp1 +200/5k, xp2 +600/12k, xp3 +1500/25k coins; consumable; unlocks level achievements).
|
||||
- **Achievements:** ~100, metric-driven generator (categories: victory/kot/streak/hakem/level/rank/veteran), incl. "7× hakem", "7–0 sweep". Dedicated **AchievementsScreen** (tabbed) + Profile summary. Some unlock **sticker packs**.
|
||||
- **Cosmetics:** avatars, titles (incl. expert/professional/captain/leader ladder), card **front**+**back**, reaction packs, **16 sticker packs** (all custom inline-SVG art in `Sticker.tsx`). **✨ Luxury tier** (premium giftable items): luxury avatars (🦢🎩💎💰🏆 + 💠 rank-gated), luxury card backs (Diamond/Black Gold/Platinum/Peacock/Rose-Gold) + fronts (Diamond/Black Gold), luxury titles (Hokm Sultan/Emperor/Grandmaster). Shop tags items priced ≥2000 with a gold **«ویژه/Luxury»** badge + ring.
|
||||
|
||||
@@ -56,7 +56,7 @@ public static class Gamification
|
||||
// metric: wins|kotsFor|bestWinStreak|shutoutWins|games|tricks|level ; ratingFloor>0 = rank ach.
|
||||
private record AchDef(string Id, string? Metric, int RatingFloor, int Goal, int Coin, string NameFa, string NameEn, string Icon);
|
||||
// Mirrors src/lib/online/gamification.ts (same ids/goals/coins/metrics).
|
||||
private static int Coin(int g) => Math.Max(100, (int)Math.Floor((80.0 + g * 12) / 50.0 + 0.5) * 50);
|
||||
private static int Coin(int g) => Math.Min(1500, Math.Max(50, (int)Math.Floor((40.0 + g * 6) / 50.0 + 0.5) * 50));
|
||||
private static string Fa(int n) =>
|
||||
new string(n.ToString().Select(c => c is >= '0' and <= '9' ? "۰۱۲۳۴۵۶۷۸۹"[c - '0'] : c).ToArray());
|
||||
|
||||
@@ -77,11 +77,11 @@ public static class Gamification
|
||||
l.AddRange(Tier("roundsWon", "rounds", "🎴", new[] { 25, 100, 250, 500, 1000, 2000, 5000 }, g => $"{Fa(g)} دست برده", g => $"{g} Rounds Won"));
|
||||
l.AddRange(Tier("tricks", "tricks", "🗂️", new[] { 50, 100, 250, 500, 1000, 2500, 5000, 10000 }, g => $"{Fa(g)} دستبرد", g => $"{g} Tricks"));
|
||||
l.AddRange(Tier("losses", "grit", "🛡️", new[] { 10, 50, 100 }, g => $"{Fa(g)} باخت", g => $"{g} Losses"));
|
||||
l.Add(new AchDef("reach_silver", null, 1100, 1, 200, "لیگ نقره", "Reach Silver", "🥈"));
|
||||
l.Add(new AchDef("reach_gold", null, 1300, 1, 500, "لیگ طلا", "Reach Gold", "🥇"));
|
||||
l.Add(new AchDef("reach_platinum", null, 1500, 1, 1000, "لیگ پلاتین", "Reach Platinum", "🛡️"));
|
||||
l.Add(new AchDef("reach_diamond", null, 1700, 1, 2000, "لیگ الماس", "Reach Diamond", "💠"));
|
||||
l.Add(new AchDef("reach_master", null, 1900, 1, 4000, "لیگ استاد", "Reach Master", "👑"));
|
||||
l.Add(new AchDef("reach_silver", null, 1100, 1, 150, "لیگ نقره", "Reach Silver", "🥈"));
|
||||
l.Add(new AchDef("reach_gold", null, 1300, 1, 300, "لیگ طلا", "Reach Gold", "🥇"));
|
||||
l.Add(new AchDef("reach_platinum", null, 1500, 1, 500, "لیگ پلاتین", "Reach Platinum", "🛡️"));
|
||||
l.Add(new AchDef("reach_diamond", null, 1700, 1, 900, "لیگ الماس", "Reach Diamond", "💠"));
|
||||
l.Add(new AchDef("reach_master", null, 1900, 1, 1500, "لیگ استاد", "Reach Master", "👑"));
|
||||
return l.ToArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ public class ProfileService
|
||||
|
||||
public static readonly CoinPackDto[] Packs =
|
||||
{
|
||||
new() { Id = "p1", Coins = 50000, Bonus = 0, PriceToman = 95000, Tag = "starter" },
|
||||
new() { Id = "p2", Coins = 120000, Bonus = 15000, PriceToman = 189000, Tag = "popular" },
|
||||
new() { Id = "p3", Coins = 300000, Bonus = 50000, PriceToman = 389000, Tag = "best" },
|
||||
new() { Id = "p4", Coins = 700000, Bonus = 150000, PriceToman = 790000 },
|
||||
new() { Id = "p1", Coins = 5000, Bonus = 0, PriceToman = 99000, Tag = "starter" },
|
||||
new() { Id = "p2", Coins = 11000, Bonus = 1000, PriceToman = 199000, Tag = "popular" },
|
||||
new() { Id = "p3", Coins = 24000, Bonus = 4000, PriceToman = 399000, Tag = "best" },
|
||||
new() { Id = "p4", Coins = 50000, Bonus = 15000, PriceToman = 799000 },
|
||||
};
|
||||
|
||||
private static ProfileDto Default(string userId, string? name) => new()
|
||||
@@ -120,9 +120,9 @@ public class ProfileService
|
||||
// Coin-priced XP packs (XP is intentionally expensive). Server-authoritative.
|
||||
public static readonly Dictionary<string, (int Price, int Xp)> XpPacks = new()
|
||||
{
|
||||
["xp1"] = (5000, 200),
|
||||
["xp2"] = (12000, 600),
|
||||
["xp3"] = (25000, 1500),
|
||||
["xp1"] = (1500, 200),
|
||||
["xp2"] = (4000, 600),
|
||||
["xp3"] = (8000, 1500),
|
||||
};
|
||||
|
||||
public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price)
|
||||
@@ -165,7 +165,7 @@ public class ProfileService
|
||||
/* ----------------------------- daily ------------------------------ */
|
||||
|
||||
// Mirror the client DAILY_REWARDS (src/lib/online/gamification.ts) exactly.
|
||||
private static readonly int[] DailyRewards = { 300, 500, 750, 1000, 1500, 2500, 7500 };
|
||||
private static readonly int[] DailyRewards = { 100, 150, 200, 300, 400, 600, 1500 };
|
||||
private static string Today => DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||
|
||||
public async Task<(int day, string? lastClaimed, bool available)> GetDaily(string uid)
|
||||
|
||||
@@ -203,9 +203,11 @@ export function GameTable({
|
||||
{/* Your hand */}
|
||||
<PlayerHand legalIds={legalIds} />
|
||||
|
||||
{/* Turn indicator */}
|
||||
<TurnIndicator />
|
||||
<TurnTimer />
|
||||
{/* 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>
|
||||
<DisconnectBanner />
|
||||
<Reactions />
|
||||
<ShortcutsHint />
|
||||
@@ -666,7 +668,7 @@ function TurnIndicator() {
|
||||
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"
|
||||
className="pointer-events-none"
|
||||
>
|
||||
{isYou ? (
|
||||
<motion.div
|
||||
@@ -702,7 +704,7 @@ function TurnTimer() {
|
||||
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">
|
||||
<div className="w-36 sm:w-40 text-center">
|
||||
<span
|
||||
className={cn(
|
||||
"block text-sm font-black tabular-nums mb-1",
|
||||
|
||||
@@ -27,6 +27,47 @@ interface Props {
|
||||
front?: CardFront;
|
||||
}
|
||||
|
||||
/* Pip positions (x,y as fractions of the card) for number cards 2–10. Bottom-half
|
||||
pips (y>0.5) render rotated 180°, like a real deck — so each rank looks distinct. */
|
||||
const PL = 0.3, PC = 0.5, PR = 0.7;
|
||||
const PIP_LAYOUT: Record<number, [number, number][]> = {
|
||||
2: [[PC, 0.24], [PC, 0.76]],
|
||||
3: [[PC, 0.22], [PC, 0.5], [PC, 0.78]],
|
||||
4: [[PL, 0.26], [PR, 0.26], [PL, 0.74], [PR, 0.74]],
|
||||
5: [[PL, 0.26], [PR, 0.26], [PC, 0.5], [PL, 0.74], [PR, 0.74]],
|
||||
6: [[PL, 0.24], [PR, 0.24], [PL, 0.5], [PR, 0.5], [PL, 0.76], [PR, 0.76]],
|
||||
7: [[PL, 0.23], [PR, 0.23], [PC, 0.365], [PL, 0.5], [PR, 0.5], [PL, 0.77], [PR, 0.77]],
|
||||
8: [[PL, 0.23], [PR, 0.23], [PC, 0.365], [PL, 0.5], [PR, 0.5], [PC, 0.635], [PL, 0.77], [PR, 0.77]],
|
||||
9: [[PL, 0.22], [PR, 0.22], [PL, 0.41], [PR, 0.41], [PC, 0.5], [PL, 0.59], [PR, 0.59], [PL, 0.78], [PR, 0.78]],
|
||||
10: [[PL, 0.22], [PR, 0.22], [PC, 0.32], [PL, 0.41], [PR, 0.41], [PL, 0.59], [PR, 0.59], [PC, 0.68], [PL, 0.78], [PR, 0.78]],
|
||||
};
|
||||
|
||||
function Pips({ rank, symbol, color, w }: { rank: number; symbol: string; color: string; w: number }) {
|
||||
const layout = PIP_LAYOUT[rank];
|
||||
if (!layout) return null;
|
||||
const size = (rank >= 9 ? 0.165 : rank >= 7 ? 0.19 : 0.22) * w;
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{layout.map(([x, y], i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="absolute font-bold"
|
||||
style={{
|
||||
left: `${x * 100}%`,
|
||||
top: `${y * 100}%`,
|
||||
transform: `translate(-50%,-50%)${y > 0.5 ? " rotate(180deg)" : ""}`,
|
||||
fontSize: size,
|
||||
lineHeight: 1,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{symbol}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayingCard({
|
||||
card,
|
||||
faceDown,
|
||||
@@ -81,6 +122,7 @@ export function PlayingCard({
|
||||
const red = SUIT_IS_RED[card.suit];
|
||||
const symbol = SUIT_SYMBOL[card.suit];
|
||||
const label = rankLabel(card.rank);
|
||||
const rank = card.rank;
|
||||
|
||||
// UNO-style: suit-aware background
|
||||
const cardBg = front
|
||||
@@ -114,18 +156,21 @@ export function PlayingCard({
|
||||
<div style={{ color: pipColor, fontSize: "0.82em" }}>{symbol}</div>
|
||||
</div>
|
||||
|
||||
{/* Center symbol — large, bold, slightly shadowed */}
|
||||
<div
|
||||
className={cn("absolute inset-0 flex items-center justify-center font-black", s.center)}
|
||||
style={{
|
||||
color: inkColor,
|
||||
textShadow: red
|
||||
? "0 2px 10px rgba(210,40,40,0.18)"
|
||||
: "0 2px 10px rgba(28,28,56,0.12)",
|
||||
}}
|
||||
>
|
||||
{symbol}
|
||||
</div>
|
||||
{/* Center: pip layout for 2–10, big rank for J/Q/K, single big pip for Ace */}
|
||||
{rank >= 2 && rank <= 10 ? (
|
||||
<Pips rank={rank} symbol={symbol} color={pipColor} w={s.w} />
|
||||
) : rank === 14 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center font-black"
|
||||
style={{ color: pipColor, textShadow: "0 2px 10px rgba(0,0,0,0.12)" }}>
|
||||
<span style={{ fontSize: s.w * 0.54, lineHeight: 1 }}>{symbol}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center font-black"
|
||||
style={{ lineHeight: 0.9 }}>
|
||||
<span style={{ fontSize: s.w * 0.46, color: inkColor }}>{label}</span>
|
||||
<span style={{ fontSize: s.w * 0.27, color: pipColor, marginTop: s.w * -0.03 }}>{symbol}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom-right corner (rotated 180°) */}
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Coins, Plus } from "lucide-react";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useUIStore } from "@/lib/ui-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
/**
|
||||
* The coin balance, as a button that opens the buy-coins store. Use it anywhere
|
||||
* coins are shown so tapping the balance always leads to topping up.
|
||||
*/
|
||||
export function CoinsPill({ className }: { className?: string }) {
|
||||
const coins = useSessionStore((s) => s.profile?.coins ?? 0);
|
||||
const go = useUIStore((s) => s.go);
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<button
|
||||
onClick={() => go("buycoins")}
|
||||
title={t("buy.title")}
|
||||
aria-label={t("buy.title")}
|
||||
className={cn(
|
||||
"glass rounded-full ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 py-1 flex items-center gap-1.5 hover:bg-navy-800/80 active:scale-95 transition",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Coins className="size-3.5 text-gold-400 shrink-0" />
|
||||
<span className="text-xs font-bold text-gold-300 tabular-nums">{coins.toLocaleString()}</span>
|
||||
<span className="grid size-5 place-items-center rounded-full btn-gold shrink-0">
|
||||
<Plus className="size-3" strokeWidth={3.5} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGameStore } from "@/lib/game-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { teamOf, type Seat } from "@/lib/hokm/types";
|
||||
import { titleById } from "@/lib/online/gamification";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Where each seat sits around the table + which edge it slides in from.
|
||||
const SEAT: Record<Seat, { pos: string; enter: { x?: number; y?: number }; delay: number }> = {
|
||||
2: { pos: "top-0 left-1/2 -translate-x-1/2", enter: { y: -50 }, delay: 0.15 }, // partner (top)
|
||||
1: { pos: "top-1/2 right-0 -translate-y-1/2", enter: { x: 50 }, delay: 0.3 }, // opponent (right)
|
||||
3: { pos: "top-1/2 left-0 -translate-y-1/2", enter: { x: -50 }, delay: 0.45 }, // opponent (left)
|
||||
0: { pos: "bottom-0 left-1/2 -translate-x-1/2", enter: { y: 50 }, delay: 0 }, // you (bottom)
|
||||
};
|
||||
|
||||
function Seat({ seat }: { seat: Seat }) {
|
||||
const sp = useGameStore((s) => s.seatPlayers[seat]);
|
||||
const { t, locale } = useI18n();
|
||||
const team = teamOf(seat);
|
||||
const cfg = SEAT[seat];
|
||||
const td = titleById(sp?.title);
|
||||
const title = td ? (locale === "fa" ? td.nameFa : td.nameEn) : null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.4, ...cfg.enter }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 240, damping: 18, delay: cfg.delay }}
|
||||
className={cn("absolute flex flex-col items-center gap-1 w-20", cfg.pos)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative size-14 rounded-full grid place-items-center text-2xl font-bold ring-2",
|
||||
team === 0 ? "bg-teal-700/80 text-teal-100 ring-teal-400/70" : "bg-rose-900/70 text-rose-100 ring-rose-400/70"
|
||||
)}
|
||||
style={{ boxShadow: team === 0 ? "0 0 18px rgba(45,212,191,0.4)" : "0 0 18px rgba(251,113,133,0.4)" }}
|
||||
>
|
||||
{sp ? (
|
||||
sp.avatar
|
||||
) : (
|
||||
<motion.span
|
||||
className="text-cream/30 text-xl"
|
||||
animate={{ opacity: [0.3, 0.7, 0.3] }}
|
||||
transition={{ repeat: Infinity, duration: 1.2 }}
|
||||
>
|
||||
?
|
||||
</motion.span>
|
||||
)}
|
||||
{seat === 0 && (
|
||||
<span className="absolute -bottom-1.5 rounded-full btn-gold px-1.5 text-[8px] font-black leading-tight">
|
||||
{t("match.you")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-cream max-w-20 truncate hud-shadow">
|
||||
{sp?.name ?? "…"}
|
||||
</span>
|
||||
{title && <span className="text-[8px] font-bold gold-text leading-none max-w-20 truncate">{title}</span>}
|
||||
{sp && sp.level > 0 && (
|
||||
<span className="text-[9px] text-gold-300/80 leading-none">{t("common.level")} {sp.level}</span>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* UNO-style pre-game reveal: players animate into their seats around the table,
|
||||
* then a short 3-2-1 countdown, then the game shows. Plays once per fresh online
|
||||
* match (see game-store `matchIntroPending`).
|
||||
*/
|
||||
export function MatchIntroOverlay({ onDone }: { onDone: () => void }) {
|
||||
const { t } = useI18n();
|
||||
// 3 → 2 → 1 → 0 (GO!) → -1 (finish)
|
||||
const [count, setCount] = useState(3);
|
||||
|
||||
useEffect(() => {
|
||||
if (count <= -1) {
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
// Hold the first number a touch longer so the seats finish sliding in.
|
||||
const id = setTimeout(() => setCount((c) => c - 1), count === 3 ? 1150 : 760);
|
||||
return () => clearTimeout(id);
|
||||
}, [count, onDone]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[75] flex flex-col items-center justify-center bg-navy-950/92 backdrop-blur-md persian-pattern"
|
||||
>
|
||||
<motion.h2
|
||||
initial={{ scale: 0.6, opacity: 0, y: -12 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 16 }}
|
||||
className="gold-text text-3xl font-black mb-1"
|
||||
>
|
||||
{t("intro.found")}
|
||||
</motion.h2>
|
||||
<p className="text-cream/55 text-sm mb-6">{t("intro.getReady")}</p>
|
||||
|
||||
{/* the table with the four seats sliding in */}
|
||||
<div className="relative w-[min(86vw,360px)] aspect-square max-h-[46vh]">
|
||||
{/* felt oval */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.85, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
className="felt absolute inset-[18%] rounded-[46%]"
|
||||
/>
|
||||
{/* center countdown */}
|
||||
<div className="absolute inset-0 grid place-items-center pointer-events-none">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={count}
|
||||
initial={{ scale: 0.3, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 1.8, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 18 }}
|
||||
className={cn(
|
||||
"grid size-16 place-items-center rounded-full font-black",
|
||||
count === 0 ? "btn-gold text-2xl px-2" : "glass gold-border text-4xl gold-text"
|
||||
)}
|
||||
>
|
||||
{count >= 1 ? count : count === 0 ? t("intro.go") : ""}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{([0, 1, 2, 3] as Seat[]).map((seat) => (
|
||||
<Seat key={seat} seat={seat} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* team legend */}
|
||||
<div className="mt-6 flex items-center gap-4 text-xs">
|
||||
<span className="flex items-center gap-1.5 text-teal-300">
|
||||
<span className="size-2.5 rounded-full bg-teal-400" /> {t("team.us")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-rose-300">
|
||||
<span className="size-2.5 rounded-full bg-rose-400" /> {t("team.them")}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, Coins, Crown, Gift } from "lucide-react";
|
||||
import { Bell, Crown, Gift, Store } from "lucide-react";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useUIStore } from "@/lib/ui-store";
|
||||
import { useNotifStore } from "@/lib/notification-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { MAX_LEVEL, xpNeededForLevel } from "@/lib/online/gamification";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { CoinsPill } from "./CoinsPill";
|
||||
|
||||
export function TopBar() {
|
||||
const profile = useSessionStore((s) => s.profile);
|
||||
@@ -68,16 +69,13 @@ export function TopBar() {
|
||||
<Gift className="size-4 text-gold-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => go("buycoins")}
|
||||
className="glass rounded-full px-3 py-1.5 flex items-center gap-1.5 hover:bg-navy-800/80 transition"
|
||||
title={t("buy.title")}
|
||||
onClick={() => go("shop")}
|
||||
className="glass rounded-full p-2 hover:bg-navy-800/80 transition"
|
||||
title={t("menu.shop")}
|
||||
>
|
||||
<Coins className="size-4 text-gold-400" />
|
||||
<span className="text-sm font-bold text-cream tabular-nums">
|
||||
{profile.coins.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-gold-400 text-sm leading-none font-bold">+</span>
|
||||
<Store className="size-4 text-gold-400" />
|
||||
</button>
|
||||
<CoinsPill />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { GameTable } from "@/components/GameTable";
|
||||
import { PostMatchRewardsModal } from "@/components/online/PostMatchRewardsModal";
|
||||
import { MatchIntroOverlay } from "@/components/online/MatchIntroOverlay";
|
||||
import { useGameStore } from "@/lib/game-store";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useUIStore } from "@/lib/ui-store";
|
||||
@@ -24,6 +25,8 @@ export function GameScreen() {
|
||||
const forfeited = useGameStore((s) => s.forfeited);
|
||||
const forfeitRequest = useGameStore((s) => s.forfeitRequest);
|
||||
const respondForfeit = useGameStore((s) => s.respondForfeit);
|
||||
const introPending = useGameStore((s) => s.matchIntroPending);
|
||||
const consumeIntro = useGameStore((s) => s.consumeIntro);
|
||||
const returnTo = useUIStore((s) => s.returnTo);
|
||||
const go = useUIStore((s) => s.go);
|
||||
const refreshProfile = useSessionStore((s) => s.refreshProfile);
|
||||
@@ -139,6 +142,13 @@ export function GameScreen() {
|
||||
onForfeit={canForfeit ? () => useGameStore.getState().forfeit() : undefined}
|
||||
/>
|
||||
|
||||
{/* UNO-style "players joining the table" intro (online matches, once) */}
|
||||
<AnimatePresence>
|
||||
{introPending && mode === "online" && (
|
||||
<MatchIntroOverlay onDone={consumeIntro} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* teammate asked to forfeit — confirm or decline */}
|
||||
{forfeitRequest && (
|
||||
<motion.div
|
||||
|
||||
@@ -4,6 +4,7 @@ import { motion } from "framer-motion";
|
||||
import { Coins, Lock, Trophy, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||
import { CoinsPill } from "@/components/online/CoinsPill";
|
||||
import { MATCH_LEAGUES, leagueById } from "@/lib/online/gamification";
|
||||
import { useOnlineStore } from "@/lib/online-store";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
@@ -43,15 +44,7 @@ export function OnlineLobbyScreen() {
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<ScreenHeader
|
||||
title={t("lobby.title")}
|
||||
right={
|
||||
<span className="glass rounded-full px-3 py-1.5 text-xs font-bold text-gold-300 flex items-center gap-1">
|
||||
<Coins className="size-3.5 text-gold-400" />
|
||||
{coins.toLocaleString()}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<ScreenHeader title={t("lobby.title")} right={<CoinsPill />} />
|
||||
|
||||
{/* league pick (only for ranked) */}
|
||||
<div className="glass rounded-2xl p-4 mb-4">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Check, ChevronLeft, Coins, Crown, Eye, EyeOff, Lock, Music, Pencil, Star, Upload, Users, Volume2 } from "lucide-react";
|
||||
import { Check, ChevronLeft, Crown, Eye, EyeOff, Lock, Music, Pencil, Star, Upload, Users, Volume2 } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||
import { RankBadge } from "@/components/online/RankBadge";
|
||||
import { CoinsPill } from "@/components/online/CoinsPill";
|
||||
import { XpBar } from "@/components/online/XpBar";
|
||||
import { Avatar } from "@/components/online/Avatar";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
@@ -138,10 +139,7 @@ export function ProfileScreen() {
|
||||
|
||||
<div className="mt-2 flex items-center justify-center gap-2">
|
||||
<RankBadge rating={profile.rating} showRating />
|
||||
<span className="glass rounded-full px-2.5 py-1 text-xs font-bold text-gold-300 flex items-center gap-1">
|
||||
<Coins className="size-3.5 text-gold-400" />
|
||||
{profile.coins.toLocaleString()}
|
||||
</span>
|
||||
<CoinsPill />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Check, Coins, Sparkles, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||
import { Sticker } from "@/components/online/Sticker";
|
||||
import { CoinsPill } from "@/components/online/CoinsPill";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { getService } from "@/lib/online/service";
|
||||
@@ -146,15 +147,7 @@ export function ShopScreen() {
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<ScreenHeader
|
||||
title={t("shop.title")}
|
||||
right={
|
||||
<span className="glass rounded-full px-3 py-1.5 text-xs font-bold text-gold-300 flex items-center gap-1">
|
||||
<Coins className="size-3.5 text-gold-400" />
|
||||
{profile.coins.toLocaleString()}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<ScreenHeader title={t("shop.title")} right={<CoinsPill />} />
|
||||
|
||||
{msg && (
|
||||
<div className="mb-3 text-center text-rose-300 text-sm glass rounded-xl py-2">{msg}</div>
|
||||
|
||||
@@ -94,6 +94,8 @@ interface GameStore {
|
||||
forfeited: boolean;
|
||||
/** a teammate is asking to forfeit and needs your confirmation. */
|
||||
forfeitRequest: ForfeitRequest | null;
|
||||
/** a fresh online match just started — play the "players joining the table" intro once. */
|
||||
matchIntroPending: boolean;
|
||||
|
||||
newMatch: (settings: GameSettings) => void;
|
||||
newOnlineMatch: (cfg: OnlineMatchConfig) => void;
|
||||
@@ -109,6 +111,8 @@ interface GameStore {
|
||||
forfeit: () => void;
|
||||
/** Respond to a teammate's forfeit request. */
|
||||
respondForfeit: (confirm: boolean) => void;
|
||||
/** Mark the match-intro reveal as played (so it doesn't replay on resume). */
|
||||
consumeIntro: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -334,6 +338,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
paused: false,
|
||||
forfeited: false,
|
||||
forfeitRequest: null,
|
||||
matchIntroPending: false,
|
||||
|
||||
newMatch: (settings) => {
|
||||
clearPending();
|
||||
@@ -376,6 +381,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
paused: false,
|
||||
forfeited: false,
|
||||
forfeitRequest: null,
|
||||
matchIntroPending: true,
|
||||
matchMeta: { ranked: cfg.ranked, stake: cfg.stake, speed: !!cfg.speed },
|
||||
tally: freshTally(),
|
||||
turnDeadline: null,
|
||||
@@ -410,6 +416,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
paused: false,
|
||||
forfeited: false,
|
||||
forfeitRequest: null,
|
||||
matchIntroPending: true,
|
||||
matchMeta: { ranked: true, stake: 0, speed: false },
|
||||
tally: freshTally(),
|
||||
turnDeadline: null,
|
||||
@@ -529,6 +536,8 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
set({ forfeitRequest: null });
|
||||
},
|
||||
|
||||
consumeIntro: () => set({ matchIntroPending: false }),
|
||||
|
||||
reset: () => {
|
||||
clearPending();
|
||||
if (liveUnsub) {
|
||||
@@ -553,6 +562,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
paused: false,
|
||||
forfeited: false,
|
||||
forfeitRequest: null,
|
||||
matchIntroPending: false,
|
||||
seatPlayers: [],
|
||||
tally: freshTally(),
|
||||
turnDeadline: null,
|
||||
|
||||
@@ -221,6 +221,9 @@ const fa: Dict = {
|
||||
"mm.found": "بازیکنان پیدا شدند!",
|
||||
"mm.ready": "آماده شروع",
|
||||
"mm.fillHint": "اگر بازیکن آنلاینی پیدا نشود، رباتها جایگزین میشوند",
|
||||
"intro.found": "بازیکنان آمادهاند!",
|
||||
"intro.getReady": "بازی در حال شروع است…",
|
||||
"intro.go": "شروع!",
|
||||
"mm.cancel": "لغو",
|
||||
"mm.start": "ورود به بازی",
|
||||
|
||||
@@ -379,6 +382,10 @@ const en: Dict = {
|
||||
"match.addFriend": "Add",
|
||||
"match.sent": "Sent",
|
||||
|
||||
"intro.found": "Players ready!",
|
||||
"intro.getReady": "The game is starting…",
|
||||
"intro.go": "GO!",
|
||||
|
||||
"seat.you": "You",
|
||||
"team.us": "Us",
|
||||
"team.them": "Them",
|
||||
|
||||
@@ -134,9 +134,9 @@ export function leagueById(id: string): MatchLeague {
|
||||
|
||||
/** Coin-priced XP packs (XP is intentionally expensive). Server-authoritative. */
|
||||
export const XP_PACKS: { id: string; xp: number; price: number }[] = [
|
||||
{ id: "xp1", xp: 200, price: 5000 },
|
||||
{ id: "xp2", xp: 600, price: 12000 },
|
||||
{ id: "xp3", xp: 1500, price: 25000 },
|
||||
{ id: "xp1", xp: 200, price: 1500 },
|
||||
{ id: "xp2", xp: 600, price: 4000 },
|
||||
{ id: "xp3", xp: 1500, price: 8000 },
|
||||
];
|
||||
|
||||
/* ------------------------------- XP ---------------------------------- */
|
||||
@@ -250,7 +250,7 @@ function tier(
|
||||
category,
|
||||
metric,
|
||||
goal: g,
|
||||
coinReward: Math.max(100, Math.round((80 + g * 12) / 50) * 50),
|
||||
coinReward: Math.min(1500, Math.max(50, Math.round((40 + g * 6) / 50) * 50)),
|
||||
icon,
|
||||
nameFa: faName(faNum(g)),
|
||||
nameEn: enName(g),
|
||||
@@ -287,11 +287,11 @@ export const ACHIEVEMENTS: AchievementDef[] = [
|
||||
(g) => `${g} باخت`, (g) => `${g} Losses`,
|
||||
(g) => `با وجود ${g} باخت ادامه دهید`, (g) => `Persevere through ${g} losses`),
|
||||
// ranks (explicit rating floors)
|
||||
{ id: "reach_silver", category: "rank", ratingFloor: 1100, goal: 1, coinReward: 200, icon: "🥈", nameFa: "لیگ نقره", nameEn: "Reach Silver", descFa: "به لیگ نقره برسید", descEn: "Reach the Silver league" },
|
||||
{ id: "reach_gold", category: "rank", ratingFloor: 1300, goal: 1, coinReward: 500, icon: "🥇", nameFa: "لیگ طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league" },
|
||||
{ id: "reach_platinum", category: "rank", ratingFloor: 1500, goal: 1, coinReward: 1000, icon: "🛡️", nameFa: "لیگ پلاتین", nameEn: "Reach Platinum", descFa: "به لیگ پلاتین برسید", descEn: "Reach the Platinum league" },
|
||||
{ id: "reach_diamond", category: "rank", ratingFloor: 1700, goal: 1, coinReward: 2000, icon: "💠", nameFa: "لیگ الماس", nameEn: "Reach Diamond", descFa: "به لیگ الماس برسید", descEn: "Reach the Diamond league" },
|
||||
{ id: "reach_master", category: "rank", ratingFloor: 1900, goal: 1, coinReward: 4000, icon: "👑", nameFa: "لیگ استاد", nameEn: "Reach Master", descFa: "به لیگ استاد برسید", descEn: "Reach the Master league" },
|
||||
{ id: "reach_silver", category: "rank", ratingFloor: 1100, goal: 1, coinReward: 150, icon: "🥈", nameFa: "لیگ نقره", nameEn: "Reach Silver", descFa: "به لیگ نقره برسید", descEn: "Reach the Silver league" },
|
||||
{ id: "reach_gold", category: "rank", ratingFloor: 1300, goal: 1, coinReward: 300, icon: "🥇", nameFa: "لیگ طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league" },
|
||||
{ id: "reach_platinum", category: "rank", ratingFloor: 1500, goal: 1, coinReward: 500, icon: "🛡️", nameFa: "لیگ پلاتین", nameEn: "Reach Platinum", descFa: "به لیگ پلاتین برسید", descEn: "Reach the Platinum league" },
|
||||
{ id: "reach_diamond", category: "rank", ratingFloor: 1700, goal: 1, coinReward: 900, icon: "💠", nameFa: "لیگ الماس", nameEn: "Reach Diamond", descFa: "به لیگ الماس برسید", descEn: "Reach the Diamond league" },
|
||||
{ id: "reach_master", category: "rank", ratingFloor: 1900, goal: 1, coinReward: 1500, icon: "👑", nameFa: "لیگ استاد", nameEn: "Reach Master", descFa: "به لیگ استاد برسید", descEn: "Reach the Master league" },
|
||||
];
|
||||
|
||||
function metricValue(metric: NonNullable<AchievementDef["metric"]>, stats: PlayerStats, level: number): number {
|
||||
@@ -753,7 +753,7 @@ export function applyMatchResult(
|
||||
|
||||
/* --------------------------- Daily reward ---------------------------- */
|
||||
|
||||
export const DAILY_REWARDS = [300, 500, 750, 1000, 1500, 2500, 7500];
|
||||
export const DAILY_REWARDS = [100, 150, 200, 300, 400, 600, 1500];
|
||||
|
||||
export function dailyRewardFor(day: number): number {
|
||||
return DAILY_REWARDS[Math.min(day, DAILY_REWARDS.length) - 1] ?? 100;
|
||||
|
||||
@@ -961,10 +961,10 @@ export class MockOnlineService implements OnlineService {
|
||||
|
||||
async getCoinPacks(): Promise<CoinPack[]> {
|
||||
return [
|
||||
{ id: "p1", coins: 50000, bonus: 0, priceToman: 95000, tag: "starter" },
|
||||
{ id: "p2", coins: 120000, bonus: 15000, priceToman: 189000, tag: "popular" },
|
||||
{ id: "p3", coins: 300000, bonus: 50000, priceToman: 389000, tag: "best" },
|
||||
{ id: "p4", coins: 700000, bonus: 150000, priceToman: 790000 },
|
||||
{ id: "p1", coins: 5000, bonus: 0, priceToman: 99000, tag: "starter" },
|
||||
{ id: "p2", coins: 11000, bonus: 1000, priceToman: 199000, tag: "popular" },
|
||||
{ id: "p3", coins: 24000, bonus: 4000, priceToman: 399000, tag: "best" },
|
||||
{ id: "p4", coins: 50000, bonus: 15000, priceToman: 799000 },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user