From ae239f4c519f80f764e031bba825c7ca3b65cea8 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 11:49:19 +0330 Subject: [PATCH] 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 --- scripts/sim.ts | 6 +- src/components/GameTable.tsx | 42 +++- src/components/HomeScreen.tsx | 19 +- src/components/PlayingCard.tsx | 16 +- src/components/online/DailyRewardModal.tsx | 2 + .../online/PostMatchRewardsModal.tsx | 10 + src/components/screens/ChatScreen.tsx | 7 + src/components/screens/ProfileScreen.tsx | 95 ++++++++- src/components/screens/ShopScreen.tsx | 52 ++++- src/lib/game-store.ts | 17 +- src/lib/i18n.tsx | 18 ++ src/lib/online/gamification.ts | 50 ++++- src/lib/online/mock-service.ts | 56 +++-- src/lib/online/service.ts | 2 +- src/lib/online/types.ts | 33 ++- src/lib/session-store.ts | 2 +- src/lib/sound-store.ts | 26 +++ src/lib/sound.ts | 198 ++++++++++++++++++ 18 files changed, 579 insertions(+), 72 deletions(-) create mode 100644 src/lib/sound-store.ts create mode 100644 src/lib/sound.ts diff --git a/scripts/sim.ts b/scripts/sim.ts index 372efef..aabd830 100644 --- a/scripts/sim.ts +++ b/scripts/sim.ts @@ -89,12 +89,14 @@ function baseProfile(): UserProfile { }, plan: "free", ownedAvatars: ["a-fox"], - ownedCardStyles: ["classic"], + ownedCardFronts: ["classic"], + ownedCardBacks: ["classic"], ownedTitles: ["novice"], ownedReactionPacks: [], ownedStickerPacks: [], title: "novice", - cardStyle: "classic", + cardFront: "classic", + cardBack: "classic", achievements: {}, unlocked: [], createdAt: 0, diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx index 52aba2b..2546a4f 100644 --- a/src/components/GameTable.tsx +++ b/src/components/GameTable.tsx @@ -1,9 +1,10 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Crown, LogOut, SmilePlus, WifiOff } from "lucide-react"; +import { Crown, LogOut, SmilePlus, Volume2, VolumeX, WifiOff } from "lucide-react"; import { useEffect, useState } from "react"; import { TURN_MS, useGameStore } from "@/lib/game-store"; +import { useSoundStore } from "@/lib/sound-store"; import { legalMoves } from "@/lib/hokm/engine"; import { sortHand } from "@/lib/hokm/deck"; import { @@ -17,7 +18,7 @@ import { } from "@/lib/hokm/types"; import { useI18n } from "@/lib/i18n"; import { useSessionStore } from "@/lib/session-store"; -import { cardStyleById, ownedReactions, ownedStickers } from "@/lib/online/gamification"; +import { cardBackById, cardFrontById, ownedReactions, ownedStickers } from "@/lib/online/gamification"; import { getService } from "@/lib/online/service"; import { cn } from "@/lib/cn"; import { PlayingCard } from "./PlayingCard"; @@ -34,12 +35,26 @@ function useCountdown(deadline: number | null) { return Math.max(0, Math.ceil((deadline - now) / 1000)); } +function useCardSkins() { + const frontId = useSessionStore((s) => s.profile?.cardFront ?? "classic"); + const backId = useSessionStore((s) => s.profile?.cardBack ?? "classic"); + const f = cardFrontById(frontId); + const b = cardBackById(backId); + return { + front: { bg1: f.bg1, bg2: f.bg2, border: f.border }, + back: { c1: b.c1, c2: b.c2, accent: b.accent }, + }; +} + export function GameTable({ onExit }: { onExit?: () => void } = {}) { const game = useGameStore((s) => s.game); const reset = useGameStore((s) => s.reset); const mode = useGameStore((s) => s.mode); const { t } = useI18n(); + const sfx = useSoundStore((s) => s.sfx); + const toggleSfx = useSoundStore((s) => s.toggleSfx); + const exit = onExit ?? reset; const { phase, players, hakem, trump, turn, currentTrick } = game; @@ -56,6 +71,17 @@ export function GameTable({ onExit }: { onExit?: () => void } = {}) {
{trump && } + + ))} +
+ + + {/* card back picker */} +
+

