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:
soroush.asadi
2026-06-04 11:15:28 +03:30
parent f9425dea01
commit db4eade619
8 changed files with 359 additions and 20 deletions
+32
View File
@@ -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 {
+27 -4
View File
@@ -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
View File
@@ -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;