Split card design into front+back, add sound effects & background music

Card design:
- Separate cardFront + cardBack (each own/equip independently)
- Fronts: classic (free), ivory/rosegold (buy), parchment/mint (earned)
- Backs: classic (free), sapphire/emerald (buy), ruby/royal (earned)
- PlayingCard `front` prop; table applies front to all faces, back to opponents
- Profile has front + back pickers; shop has both sections

Audio:
- Web Audio synth engine (no asset files): SFX for card/deal/trump/trick,
  win/lose, message, notify, award, levelup, purchase, kot + ambient music
- Toggles in profile (Audio) + mute button in game HUD; prefs persisted
- Wired across game-store, rewards, daily, shop, chat

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 11:49:19 +03:30
parent db4eade619
commit ae239f4c51
18 changed files with 579 additions and 72 deletions
+41 -11
View File
@@ -7,6 +7,7 @@ import { Sticker } from "@/components/online/Sticker";
import { useSessionStore } from "@/lib/session-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { sound } from "@/lib/sound";
import { ShopItem } from "@/lib/online/types";
import { cn } from "@/lib/cn";
@@ -23,19 +24,26 @@ export function ShopScreen() {
if (!profile) return null;
const owns = (item: ShopItem) =>
item.kind === "avatar"
? profile.ownedAvatars.includes(item.id)
: item.kind === "cardstyle"
? profile.ownedCardStyles.includes(item.id)
: item.kind === "reactionpack"
? profile.ownedReactionPacks.includes(item.id)
: profile.ownedStickerPacks.includes(item.id);
const owns = (item: ShopItem) => {
switch (item.kind) {
case "avatar":
return profile.ownedAvatars.includes(item.id);
case "cardfront":
return profile.ownedCardFronts.includes(item.id);
case "cardback":
return profile.ownedCardBacks.includes(item.id);
case "reactionpack":
return profile.ownedReactionPacks.includes(item.id);
default:
return profile.ownedStickerPacks.includes(item.id);
}
};
const buy = async (item: ShopItem) => {
const res = await getService().buyItem(item.id);
if (res.ok && res.profile) {
setProfile(res.profile);
sound.play("purchase");
} else {
setMsg(locale === "fa" ? res.messageFa : res.messageEn);
setTimeout(() => setMsg(""), 1800);
@@ -43,7 +51,8 @@ export function ShopScreen() {
};
const avatars = items.filter((i) => i.kind === "avatar");
const cardstyles = items.filter((i) => i.kind === "cardstyle");
const cardfronts = items.filter((i) => i.kind === "cardfront");
const cardbacks = items.filter((i) => i.kind === "cardback");
const reactions = items.filter((i) => i.kind === "reactionpack");
const stickers = items.filter((i) => i.kind === "stickerpack");
@@ -71,9 +80,30 @@ export function ShopScreen() {
</div>
</Section>
<Section title={t("shop.cardstyles")}>
<Section title={t("shop.cardfronts")}>
<div className="grid grid-cols-3 gap-3">
{cardstyles.map((item) => (
{cardfronts.map((item) => (
<ItemCard
key={item.id}
item={item}
owned={owns(item)}
onBuy={() => buy(item)}
preview={
<span
className="w-8 h-11 rounded-md border flex items-center justify-center text-slate-900"
style={{ background: `linear-gradient(160deg, #ffffff, ${item.preview})`, borderColor: "rgba(0,0,0,0.18)" }}
>
</span>
}
/>
))}
</div>
</Section>
<Section title={t("shop.cardbacks")}>
<div className="grid grid-cols-3 gap-3">
{cardbacks.map((item) => (
<ItemCard
key={item.id}
item={item}