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
+35 -7
View File
@@ -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 } = {}) {
<Scoreboard />
<div className="flex items-center gap-2">
{trump && <TrumpBadge trump={trump} />}
<button
onClick={toggleSfx}
className="glass rounded-full p-2.5 hover:bg-navy-800 transition"
title={t("settings.sound")}
>
{sfx ? (
<Volume2 className="size-4 text-gold-400" />
) : (
<VolumeX className="size-4 text-cream/60" />
)}
</button>
<button
onClick={exit}
className="glass rounded-full p-2.5 hover:bg-navy-800 transition"
@@ -239,9 +265,7 @@ function OpponentHand({
horizontal?: boolean;
}) {
const count = useGameStore((s) => s.game.players[seat].hand.length);
const styleId = useSessionStore((s) => s.profile?.cardStyle ?? "classic");
const cs = cardStyleById(styleId);
const back = { c1: cs.c1, c2: cs.c2, accent: cs.accent };
const { back } = useCardSkins();
const cards = Array.from({ length: count });
return (
<div
@@ -287,6 +311,7 @@ function TrickArea({
winner: Seat | null;
phase: string;
}) {
const { front } = useCardSkins();
return (
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative size-1 ">
@@ -315,7 +340,7 @@ function TrickArea({
: undefined,
}}
>
<PlayingCard card={pc.card} size="md" />
<PlayingCard card={pc.card} size="md" front={front} />
</motion.div>
);
})}
@@ -332,6 +357,7 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
const phase = useGameStore((s) => s.game.phase);
const turn = useGameStore((s) => s.game.turn);
const playHuman = useGameStore((s) => s.playHuman);
const { front } = useCardSkins();
const sorted = sortHand(hand);
const myTurn = phase === "playing" && turn === 0;
@@ -376,6 +402,7 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
card={card}
size="lg"
dimmed={dimmed}
front={front}
className={cn(playable && "ring-2 ring-gold-400/70")}
/>
</motion.button>
@@ -627,6 +654,7 @@ function Backdrop({ children }: { children: React.ReactNode }) {
function HakemOverlay() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
const { front } = useCardSkins();
const hakemName = game.hakem != null ? game.players[game.hakem].name : "";
return (
<Backdrop>
@@ -645,7 +673,7 @@ function HakemOverlay() {
animate={{ opacity: 1, y: 0, rotateY: 0 }}
transition={{ delay: i * 0.12 }}
>
<PlayingCard card={pc.card} size="sm" />
<PlayingCard card={pc.card} size="sm" front={front} />
</motion.div>
))}
</div>