From f9425dea01b28bad1da0184d0fcc655f41ed48e5 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 11:02:25 +0330 Subject: [PATCH] Add reactions (Sheklak), fix hakem card visibility during trump choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reactions/emotes in-game: tray of owned emojis + animated per-seat bubbles (feature named "شکلک / Sheklak"). Packs: starter (free), champion/legend (earned by rating/wins), emotions/taunt (purchasable in shop) - OnlineService.sendReaction/onReaction; mock echoes you + random opponents - Fix: human hakem's 5 cards were blurred behind the trump-chooser overlay — raise hand to z-50 during choosing-trump so cards stay readable Co-Authored-By: Claude Opus 4.8 --- scripts/sim.ts | 1 + src/components/GameTable.tsx | 100 +++++++++++++++++++++++++- src/components/screens/ShopScreen.tsx | 19 ++++- src/lib/i18n.tsx | 6 ++ src/lib/online/gamification.ts | 34 +++++++++ src/lib/online/mock-service.ts | 50 ++++++++++++- src/lib/online/service.ts | 4 ++ src/lib/online/types.ts | 16 ++++- 8 files changed, 222 insertions(+), 8 deletions(-) diff --git a/scripts/sim.ts b/scripts/sim.ts index c2e4dd8..90d4c6e 100644 --- a/scripts/sim.ts +++ b/scripts/sim.ts @@ -91,6 +91,7 @@ function baseProfile(): UserProfile { ownedAvatars: ["a-fox"], ownedCardStyles: ["classic"], ownedTitles: ["novice"], + ownedReactionPacks: [], title: "novice", cardStyle: "classic", achievements: {}, diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx index b6b14a0..6625309 100644 --- a/src/components/GameTable.tsx +++ b/src/components/GameTable.tsx @@ -1,7 +1,7 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Crown, LogOut, WifiOff } from "lucide-react"; +import { Crown, LogOut, SmilePlus, WifiOff } from "lucide-react"; import { useEffect, useState } from "react"; import { TURN_MS, useGameStore } from "@/lib/game-store"; import { legalMoves } from "@/lib/hokm/engine"; @@ -17,7 +17,8 @@ import { } from "@/lib/hokm/types"; import { useI18n } from "@/lib/i18n"; import { useSessionStore } from "@/lib/session-store"; -import { cardStyleById } from "@/lib/online/gamification"; +import { cardStyleById, ownedReactions } from "@/lib/online/gamification"; +import { getService } from "@/lib/online/service"; import { cn } from "@/lib/cn"; import { PlayingCard } from "./PlayingCard"; @@ -89,6 +90,7 @@ export function GameTable({ onExit }: { onExit?: () => void } = {}) { + {/* Overlays */} @@ -332,10 +334,17 @@ function PlayerHand({ legalIds }: { legalIds: Set }) { 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; return ( -
+
{sorted.map((card, i) => { const playable = myTurn && legalIds.has(card.id); @@ -468,6 +477,91 @@ function DisconnectBanner() { ); } +/* ----------------------------- Reactions ------------------------------ */ + +const REACTION_POS: Record = { + 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; +} + +function Reactions() { + const profile = useSessionStore((s) => s.profile); + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [bubbles, setBubbles] = useState([]); + const list = profile ? ownedReactions(profile) : []; + + 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; + }, []); + + const send = (emoji: string) => { + getService().sendReaction(emoji); + setOpen(false); + }; + + return ( + <> + {/* floating bubbles */} + {bubbles.map((b) => ( + + {b.emoji} + + ))} + + {/* tray */} + + {open && ( + + {list.map((emoji, i) => ( + + ))} + + )} + + + {/* button */} + + + ); +} + /* ------------------------------ Overlays ------------------------------ */ function Backdrop({ children }: { children: React.ReactNode }) { diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx index d56b691..6377859 100644 --- a/src/components/screens/ShopScreen.tsx +++ b/src/components/screens/ShopScreen.tsx @@ -25,7 +25,9 @@ export function ShopScreen() { const owns = (item: ShopItem) => item.kind === "avatar" ? profile.ownedAvatars.includes(item.id) - : profile.ownedCardStyles.includes(item.id); + : item.kind === "cardstyle" + ? profile.ownedCardStyles.includes(item.id) + : profile.ownedReactionPacks.includes(item.id); const buy = async (item: ShopItem) => { const res = await getService().buyItem(item.id); @@ -39,6 +41,7 @@ export function ShopScreen() { const avatars = items.filter((i) => i.kind === "avatar"); const cardstyles = items.filter((i) => i.kind === "cardstyle"); + const reactions = items.filter((i) => i.kind === "reactionpack"); return ( @@ -85,6 +88,20 @@ export function ShopScreen() { ))}
+ +
+
+ {reactions.map((item) => ( + buy(item)} + preview={{item.preview}} + /> + ))} +
+
); } diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index a6c2a3e..ec81498 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -218,7 +218,10 @@ const fa: Dict = { "queue.upgrade": "ورود سریع (ویژه)", "shop.cardstyles": "طرح کارت‌ها", + "shop.reactions": "بسته شکلک‌ها", "reward.newTitle": "عنوان جدید", + + "reactions.title": "شکلک‌ها", }; const en: Dict = { @@ -426,7 +429,10 @@ const en: Dict = { "queue.upgrade": "Skip queue (Pro)", "shop.cardstyles": "Card styles", + "shop.reactions": "Reaction packs", "reward.newTitle": "New title", + + "reactions.title": "Reactions", }; const DICTS: Record = { fa, en }; diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts index c35a9d2..bfad04f 100644 --- a/src/lib/online/gamification.ts +++ b/src/lib/online/gamification.ts @@ -10,6 +10,7 @@ import { PlayerStats, RankTier, RankTierId, + ReactionPackDef, RewardResult, TitleDef, TitleUnlock, @@ -228,6 +229,39 @@ export function cardStyleById(id: string): CardStyleDef { return CARD_STYLES.find((c) => c.id === id) ?? CARD_STYLES[0]; } +/* --------------------- Reactions (Sheklak / شکلک) -------------------- */ + +export const REACTION_PACKS: ReactionPackDef[] = [ + { id: "starter", nameFa: "پایه", nameEn: "Starter", reactions: ["👍", "👏", "😂", "😮"], price: 0, default: true }, + { id: "emotions", nameFa: "احساسات", nameEn: "Emotions", reactions: ["😎", "😭", "🤯", "🥳", "😍"], price: 600 }, + { id: "taunt", nameFa: "طعنه", nameEn: "Taunts", reactions: ["😏", "🤡", "🙄", "😴", "🥱"], price: 900 }, + { id: "champion", nameFa: "قهرمان", nameEn: "Champion", reactions: ["👑", "🏆", "💪", "🔥"], price: 0, unlockRating: 1300 }, + { id: "legend", nameFa: "اسطوره", nameEn: "Legend", reactions: ["💎", "⚡", "🐐", "🎯"], price: 0, unlockWins: 100 }, +]; + +export function reactionPackById(id: string): ReactionPackDef | undefined { + return REACTION_PACKS.find((p) => p.id === id); +} + +/** Which packs the player currently owns (default + earned + purchased). */ +export function ownedReactionPackIds(profile: UserProfile): string[] { + const purchased = profile.ownedReactionPacks ?? []; + const ids = new Set(); + for (const p of REACTION_PACKS) { + const earned = + (p.unlockRating != null && profile.rating >= p.unlockRating) || + (p.unlockWins != null && profile.stats.wins >= p.unlockWins); + if (p.default || earned || purchased.includes(p.id)) ids.add(p.id); + } + return [...ids]; +} + +/** Flattened emoji list the player can send. */ +export function ownedReactions(profile: UserProfile): string[] { + const ids = new Set(ownedReactionPackIds(profile)); + return REACTION_PACKS.filter((p) => ids.has(p.id)).flatMap((p) => p.reactions); +} + /* ---------------------- Apply a match result ------------------------- */ function applyStats(stats: PlayerStats, summary: MatchSummary): PlayerStats { diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 6d84c38..3cb0eeb 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -2,7 +2,7 @@ // Simulates remote players, friends presence, room invites and matchmaking // with timers, and computes rewards via gamification.ts. -import { CARD_STYLES, applyMatchResult, dailyRewardFor } from "./gamification"; +import { CARD_STYLES, REACTION_PACKS, applyMatchResult, dailyRewardFor } from "./gamification"; import { CreateRoomOptions, MatchmakingOptions, @@ -112,6 +112,7 @@ function defaultProfile(session: AuthSession): UserProfile { ownedAvatars: [AVATARS[0].id, AVATARS[1].id], ownedCardStyles: ["classic"], ownedTitles: ["novice"], + ownedReactionPacks: [], title: "novice", cardStyle: "classic", achievements: {}, @@ -128,6 +129,7 @@ function migrateProfile(p: UserProfile): UserProfile { ownedAvatars: p.ownedAvatars ?? [AVATARS[0].id], ownedCardStyles: p.ownedCardStyles ?? ["classic"], ownedTitles: p.ownedTitles ?? ["novice"], + ownedReactionPacks: p.ownedReactionPacks ?? [], title: p.title ?? "novice", cardStyle: p.cardStyle ?? "classic", }; @@ -172,6 +174,8 @@ export class MockOnlineService implements OnlineService { private mmCbs = new Set<(s: MatchmakingState) => void>(); private friendCbs = new Set<(f: Friend[]) => void>(); private chatCbs = new Set<(friendId: string, m: ChatMessage[]) => void>(); + private reactionCbs = new Set<(seat: number, reaction: string) => void>(); + private reactionTimer: ReturnType | null = null; private timers: ReturnType[] = []; constructor() { @@ -439,6 +443,32 @@ export class MockOnlineService implements OnlineService { return () => this.chatCbs.delete(cb); } + /* ---------------------------- reactions ---------------------------- */ + + async sendReaction(reaction: string) { + for (const cb of this.reactionCbs) cb(0, reaction); + } + + onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe { + this.reactionCbs.add(cb); + if (this.reactionTimer == null) { + const pool = ["👍", "😂", "🔥", "😮", "👏", "😎", "🙄", "😭"]; + this.reactionTimer = setInterval(() => { + if (this.reactionCbs.size === 0) return; + const seat = randInt(1, 3); + const r = pick(pool); + for (const c of this.reactionCbs) c(seat, r); + }, 9000); + } + return () => { + this.reactionCbs.delete(cb); + if (this.reactionCbs.size === 0 && this.reactionTimer) { + clearInterval(this.reactionTimer); + this.reactionTimer = null; + } + }; + } + /* ------------------------------ rooms ------------------------------ */ private seatYou(): RoomSeat { @@ -723,7 +753,15 @@ export class MockOnlineService implements OnlineService { price: c.price, preview: c.accent, })); - return [...avatarItems, ...cardItems]; + const reactionItems: ShopItem[] = REACTION_PACKS.filter((r) => r.price > 0).map((r) => ({ + id: r.id, + kind: "reactionpack", + nameFa: r.nameFa, + nameEn: r.nameEn, + price: r.price, + preview: r.reactions[0], + })); + return [...avatarItems, ...cardItems, ...reactionItems]; } async buyItem(id: string) { @@ -732,7 +770,11 @@ export class MockOnlineService implements OnlineService { const item = items.find((i) => i.id === id); if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" }; const owned = - item.kind === "avatar" ? p.ownedAvatars.includes(id) : p.ownedCardStyles.includes(id); + item.kind === "avatar" + ? p.ownedAvatars.includes(id) + : item.kind === "cardstyle" + ? p.ownedCardStyles.includes(id) + : p.ownedReactionPacks.includes(id); if (owned) return { ok: false, messageFa: "قبلاً خریداری شده", messageEn: "Already owned" }; if (p.coins < item.price) return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" }; @@ -743,6 +785,8 @@ export class MockOnlineService implements OnlineService { ownedAvatars: item.kind === "avatar" ? [...p.ownedAvatars, id] : p.ownedAvatars, ownedCardStyles: item.kind === "cardstyle" ? [...p.ownedCardStyles, id] : p.ownedCardStyles, + ownedReactionPacks: + item.kind === "reactionpack" ? [...p.ownedReactionPacks, id] : p.ownedReactionPacks, }; this.saveProfile(); return { ok: true, profile: this.profile, messageFa: "خرید انجام شد", messageEn: "Purchased" }; diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index b0fd2c9..ea82897 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -67,6 +67,10 @@ export interface OnlineService { markRead(friendId: string): Promise; onChat(cb: (friendId: string, messages: ChatMessage[]) => void): Unsubscribe; + /* ----- reactions (in-game emotes) ----- */ + sendReaction(reaction: string): Promise; + onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe; + /* ----- rooms ----- */ createRoom(opts: CreateRoomOptions): Promise; setPartner(roomId: string, friendId: string | null): Promise; diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index 3d374bd..f808984 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -54,6 +54,7 @@ export interface UserProfile { ownedAvatars: string[]; ownedCardStyles: string[]; ownedTitles: string[]; + ownedReactionPacks: string[]; // purchased reaction packs title: string | null; // equipped title id cardStyle: string; // equipped card-back style id @@ -132,6 +133,19 @@ export interface CardStyleDef { price: number; // 0 = free/default } +/* --------------------- Reactions (Sheklak / شکلک) -------------------- */ + +export interface ReactionPackDef { + id: string; + nameFa: string; + nameEn: string; + reactions: string[]; // emoji/sticker chars + price: number; // >0 → purchasable in the shop + default?: boolean; // owned from the start + unlockRating?: number; // earned by reaching this rating + unlockWins?: number; // earned by total wins +} + /* ------------------------------ Friends ------------------------------ */ export type PresenceStatus = "online" | "offline" | "in-game"; @@ -267,7 +281,7 @@ export interface LeaderboardEntry { /* ------------------------------- Shop -------------------------------- */ -export type ShopItemKind = "avatar" | "cardstyle"; +export type ShopItemKind = "avatar" | "cardstyle" | "reactionpack"; export interface ShopItem { id: string;