diff --git a/scripts/sim.ts b/scripts/sim.ts index 90d4c6e..372efef 100644 --- a/scripts/sim.ts +++ b/scripts/sim.ts @@ -92,6 +92,7 @@ function baseProfile(): UserProfile { ownedCardStyles: ["classic"], ownedTitles: ["novice"], ownedReactionPacks: [], + ownedStickerPacks: [], title: "novice", cardStyle: "classic", achievements: {}, diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx index 6625309..52aba2b 100644 --- a/src/components/GameTable.tsx +++ b/src/components/GameTable.tsx @@ -17,10 +17,11 @@ import { } from "@/lib/hokm/types"; import { useI18n } from "@/lib/i18n"; import { useSessionStore } from "@/lib/session-store"; -import { cardStyleById, ownedReactions } from "@/lib/online/gamification"; +import { cardStyleById, ownedReactions, ownedStickers } from "@/lib/online/gamification"; import { getService } from "@/lib/online/service"; import { cn } from "@/lib/cn"; import { PlayingCard } from "./PlayingCard"; +import { Sticker } from "./online/Sticker"; function useCountdown(deadline: number | null) { const [now, setNow] = useState(() => Date.now()); @@ -492,12 +493,21 @@ interface Bubble { emoji: string; } +function ReactionBubble({ value }: { value: string }) { + if (value.startsWith("sticker:")) { + return ; + } + return {value}; +} + function Reactions() { const profile = useSessionStore((s) => s.profile); const { t } = useI18n(); const [open, setOpen] = useState(false); + const [tab, setTab] = useState<"emoji" | "sticker">("emoji"); const [bubbles, setBubbles] = useState([]); - const list = profile ? ownedReactions(profile) : []; + const emojis = profile ? ownedReactions(profile) : []; + const stickers = profile ? ownedStickers(profile) : []; useEffect(() => { const unsub = getService().onReaction((seat, emoji) => { @@ -508,8 +518,8 @@ function Reactions() { return unsub; }, []); - const send = (emoji: string) => { - getService().sendReaction(emoji); + const send = (value: string) => { + getService().sendReaction(value); setOpen(false); }; @@ -524,7 +534,7 @@ function Reactions() { exit={{ opacity: 0 }} className={cn("absolute z-40 pointer-events-none", REACTION_POS[b.seat])} > - {b.emoji} + ))} @@ -535,17 +545,54 @@ function Reactions() { initial={{ opacity: 0, y: 12, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 12, scale: 0.95 }} - className="absolute bottom-20 ltr:right-4 rtl:left-4 z-50 glass rounded-2xl p-2 grid grid-cols-5 gap-1 max-w-[260px]" + className="absolute bottom-20 ltr:right-4 rtl:left-4 z-50 glass rounded-2xl p-2 w-[270px]" > - {list.map((emoji, i) => ( +
- ))} + +
+ + {tab === "emoji" ? ( +
+ {emojis.map((emoji, i) => ( + + ))} +
+ ) : ( +
+ {stickers.map((id) => ( + + ))} +
+ )} )} diff --git a/src/components/online/Sticker.tsx b/src/components/online/Sticker.tsx new file mode 100644 index 0000000..8b2b492 --- /dev/null +++ b/src/components/online/Sticker.tsx @@ -0,0 +1,202 @@ +"use client"; + +/** + * Hand-designed sticker artwork as inline SVG — no external assets. + * Each sticker is keyed by id; packs (see gamification.ts) reference these ids. + */ + +type SvgProps = { className?: string }; + +function Face({ + bg1, + bg2, + children, +}: { + bg1: string; + bg2: string; + children: React.ReactNode; +}) { + return ( + <> + + + + + + + + {children} + + ); +} + +const STICKERS: Record = { + /* ----------------------------- faces ----------------------------- */ + happy: ( + + + + + + ), + sad: ( + + + + + + + ), + cool: ( + + + + + + + ), + love: ( + + + + + + ), + angry: ( + + + + + + + + ), + + /* ------------------------------ hokm ----------------------------- */ + "hokm-badge": ( + <> + + + + + + + + + + + حکم + + + ♠ + + + ), + "kot-stamp": ( + + + + + کُت! + + + ), + crown: ( + <> + + + + + + + + + + + + + ), + "ace-spade": ( + <> + + A + + A + + ), + + /* ----------------------------- persian --------------------------- */ + chai: ( + <> + + + + + + + ), + afarin: ( + <> + + + + + + + + + آفرین + + + ), + rose: ( + <> + + + + + + + ), + + /* ------------------------------ taunt ---------------------------- */ + clown: ( + + + + + + + + + ), + sleep: ( + + + + z + z + + ), + weak: ( + <> + + + + ضعیف! + + + ), +}; + +export const STICKER_IDS = Object.keys(STICKERS); + +export function Sticker({ id, size = 64, className }: { id: string; size?: number } & SvgProps) { + const art = STICKERS[id]; + if (!art) return null; + return ( + + {art} + + ); +} diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx index 6377859..7d0c5d1 100644 --- a/src/components/screens/ShopScreen.tsx +++ b/src/components/screens/ShopScreen.tsx @@ -3,6 +3,7 @@ import { Check, Coins } from "lucide-react"; import { useEffect, useState } from "react"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; +import { Sticker } from "@/components/online/Sticker"; import { useSessionStore } from "@/lib/session-store"; import { useI18n } from "@/lib/i18n"; import { getService } from "@/lib/online/service"; @@ -27,7 +28,9 @@ export function ShopScreen() { ? profile.ownedAvatars.includes(item.id) : item.kind === "cardstyle" ? profile.ownedCardStyles.includes(item.id) - : profile.ownedReactionPacks.includes(item.id); + : item.kind === "reactionpack" + ? profile.ownedReactionPacks.includes(item.id) + : profile.ownedStickerPacks.includes(item.id); const buy = async (item: ShopItem) => { const res = await getService().buyItem(item.id); @@ -42,6 +45,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"); + const stickers = items.filter((i) => i.kind === "stickerpack"); return ( @@ -102,6 +106,20 @@ export function ShopScreen() { ))} + +
+
+ {stickers.map((item) => ( + buy(item)} + preview={} + /> + ))} +
+
); } diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index ec81498..b63c386 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -219,9 +219,11 @@ const fa: Dict = { "shop.cardstyles": "طرح کارت‌ها", "shop.reactions": "بسته شکلک‌ها", + "shop.stickers": "بسته استیکرها", "reward.newTitle": "عنوان جدید", - "reactions.title": "شکلک‌ها", + "reactions.title": "شکلک", + "stickers.title": "استیکر", }; const en: Dict = { @@ -430,9 +432,11 @@ const en: Dict = { "shop.cardstyles": "Card styles", "shop.reactions": "Reaction packs", + "shop.stickers": "Sticker packs", "reward.newTitle": "New title", - "reactions.title": "Reactions", + "reactions.title": "Emoji", + "stickers.title": "Stickers", }; const DICTS: Record = { fa, en }; diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts index bfad04f..75fdabf 100644 --- a/src/lib/online/gamification.ts +++ b/src/lib/online/gamification.ts @@ -12,6 +12,7 @@ import { RankTierId, ReactionPackDef, RewardResult, + StickerPackDef, TitleDef, TitleUnlock, UserProfile, @@ -262,6 +263,37 @@ export function ownedReactions(profile: UserProfile): string[] { return REACTION_PACKS.filter((p) => ids.has(p.id)).flatMap((p) => p.reactions); } +/* ------------------------- Sticker packs ----------------------------- */ + +export const STICKER_PACKS: StickerPackDef[] = [ + { id: "faces", nameFa: "شکلک‌ها", nameEn: "Faces", stickers: ["happy", "sad", "cool", "love", "angry"], price: 0, default: true }, + { id: "hokm", nameFa: "حکم", nameEn: "Hokm", stickers: ["hokm-badge", "kot-stamp", "crown", "ace-spade"], price: 0, unlockRating: 1300 }, + { id: "persian", nameFa: "ایرانی", nameEn: "Persian", stickers: ["chai", "afarin", "rose"], price: 700 }, + { id: "taunt", nameFa: "طعنه", nameEn: "Taunts", stickers: ["clown", "sleep", "weak"], price: 900 }, +]; + +export function stickerPackById(id: string): StickerPackDef | undefined { + return STICKER_PACKS.find((p) => p.id === id); +} + +export function ownedStickerPackIds(profile: UserProfile): string[] { + const purchased = profile.ownedStickerPacks ?? []; + const ids = new Set(); + for (const p of STICKER_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 sticker-id list the player can send. */ +export function ownedStickers(profile: UserProfile): string[] { + const ids = new Set(ownedStickerPackIds(profile)); + return STICKER_PACKS.filter((p) => ids.has(p.id)).flatMap((p) => p.stickers); +} + /* ---------------------- 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 3cb0eeb..f3da261 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -2,7 +2,13 @@ // Simulates remote players, friends presence, room invites and matchmaking // with timers, and computes rewards via gamification.ts. -import { CARD_STYLES, REACTION_PACKS, applyMatchResult, dailyRewardFor } from "./gamification"; +import { + CARD_STYLES, + REACTION_PACKS, + STICKER_PACKS, + applyMatchResult, + dailyRewardFor, +} from "./gamification"; import { CreateRoomOptions, MatchmakingOptions, @@ -113,6 +119,7 @@ function defaultProfile(session: AuthSession): UserProfile { ownedCardStyles: ["classic"], ownedTitles: ["novice"], ownedReactionPacks: [], + ownedStickerPacks: [], title: "novice", cardStyle: "classic", achievements: {}, @@ -130,6 +137,7 @@ function migrateProfile(p: UserProfile): UserProfile { ownedCardStyles: p.ownedCardStyles ?? ["classic"], ownedTitles: p.ownedTitles ?? ["novice"], ownedReactionPacks: p.ownedReactionPacks ?? [], + ownedStickerPacks: p.ownedStickerPacks ?? [], title: p.title ?? "novice", cardStyle: p.cardStyle ?? "classic", }; @@ -452,7 +460,10 @@ export class MockOnlineService implements OnlineService { onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe { this.reactionCbs.add(cb); if (this.reactionTimer == null) { - const pool = ["👍", "😂", "🔥", "😮", "👏", "😎", "🙄", "😭"]; + const pool = [ + "👍", "😂", "🔥", "😮", "👏", "🙄", + "sticker:happy", "sticker:cool", "sticker:kot-stamp", "sticker:crown", + ]; this.reactionTimer = setInterval(() => { if (this.reactionCbs.size === 0) return; const seat = randInt(1, 3); @@ -761,7 +772,15 @@ export class MockOnlineService implements OnlineService { price: r.price, preview: r.reactions[0], })); - return [...avatarItems, ...cardItems, ...reactionItems]; + const stickerItems: ShopItem[] = STICKER_PACKS.filter((p) => p.price > 0).map((p) => ({ + id: p.id, + kind: "stickerpack", + nameFa: p.nameFa, + nameEn: p.nameEn, + price: p.price, + preview: p.stickers[0], // sticker id; ShopScreen renders via + })); + return [...avatarItems, ...cardItems, ...reactionItems, ...stickerItems]; } async buyItem(id: string) { @@ -774,7 +793,9 @@ export class MockOnlineService implements OnlineService { ? p.ownedAvatars.includes(id) : item.kind === "cardstyle" ? p.ownedCardStyles.includes(id) - : p.ownedReactionPacks.includes(id); + : item.kind === "reactionpack" + ? p.ownedReactionPacks.includes(id) + : p.ownedStickerPacks.includes(id); if (owned) return { ok: false, messageFa: "قبلاً خریداری شده", messageEn: "Already owned" }; if (p.coins < item.price) return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" }; @@ -787,6 +808,8 @@ export class MockOnlineService implements OnlineService { item.kind === "cardstyle" ? [...p.ownedCardStyles, id] : p.ownedCardStyles, ownedReactionPacks: item.kind === "reactionpack" ? [...p.ownedReactionPacks, id] : p.ownedReactionPacks, + ownedStickerPacks: + item.kind === "stickerpack" ? [...p.ownedStickerPacks, id] : p.ownedStickerPacks, }; this.saveProfile(); return { ok: true, profile: this.profile, messageFa: "خرید انجام شد", messageEn: "Purchased" }; diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index f808984..e87b009 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -55,6 +55,7 @@ export interface UserProfile { ownedCardStyles: string[]; ownedTitles: string[]; ownedReactionPacks: string[]; // purchased reaction packs + ownedStickerPacks: string[]; // purchased sticker packs title: string | null; // equipped title id cardStyle: string; // equipped card-back style id @@ -146,6 +147,17 @@ export interface ReactionPackDef { unlockWins?: number; // earned by total wins } +export interface StickerPackDef { + id: string; + nameFa: string; + nameEn: string; + stickers: string[]; // Sticker artwork ids (see components/online/Sticker.tsx) + price: number; // >0 → purchasable in the shop + default?: boolean; + unlockRating?: number; + unlockWins?: number; +} + /* ------------------------------ Friends ------------------------------ */ export type PresenceStatus = "online" | "offline" | "in-game"; @@ -281,7 +293,7 @@ export interface LeaderboardEntry { /* ------------------------------- Shop -------------------------------- */ -export type ShopItemKind = "avatar" | "cardstyle" | "reactionpack"; +export type ShopItemKind = "avatar" | "cardstyle" | "reactionpack" | "stickerpack"; export interface ShopItem { id: string;