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