{t("profile.cardBack")}

+
+ {CARD_BACKS.filter((c) => ownedBacks.has(c.id)).map((c) => ( + ))}
+ {/* audio settings */} + + {/* stats */}

{t("profile.stats")}

@@ -259,3 +293,44 @@ function Stat({ label, value }: { label: string; value: string | number }) {
); } + +function SoundSettings() { + const { t } = useI18n(); + const { sfx, music, toggleSfx, toggleMusic } = useSoundStore(); + return ( +
+

{t("settings.audio")}

+ } label={t("settings.sound")} on={sfx} onClick={toggleSfx} /> + } label={t("settings.music")} on={music} onClick={toggleMusic} /> +
+ ); +} + +function ToggleRow({ + icon, + label, + on, + onClick, +}: { + icon: React.ReactNode; + label: string; + on: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx index 7d0c5d1..6ac861c 100644 --- a/src/components/screens/ShopScreen.tsx +++ b/src/components/screens/ShopScreen.tsx @@ -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() { -
+
- {cardstyles.map((item) => ( + {cardfronts.map((item) => ( + buy(item)} + preview={ + + ♠ + + } + /> + ))} +
+
+ +
+
+ {cardbacks.map((item) => ( ((set, get) => { if (cur.phase !== "playing" || cur.turn !== seat) return; const card = chooseCardAI(cur, seat); set({ game: playCard(cur, seat, card) }); + sound.play("cardPlay"); scheduleAuto(); } @@ -123,6 +125,7 @@ export const useGameStore = create((set, get) => { set({ turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null }); pending = setTimeout(() => { set({ game: dealForTrump(get().game) }); + sound.play("deal"); scheduleAuto(); }, TIMING.hakemDraw); break; @@ -145,6 +148,7 @@ export const useGameStore = create((set, get) => { const cur = get().game; const suit = chooseTrumpAI(cur.players[cur.hakem!].hand); set({ game: engineChooseTrump(cur, suit) }); + sound.play("trump"); scheduleAuto(); }, TIMING.aiTrump); } @@ -160,6 +164,7 @@ export const useGameStore = create((set, get) => { const cur = get().game; if (cur.phase !== "playing" || cur.turn !== seat) return; set({ game: playCard(cur, seat, chooseCardAI(cur, seat)), turnDeadline: null }); + sound.play("cardPlay"); scheduleAuto(); }, TURN_MS); } else { @@ -190,10 +195,16 @@ export const useGameStore = create((set, get) => { case "trick-complete": set({ turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null }); + sound.play("trickWin"); pending = setTimeout(() => { const next = advanceAfterTrick(get().game, KOT_POINTS); set({ game: next }); - if (next.phase === "match-over") recordRound(next.lastRoundResult); + if (next.phase === "match-over") { + recordRound(next.lastRoundResult); + sound.play(next.matchWinner === 0 ? "win" : "lose"); + } else if (next.phase === "round-over" && next.lastRoundResult?.kot) { + sound.play("kot"); + } scheduleAuto(); }, TIMING.trickPause); break; @@ -226,6 +237,7 @@ export const useGameStore = create((set, get) => { newMatch: (settings) => { clearPending(); + sound.init(); const initial = createInitialState(settings); set({ game: selectHakem(initial), @@ -247,6 +259,7 @@ export const useGameStore = create((set, get) => { newOnlineMatch: (cfg) => { clearPending(); + sound.init(); const names = cfg.players.map((p) => p.displayName) as GameSettings["names"]; const initial = createInitialState({ names, targetScore: cfg.targetScore }); set({ @@ -271,6 +284,7 @@ export const useGameStore = create((set, get) => { const g = get().game; if (g.phase !== "choosing-trump") return; set({ game: engineChooseTrump(g, suit), turnDeadline: null }); + sound.play("trump"); scheduleAuto(); }, @@ -278,6 +292,7 @@ export const useGameStore = create((set, get) => { const g = get().game; if (g.phase !== "playing" || g.turn !== 0) return; set({ game: playCard(g, 0, card), turnDeadline: null }); + sound.play("cardPlay"); scheduleAuto(); }, diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index b63c386..17e21c5 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -224,6 +224,15 @@ const fa: Dict = { "reactions.title": "شکلک", "stickers.title": "استیکر", + + "settings.audio": "تنظیمات صدا", + "settings.sound": "افکت صدا", + "settings.music": "موسیقی پس‌زمینه", + + "profile.cardFront": "روی کارت", + "profile.cardBack": "پشت کارت", + "shop.cardfronts": "روی کارت‌ها", + "shop.cardbacks": "پشت کارت‌ها", }; const en: Dict = { @@ -437,6 +446,15 @@ const en: Dict = { "reactions.title": "Emoji", "stickers.title": "Stickers", + + "settings.audio": "Audio", + "settings.sound": "Sound effects", + "settings.music": "Background music", + + "profile.cardFront": "Card front", + "profile.cardBack": "Card back", + "shop.cardfronts": "Card fronts", + "shop.cardbacks": "Card backs", }; const DICTS: Record = { fa, en }; diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts index 75fdabf..d6bf331 100644 --- a/src/lib/online/gamification.ts +++ b/src/lib/online/gamification.ts @@ -4,7 +4,8 @@ import { AchievementDef, AchievementUnlock, - CardStyleDef, + CardBackDef, + CardFrontDef, LeagueInfo, MatchSummary, PlayerStats, @@ -218,16 +219,51 @@ export function titleUnlocked( /* ---------------------------- Card styles ---------------------------- */ -export const CARD_STYLES: CardStyleDef[] = [ - { id: "classic", nameFa: "کلاسیک", nameEn: "Classic", c1: "#14274f", c2: "#0a142e", accent: "#d4af37", price: 0 }, +// Card BACKS (pattern on the reverse of every card). +export const CARD_BACKS: CardBackDef[] = [ + { id: "classic", nameFa: "کلاسیک", nameEn: "Classic", c1: "#14274f", c2: "#0a142e", accent: "#d4af37", price: 0, default: true }, { id: "sapphire", nameFa: "یاقوت کبود", nameEn: "Sapphire", c1: "#0b3a82", c2: "#06173a", accent: "#6aa6ff", price: 800 }, - { id: "ruby", nameFa: "یاقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 800 }, { id: "emerald", nameFa: "زمرد", nameEn: "Emerald", c1: "#0d6b5e", c2: "#062420", accent: "#2dd4bf", price: 1000 }, - { id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 1500 }, + { id: "ruby", nameFa: "یاقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 0, unlockRating: 1300 }, // earned + { id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 0, unlockWins: 50 }, // earned ]; -export function cardStyleById(id: string): CardStyleDef { - return CARD_STYLES.find((c) => c.id === id) ?? CARD_STYLES[0]; +// Card FRONTS (the face background/border behind the suit + rank). +export const CARD_FRONTS: CardFrontDef[] = [ + { id: "classic", nameFa: "کلاسیک", nameEn: "Classic", bg1: "#fffdf7", bg2: "#f3ead2", border: "rgba(0,0,0,0.12)", price: 0, default: true }, + { id: "ivory", nameFa: "عاج", nameEn: "Ivory", bg1: "#ffffff", bg2: "#eef2f8", border: "#c9ccd6", price: 600 }, + { id: "rosegold", nameFa: "رزگلد", nameEn: "Rose Gold", bg1: "#fff1ee", bg2: "#f6d9cf", border: "#d98a72", price: 900 }, + { id: "parchment", nameFa: "پوست‌نوشت", nameEn: "Parchment", bg1: "#fbf2d8", bg2: "#efd9a3", border: "#caa84a", price: 0, unlockRating: 1300 }, // earned + { id: "mint", nameFa: "نعنایی", nameEn: "Mint", bg1: "#f0fff8", bg2: "#d3f3e3", border: "#57c79a", price: 0, unlockWins: 50 }, // earned +]; + +export function cardBackById(id: string): CardBackDef { + return CARD_BACKS.find((c) => c.id === id) ?? CARD_BACKS[0]; +} +export function cardFrontById(id: string): CardFrontDef { + return CARD_FRONTS.find((c) => c.id === id) ?? CARD_FRONTS[0]; +} + +function ownedCosmeticIds( + defs: { id: string; price: number; default?: boolean; unlockRating?: number; unlockWins?: number }[], + profile: UserProfile, + purchased: string[] +): string[] { + const ids = new Set(); + for (const d of defs) { + const earned = + (d.unlockRating != null && profile.rating >= d.unlockRating) || + (d.unlockWins != null && profile.stats.wins >= d.unlockWins); + if (d.default || earned || purchased.includes(d.id)) ids.add(d.id); + } + return [...ids]; +} + +export function ownedCardBackIds(profile: UserProfile): string[] { + return ownedCosmeticIds(CARD_BACKS, profile, profile.ownedCardBacks ?? []); +} +export function ownedCardFrontIds(profile: UserProfile): string[] { + return ownedCosmeticIds(CARD_FRONTS, profile, profile.ownedCardFronts ?? []); } /* --------------------- Reactions (Sheklak / شکلک) -------------------- */ diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index f3da261..10b2602 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -3,7 +3,8 @@ // with timers, and computes rewards via gamification.ts. import { - CARD_STYLES, + CARD_BACKS, + CARD_FRONTS, REACTION_PACKS, STICKER_PACKS, applyMatchResult, @@ -116,12 +117,14 @@ function defaultProfile(session: AuthSession): UserProfile { currentWinStreak: 0, }, ownedAvatars: [AVATARS[0].id, AVATARS[1].id], - ownedCardStyles: ["classic"], + ownedCardFronts: ["classic"], + ownedCardBacks: ["classic"], ownedTitles: ["novice"], ownedReactionPacks: [], ownedStickerPacks: [], title: "novice", - cardStyle: "classic", + cardFront: "classic", + cardBack: "classic", achievements: {}, unlocked: [], createdAt: Date.now(), @@ -130,16 +133,19 @@ function defaultProfile(session: AuthSession): UserProfile { /** Backfill fields on older persisted profiles so the app never crashes. */ function migrateProfile(p: UserProfile): UserProfile { + const legacy = p as unknown as { ownedCardStyles?: string[]; cardStyle?: string }; return { ...p, plan: p.plan ?? "free", ownedAvatars: p.ownedAvatars ?? [AVATARS[0].id], - ownedCardStyles: p.ownedCardStyles ?? ["classic"], + ownedCardFronts: p.ownedCardFronts ?? ["classic"], + ownedCardBacks: p.ownedCardBacks ?? legacy.ownedCardStyles ?? ["classic"], ownedTitles: p.ownedTitles ?? ["novice"], ownedReactionPacks: p.ownedReactionPacks ?? [], ownedStickerPacks: p.ownedStickerPacks ?? [], title: p.title ?? "novice", - cardStyle: p.cardStyle ?? "classic", + cardFront: p.cardFront ?? "classic", + cardBack: p.cardBack ?? legacy.cardStyle ?? "classic", }; } @@ -328,7 +334,7 @@ export class MockOnlineService implements OnlineService { async updateProfile( patch: Partial< - Pick + Pick > ) { const p = await this.getProfile(); @@ -756,14 +762,22 @@ export class MockOnlineService implements OnlineService { price: 500 + i * 150, preview: a.emoji, })); - const cardItems: ShopItem[] = CARD_STYLES.filter((c) => c.price > 0).map((c) => ({ + const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({ id: c.id, - kind: "cardstyle", + kind: "cardback", nameFa: c.nameFa, nameEn: c.nameEn, price: c.price, preview: c.accent, })); + const frontItems: ShopItem[] = CARD_FRONTS.filter((c) => c.price > 0).map((c) => ({ + id: c.id, + kind: "cardfront", + nameFa: c.nameFa, + nameEn: c.nameEn, + price: c.price, + preview: c.bg2, + })); const reactionItems: ShopItem[] = REACTION_PACKS.filter((r) => r.price > 0).map((r) => ({ id: r.id, kind: "reactionpack", @@ -780,7 +794,7 @@ export class MockOnlineService implements OnlineService { price: p.price, preview: p.stickers[0], // sticker id; ShopScreen renders via })); - return [...avatarItems, ...cardItems, ...reactionItems, ...stickerItems]; + return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems]; } async buyItem(id: string) { @@ -788,15 +802,15 @@ export class MockOnlineService implements OnlineService { const items = await this.getShopItems(); const item = items.find((i) => i.id === id); if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" }; - const owned = - item.kind === "avatar" - ? p.ownedAvatars.includes(id) - : item.kind === "cardstyle" - ? p.ownedCardStyles.includes(id) - : item.kind === "reactionpack" - ? p.ownedReactionPacks.includes(id) - : p.ownedStickerPacks.includes(id); - if (owned) return { ok: false, messageFa: "قبلاً خریداری شده", messageEn: "Already owned" }; + const ownedMap: Record = { + avatar: p.ownedAvatars, + cardfront: p.ownedCardFronts, + cardback: p.ownedCardBacks, + reactionpack: p.ownedReactionPacks, + stickerpack: p.ownedStickerPacks, + }; + if (ownedMap[item.kind]?.includes(id)) + return { ok: false, messageFa: "قبلاً خریداری شده", messageEn: "Already owned" }; if (p.coins < item.price) return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" }; @@ -804,8 +818,10 @@ export class MockOnlineService implements OnlineService { ...p, coins: p.coins - item.price, ownedAvatars: item.kind === "avatar" ? [...p.ownedAvatars, id] : p.ownedAvatars, - ownedCardStyles: - item.kind === "cardstyle" ? [...p.ownedCardStyles, id] : p.ownedCardStyles, + ownedCardFronts: + item.kind === "cardfront" ? [...p.ownedCardFronts, id] : p.ownedCardFronts, + ownedCardBacks: + item.kind === "cardback" ? [...p.ownedCardBacks, id] : p.ownedCardBacks, ownedReactionPacks: item.kind === "reactionpack" ? [...p.ownedReactionPacks, id] : p.ownedReactionPacks, ownedStickerPacks: diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index ea82897..27717cf 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -46,7 +46,7 @@ export interface OnlineService { getProfile(): Promise; updateProfile( patch: Partial< - Pick + Pick > ): Promise; upgradePlan(): Promise; diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index e87b009..a0702f1 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -52,12 +52,14 @@ export interface UserProfile { // cosmetics ownedAvatars: string[]; - ownedCardStyles: string[]; + ownedCardFronts: string[]; + ownedCardBacks: 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 + cardFront: string; // equipped card-front style id + cardBack: string; // equipped card-back style id achievements: Record; // achievementId -> progress count unlocked: string[]; // achievementId list already unlocked @@ -124,14 +126,30 @@ export interface TitleDef { hintEn: string; } -export interface CardStyleDef { +export interface CardBackDef { id: string; nameFa: string; nameEn: string; c1: string; // back gradient start c2: string; // back gradient end accent: string; // pattern/border accent - price: number; // 0 = free/default + price: number; // >0 = purchasable + default?: boolean; + unlockRating?: number; + unlockWins?: number; +} + +export interface CardFrontDef { + id: string; + nameFa: string; + nameEn: string; + bg1: string; // face gradient start + bg2: string; // face gradient end + border: string; // face border color + price: number; + default?: boolean; + unlockRating?: number; + unlockWins?: number; } /* --------------------- Reactions (Sheklak / شکلک) -------------------- */ @@ -293,7 +311,12 @@ export interface LeaderboardEntry { /* ------------------------------- Shop -------------------------------- */ -export type ShopItemKind = "avatar" | "cardstyle" | "reactionpack" | "stickerpack"; +export type ShopItemKind = + | "avatar" + | "cardfront" + | "cardback" + | "reactionpack" + | "stickerpack"; export interface ShopItem { id: string; diff --git a/src/lib/session-store.ts b/src/lib/session-store.ts index 3bb754d..b668411 100644 --- a/src/lib/session-store.ts +++ b/src/lib/session-store.ts @@ -22,7 +22,7 @@ interface SessionStore { signOut: () => Promise; updateProfile: ( - patch: Partial> + patch: Partial> ) => Promise; upgradePlan: () => Promise; } diff --git a/src/lib/sound-store.ts b/src/lib/sound-store.ts new file mode 100644 index 0000000..0b454de --- /dev/null +++ b/src/lib/sound-store.ts @@ -0,0 +1,26 @@ +"use client"; + +import { create } from "zustand"; +import { sound } from "./sound"; + +interface SoundStore { + sfx: boolean; + music: boolean; + toggleSfx: () => void; + toggleMusic: () => void; +} + +export const useSoundStore = create((set, get) => ({ + sfx: sound.sfxEnabled, + music: sound.musicEnabled, + toggleSfx: () => { + const v = !get().sfx; + sound.setSfxEnabled(v); + set({ sfx: v }); + }, + toggleMusic: () => { + const v = !get().music; + sound.setMusicEnabled(v); + set({ music: v }); + }, +})); diff --git a/src/lib/sound.ts b/src/lib/sound.ts new file mode 100644 index 0000000..3da6983 --- /dev/null +++ b/src/lib/sound.ts @@ -0,0 +1,198 @@ +// Procedural sound engine (Web Audio) — no audio files. +// Synthesizes UI/game SFX and a gentle ambient background loop. + +export type Sfx = + | "click" + | "cardPlay" + | "deal" + | "trump" + | "trickWin" + | "win" + | "lose" + | "message" + | "notify" + | "award" + | "levelUp" + | "purchase" + | "kot"; + +const LS_SFX = "hokm.sfx"; +const LS_MUSIC = "hokm.music"; + +function loadBool(key: string, def = true): boolean { + if (typeof window === "undefined") return def; + const v = localStorage.getItem(key); + return v == null ? def : v === "1"; +} + +class SoundManager { + private ctx: AudioContext | null = null; + private master: GainNode | null = null; + private musicGain: GainNode | null = null; + private musicTimer: ReturnType | null = null; + private step = 0; + + sfxEnabled = loadBool(LS_SFX); + musicEnabled = loadBool(LS_MUSIC); + + /** Must be called from a user gesture to unlock audio. */ + init() { + if (typeof window === "undefined") return; + if (!this.ctx) { + const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + if (!AC) return; + this.ctx = new AC(); + this.master = this.ctx.createGain(); + this.master.gain.value = 0.5; + this.master.connect(this.ctx.destination); + this.musicGain = this.ctx.createGain(); + this.musicGain.gain.value = 0.12; + this.musicGain.connect(this.master); + } + if (this.ctx.state === "suspended") void this.ctx.resume(); + if (this.musicEnabled) this.startMusic(); + } + + setSfxEnabled(b: boolean) { + this.sfxEnabled = b; + if (typeof window !== "undefined") localStorage.setItem(LS_SFX, b ? "1" : "0"); + if (b) this.init(); + } + + setMusicEnabled(b: boolean) { + this.musicEnabled = b; + if (typeof window !== "undefined") localStorage.setItem(LS_MUSIC, b ? "1" : "0"); + if (b) { + this.init(); + this.startMusic(); + } else { + this.stopMusic(); + } + } + + private tone( + freq: number, + start: number, + dur: number, + opts: { type?: OscillatorType; gain?: number; to?: number } = {} + ) { + if (!this.ctx || !this.master) return; + const osc = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + osc.type = opts.type ?? "sine"; + osc.frequency.setValueAtTime(freq, start); + if (opts.to) osc.frequency.exponentialRampToValueAtTime(opts.to, start + dur); + const peak = opts.gain ?? 0.3; + g.gain.setValueAtTime(0.0001, start); + g.gain.exponentialRampToValueAtTime(peak, start + 0.012); + g.gain.exponentialRampToValueAtTime(0.0001, start + dur); + osc.connect(g); + g.connect(this.master); + osc.start(start); + osc.stop(start + dur + 0.02); + } + + private seq(notes: [number, number][], gap = 0.11, opts?: { type?: OscillatorType; gain?: number }) { + if (!this.ctx) return; + const t0 = this.ctx.currentTime; + notes.forEach(([freq, dur], i) => this.tone(freq, t0 + i * gap, dur, opts)); + } + + play(name: Sfx) { + if (!this.sfxEnabled) return; + this.init(); + if (!this.ctx) return; + const t = this.ctx.currentTime; + switch (name) { + case "click": + this.tone(520, t, 0.06, { type: "square", gain: 0.12 }); + break; + case "cardPlay": + this.tone(360, t, 0.09, { type: "triangle", gain: 0.18, to: 220 }); + break; + case "deal": + for (let i = 0; i < 4; i++) + this.tone(320 + i * 20, t + i * 0.08, 0.07, { type: "triangle", gain: 0.14, to: 200 }); + break; + case "trump": + this.seq([[440, 0.12], [660, 0.18]], 0.1, { type: "sine", gain: 0.25 }); + break; + case "trickWin": + this.seq([[880, 0.12], [1320, 0.16]], 0.08, { type: "sine", gain: 0.22 }); + break; + case "win": + this.seq([[523, 0.16], [659, 0.16], [784, 0.16], [1047, 0.4]], 0.13, { type: "sine", gain: 0.3 }); + break; + case "lose": + this.seq([[440, 0.2], [392, 0.2], [311, 0.45]], 0.16, { type: "triangle", gain: 0.25 }); + break; + case "message": + this.tone(720, t, 0.1, { type: "sine", gain: 0.2, to: 900 }); + break; + case "notify": + this.seq([[660, 0.12], [880, 0.14]], 0.1, { type: "sine", gain: 0.2 }); + break; + case "award": + this.seq([[784, 0.1], [988, 0.1], [1319, 0.25]], 0.09, { type: "sine", gain: 0.25 }); + break; + case "levelUp": + this.seq([[523, 0.1], [659, 0.1], [784, 0.1], [988, 0.1], [1319, 0.35]], 0.09, { type: "sine", gain: 0.28 }); + break; + case "purchase": + this.seq([[1047, 0.08], [1319, 0.12]], 0.07, { type: "square", gain: 0.16 }); + break; + case "kot": + this.seq([[330, 0.14], [262, 0.14], [196, 0.4]], 0.12, { type: "sawtooth", gain: 0.2 }); + break; + } + } + + // Gentle ambient loop on a Persian-flavored scale (Dastgah-ish). + private MUSIC = [293.66, 311.13, 392, 440, 466.16, 392, 311.13, 293.66]; + + startMusic() { + if (!this.musicEnabled || this.musicTimer || !this.ctx || !this.musicGain) return; + const playNote = () => { + if (!this.ctx || !this.musicGain) return; + const freq = this.MUSIC[this.step % this.MUSIC.length]; + this.step++; + const osc = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + const t = this.ctx.currentTime; + osc.type = "sine"; + osc.frequency.value = freq; + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(0.5, t + 0.3); + g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6); + osc.connect(g); + g.connect(this.musicGain); + osc.start(t); + osc.stop(t + 1.7); + // soft fifth harmony every other note + if (this.step % 2 === 0) { + const o2 = this.ctx.createOscillator(); + const g2 = this.ctx.createGain(); + o2.type = "sine"; + o2.frequency.value = freq * 1.5; + g2.gain.setValueAtTime(0.0001, t); + g2.gain.exponentialRampToValueAtTime(0.22, t + 0.3); + g2.gain.exponentialRampToValueAtTime(0.0001, t + 1.4); + o2.connect(g2); + g2.connect(this.musicGain); + o2.start(t); + o2.stop(t + 1.5); + } + }; + playNote(); + this.musicTimer = setInterval(playNote, 900); + } + + stopMusic() { + if (this.musicTimer) { + clearInterval(this.musicTimer); + this.musicTimer = null; + } + } +} + +export const sound = new SoundManager();