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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user