Add designed sticker packs (SVG art) to the reactions system
- 15 hand-designed inline-SVG stickers (faces, Hokm: حکم/کُت/crown/ace, Persian: chai/آفرین/rose, taunts: clown/zzz/ضعیف) in components/online/Sticker.tsx - Sticker packs: faces (free), hokm (earned @rating 1300), persian & taunt (buy) - In-game tray now tabbed Emoji | Stickers; stickers broadcast as "sticker:<id>" and render as large animated bubbles per seat - Shop sells sticker packs; profile.ownedStickerPacks; gamification helpers ownedStickers/ownedStickerPackIds; mock opponents send stickers too Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,7 @@ function baseProfile(): UserProfile {
|
||||
ownedCardStyles: ["classic"],
|
||||
ownedTitles: ["novice"],
|
||||
ownedReactionPacks: [],
|
||||
ownedStickerPacks: [],
|
||||
title: "novice",
|
||||
cardStyle: "classic",
|
||||
achievements: {},
|
||||
|
||||
@@ -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 <Sticker id={value.slice(8)} size={72} className="drop-shadow-xl" />;
|
||||
}
|
||||
return <span className="text-4xl drop-shadow-lg">{value}</span>;
|
||||
}
|
||||
|
||||
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<Bubble[]>([]);
|
||||
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])}
|
||||
>
|
||||
<span className="text-4xl drop-shadow-lg">{b.emoji}</span>
|
||||
<ReactionBubble value={b.emoji} />
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
@@ -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) => (
|
||||
<div className="flex gap-1 p-1 rounded-xl bg-navy-900/70 mb-2">
|
||||
<button
|
||||
key={`${emoji}-${i}`}
|
||||
onClick={() => send(emoji)}
|
||||
className="size-10 rounded-xl hover:bg-navy-800 transition flex items-center justify-center text-2xl"
|
||||
onClick={() => setTab("emoji")}
|
||||
className={cn(
|
||||
"flex-1 rounded-lg py-1.5 text-xs font-bold transition",
|
||||
tab === "emoji" ? "btn-gold" : "text-cream/60"
|
||||
)}
|
||||
>
|
||||
{emoji}
|
||||
{t("reactions.title")}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setTab("sticker")}
|
||||
className={cn(
|
||||
"flex-1 rounded-lg py-1.5 text-xs font-bold transition",
|
||||
tab === "sticker" ? "btn-gold" : "text-cream/60"
|
||||
)}
|
||||
>
|
||||
{t("stickers.title")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "emoji" ? (
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{emojis.map((emoji, i) => (
|
||||
<button
|
||||
key={`${emoji}-${i}`}
|
||||
onClick={() => send(emoji)}
|
||||
className="size-10 rounded-xl hover:bg-navy-800 transition flex items-center justify-center text-2xl"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{stickers.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => send(`sticker:${id}`)}
|
||||
className="rounded-xl hover:bg-navy-800 transition flex items-center justify-center p-1"
|
||||
>
|
||||
<Sticker id={id} size={48} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<defs>
|
||||
<radialGradient id="fg" cx="40%" cy="35%" r="75%">
|
||||
<stop offset="0" stopColor={bg1} />
|
||||
<stop offset="1" stopColor={bg2} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="44" fill="url(#fg)" stroke="rgba(0,0,0,0.18)" strokeWidth="2" />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const STICKERS: Record<string, React.ReactNode> = {
|
||||
/* ----------------------------- faces ----------------------------- */
|
||||
happy: (
|
||||
<Face bg1="#ffe680" bg2="#f5b301">
|
||||
<circle cx="36" cy="44" r="5" fill="#3a2a00" />
|
||||
<circle cx="64" cy="44" r="5" fill="#3a2a00" />
|
||||
<path d="M32 62 Q50 80 68 62" fill="none" stroke="#3a2a00" strokeWidth="5" strokeLinecap="round" />
|
||||
</Face>
|
||||
),
|
||||
sad: (
|
||||
<Face bg1="#bfe3ff" bg2="#5aa6e0">
|
||||
<circle cx="36" cy="44" r="5" fill="#13314d" />
|
||||
<circle cx="64" cy="44" r="5" fill="#13314d" />
|
||||
<path d="M32 70 Q50 56 68 70" fill="none" stroke="#13314d" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M64 50 q4 10 0 16 q-4 -6 0 -16" fill="#2f8fd6" />
|
||||
</Face>
|
||||
),
|
||||
cool: (
|
||||
<Face bg1="#ffe680" bg2="#f5b301">
|
||||
<rect x="24" y="38" width="22" height="13" rx="5" fill="#1b1b1b" />
|
||||
<rect x="54" y="38" width="22" height="13" rx="5" fill="#1b1b1b" />
|
||||
<rect x="46" y="42" width="8" height="3" fill="#1b1b1b" />
|
||||
<path d="M36 64 Q52 74 66 60" fill="none" stroke="#3a2a00" strokeWidth="5" strokeLinecap="round" />
|
||||
</Face>
|
||||
),
|
||||
love: (
|
||||
<Face bg1="#ffc1e3" bg2="#ff5fa2">
|
||||
<path d="M30 40 l6 -6 6 6 -6 8 z" fill="#c1124e" />
|
||||
<path d="M58 40 l6 -6 6 6 -6 8 z" fill="#c1124e" />
|
||||
<path d="M34 62 Q50 78 66 62" fill="none" stroke="#7a0b30" strokeWidth="5" strokeLinecap="round" />
|
||||
</Face>
|
||||
),
|
||||
angry: (
|
||||
<Face bg1="#ff9d7a" bg2="#e23b1e">
|
||||
<path d="M28 38 l16 6" stroke="#3a0a00" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M72 38 l-16 6" stroke="#3a0a00" strokeWidth="5" strokeLinecap="round" />
|
||||
<circle cx="37" cy="50" r="4.5" fill="#3a0a00" />
|
||||
<circle cx="63" cy="50" r="4.5" fill="#3a0a00" />
|
||||
<path d="M34 72 Q50 60 66 72" fill="none" stroke="#3a0a00" strokeWidth="5" strokeLinecap="round" />
|
||||
</Face>
|
||||
),
|
||||
|
||||
/* ------------------------------ hokm ----------------------------- */
|
||||
"hokm-badge": (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#f1da8a" />
|
||||
<stop offset="0.55" stopColor="#d4af37" />
|
||||
<stop offset="1" stopColor="#b8860b" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="42" fill="url(#gold)" stroke="#7a5a00" strokeWidth="3" />
|
||||
<circle cx="50" cy="50" r="34" fill="none" stroke="#7a5a00" strokeWidth="1.5" opacity="0.5" />
|
||||
<text x="50" y="46" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="26" fill="#3a2a04">
|
||||
حکم
|
||||
</text>
|
||||
<text x="50" y="72" textAnchor="middle" fontSize="20" fill="#3a2a04">
|
||||
♠
|
||||
</text>
|
||||
</>
|
||||
),
|
||||
"kot-stamp": (
|
||||
<g transform="rotate(-14 50 50)">
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke="#d11a2a" strokeWidth="5" />
|
||||
<circle cx="50" cy="50" r="33" fill="none" stroke="#d11a2a" strokeWidth="2" />
|
||||
<text x="50" y="60" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="34" fill="#d11a2a">
|
||||
کُت!
|
||||
</text>
|
||||
</g>
|
||||
),
|
||||
crown: (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient id="cg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#ffe89a" />
|
||||
<stop offset="1" stopColor="#d4af37" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M18 70 L24 34 L40 54 L50 26 L60 54 L76 34 L82 70 Z" fill="url(#cg)" stroke="#7a5a00" strokeWidth="2.5" strokeLinejoin="round" />
|
||||
<rect x="18" y="70" width="64" height="10" rx="3" fill="url(#cg)" stroke="#7a5a00" strokeWidth="2.5" />
|
||||
<circle cx="50" cy="24" r="5" fill="#ff5d73" />
|
||||
<circle cx="24" cy="32" r="4" fill="#6aa6ff" />
|
||||
<circle cx="76" cy="32" r="4" fill="#6aa6ff" />
|
||||
</>
|
||||
),
|
||||
"ace-spade": (
|
||||
<>
|
||||
<rect x="24" y="14" width="52" height="72" rx="8" fill="#fffdf7" stroke="#caa84a" strokeWidth="2.5" />
|
||||
<text x="34" y="32" textAnchor="middle" fontSize="14" fontWeight="800" fill="#1b1b1b">A</text>
|
||||
<text x="50" y="62" textAnchor="middle" fontSize="34" fill="#1b1b1b">♠</text>
|
||||
<text x="66" y="80" textAnchor="middle" fontSize="14" fontWeight="800" fill="#1b1b1b" transform="rotate(180 66 75)">A</text>
|
||||
</>
|
||||
),
|
||||
|
||||
/* ----------------------------- persian --------------------------- */
|
||||
chai: (
|
||||
<>
|
||||
<path d="M40 24 q4 -8 0 -14 M52 24 q5 -9 0 -16" fill="none" stroke="#cbd5e1" strokeWidth="3" strokeLinecap="round" opacity="0.8" />
|
||||
<path d="M34 30 H66 L61 78 Q60 84 54 84 H46 Q40 84 39 78 Z" fill="#fff" stroke="#caa84a" strokeWidth="2.5" />
|
||||
<path d="M37 40 H63 L59 72 Q58 76 53 76 H47 Q42 76 41 72 Z" fill="#a8431a" />
|
||||
<ellipse cx="50" cy="88" rx="26" ry="6" fill="#e9d9a8" stroke="#caa84a" strokeWidth="2" />
|
||||
<path d="M66 44 q14 2 12 16 q-2 10 -14 8" fill="none" stroke="#caa84a" strokeWidth="3" />
|
||||
</>
|
||||
),
|
||||
afarin: (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient id="rib" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#f1da8a" />
|
||||
<stop offset="1" stopColor="#c9a227" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M14 36 H86 L74 50 L86 64 H14 L26 50 Z" fill="url(#rib)" stroke="#7a5a00" strokeWidth="2" />
|
||||
<text x="50" y="56" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="20" fill="#3a2a04">
|
||||
آفرین
|
||||
</text>
|
||||
</>
|
||||
),
|
||||
rose: (
|
||||
<>
|
||||
<path d="M50 60 C40 80 30 84 26 92 M50 60 C60 80 70 84 74 92" stroke="#2f7d32" strokeWidth="4" fill="none" />
|
||||
<path d="M30 74 q-12 -2 -14 -12 q12 0 16 8 M70 74 q12 -2 14 -12 q-12 0 -16 8" fill="#2f7d32" opacity="0.85" />
|
||||
<circle cx="50" cy="42" r="22" fill="#d11a2a" />
|
||||
<path d="M50 24 a18 18 0 0 1 0 36 a12 12 0 0 0 0 -24 a8 8 0 0 1 0 -12" fill="#a30f1f" opacity="0.7" />
|
||||
<circle cx="50" cy="42" r="7" fill="#7a0b16" />
|
||||
</>
|
||||
),
|
||||
|
||||
/* ------------------------------ taunt ---------------------------- */
|
||||
clown: (
|
||||
<Face bg1="#fff3d6" bg2="#ffd98a">
|
||||
<circle cx="36" cy="44" r="4.5" fill="#1b1b1b" />
|
||||
<circle cx="64" cy="44" r="4.5" fill="#1b1b1b" />
|
||||
<circle cx="50" cy="56" r="7" fill="#e23b1e" />
|
||||
<path d="M30 66 Q50 84 70 66" fill="none" stroke="#e23b1e" strokeWidth="5" strokeLinecap="round" />
|
||||
<circle cx="22" cy="40" r="6" fill="#ff5fa2" opacity="0.8" />
|
||||
<circle cx="78" cy="40" r="6" fill="#ff5fa2" opacity="0.8" />
|
||||
</Face>
|
||||
),
|
||||
sleep: (
|
||||
<Face bg1="#ffe680" bg2="#f5b301">
|
||||
<path d="M30 44 q6 -5 12 0 M58 44 q6 -5 12 0" fill="none" stroke="#3a2a00" strokeWidth="4" strokeLinecap="round" />
|
||||
<circle cx="50" cy="64" r="5" fill="none" stroke="#3a2a00" strokeWidth="4" />
|
||||
<text x="74" y="34" fontFamily="Vazirmatn, sans-serif" fontSize="16" fontWeight="900" fill="#13314d">z</text>
|
||||
<text x="82" y="24" fontFamily="Vazirmatn, sans-serif" fontSize="11" fontWeight="900" fill="#13314d">z</text>
|
||||
</Face>
|
||||
),
|
||||
weak: (
|
||||
<>
|
||||
<circle cx="50" cy="50" r="42" fill="#1f2b4d" stroke="#d11a2a" strokeWidth="3" />
|
||||
<path d="M50 30 v26 M50 56 l-9 -9 M50 56 l9 -9" stroke="#ff6b81" strokeWidth="5" fill="none" strokeLinecap="round" />
|
||||
<text x="50" y="80" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="16" fill="#ff6b81">
|
||||
ضعیف!
|
||||
</text>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
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 (
|
||||
<svg viewBox="0 0 100 100" width={size} height={size} className={className} role="img">
|
||||
{art}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ScreenShell>
|
||||
@@ -102,6 +106,20 @@ export function ShopScreen() {
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t("shop.stickers")}>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{stickers.map((item) => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
owned={owns(item)}
|
||||
onBuy={() => buy(item)}
|
||||
preview={<Sticker id={item.preview} size={48} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-2
@@ -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<Locale, Dict> = { fa, en };
|
||||
|
||||
@@ -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<string>();
|
||||
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 {
|
||||
|
||||
@@ -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 <Sticker>
|
||||
}));
|
||||
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" };
|
||||
|
||||
+13
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user