diff --git a/src/components/frontend/minigames/poker/CardView.tsx b/src/components/frontend/minigames/poker/CardView.tsx index e7df31a..a8726e9 100644 --- a/src/components/frontend/minigames/poker/CardView.tsx +++ b/src/components/frontend/minigames/poker/CardView.tsx @@ -2,6 +2,7 @@ import { cn } from "@/lib/utils"; import type { Card, Rank, Suit } from "@/lib/minigames/poker"; +import { mapCardToSvg, useCardStyle } from "./cardStyle"; const RANK_LABELS: Record = { 2: "2", @@ -47,7 +48,11 @@ export function CardView({ highlighted = false, dimmed = false, }: CardViewProps) { + const { style: cardStyle } = useCardStyle(); + const lightFace = cardStyle === "light"; + const classicFace = cardStyle === "classic"; const revealed = card !== null && faceUp; + const redSuitClass = lightFace ? "text-rose-600" : "text-rose-400"; const label = card ? `${RANK_LABELS[card.rank]} of ${ card.suit === "s" @@ -76,28 +81,38 @@ export function CardView({ style={dealIndex > 0 ? { animationDelay: `${dealIndex * 60}ms` } : undefined} aria-label={label} > - - - {card && ( - <> - - {RANK_LABELS[card.rank]} - {SUIT_SYMBOLS[card.suit]} - - - {SUIT_SYMBOLS[card.suit]} - - - )} + + + + {card && classicFace && ( + + )} + {card && !classicFace && ( + <> + + {RANK_LABELS[card.rank]} + {SUIT_SYMBOLS[card.suit]} + + + {SUIT_SYMBOLS[card.suit]} + + + )} + + - ); diff --git a/src/components/frontend/minigames/poker/PokerCardStyleToggle.tsx b/src/components/frontend/minigames/poker/PokerCardStyleToggle.tsx new file mode 100644 index 0000000..1ad1820 --- /dev/null +++ b/src/components/frontend/minigames/poker/PokerCardStyleToggle.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Images, Moon, Sun } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + POKER_CARD_STYLE_OPTIONS, + normalizeCardStyle, + useCardStyle, + type PokerCardStyle, +} from "./cardStyle"; + +const STYLE_ICONS: Record = { + light: Sun, + dark: Moon, + classic: Images, +}; + +export function PokerCardStyleToggle() { + const { style, setStyle } = useCardStyle(); + const Icon = STYLE_ICONS[style]; + const label = POKER_CARD_STYLE_OPTIONS.find((option) => option.value === style)?.label ?? "Light"; + + return ( + + + + + + setStyle(normalizeCardStyle(value))} + > + {POKER_CARD_STYLE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + ); +} diff --git a/src/components/frontend/minigames/poker/cardStyle.tsx b/src/components/frontend/minigames/poker/cardStyle.tsx new file mode 100644 index 0000000..99573cc --- /dev/null +++ b/src/components/frontend/minigames/poker/cardStyle.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import type { Card } from "@/lib/minigames/poker"; + +export type PokerCardStyle = "light" | "dark" | "classic"; + +const CARD_STYLE_STORAGE_KEY = "poker-card-style"; + +const RANK_FILE_PREFIXES: Record = { + 11: "jack", + 12: "queen", + 13: "king", + 14: "ace", +}; + +const SUIT_FILE_SUFFIXES = { + s: "spades", + h: "hearts", + d: "diamonds", + c: "clubs", +} as const; + +export function mapCardToSvg(card: Card): string { + const prefix = RANK_FILE_PREFIXES[card.rank] ?? String(card.rank); + const suffix = SUIT_FILE_SUFFIXES[card.suit]; + return `/images/assets/playing-cards/${prefix}_of_${suffix}.svg`; +} + +export function normalizeCardStyle(value: unknown): PokerCardStyle { + return value === "dark" || value === "classic" ? value : "light"; +} + +export const POKER_CARD_STYLE_OPTIONS: readonly { + readonly value: PokerCardStyle; + readonly label: string; +}[] = [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + { value: "classic", label: "Classic SVG" }, +]; + +interface CardStyleContextValue { + readonly style: PokerCardStyle; + readonly setStyle: (style: PokerCardStyle) => void; +} + +const CardStyleContext = createContext({ + style: "light", + setStyle: () => {}, +}); + +export function PokerCardStyleProvider({ children }: { readonly children: ReactNode }) { + const [style, setStyleState] = useState("light"); + + useEffect(() => { + try { + const stored = window.localStorage.getItem(CARD_STYLE_STORAGE_KEY); + if (stored !== null) setStyleState(normalizeCardStyle(stored)); + } catch { + // storage unavailable (private mode, disabled): keep the default + } + }, []); + + const setStyle = useCallback((next: PokerCardStyle) => { + setStyleState(next); + try { + window.localStorage.setItem(CARD_STYLE_STORAGE_KEY, next); + } catch { + // non-fatal: the choice just will not persist + } + }, []); + + const value = useMemo(() => ({ style, setStyle }), [style, setStyle]); + + return {children}; +} + +export function useCardStyle(): CardStyleContextValue { + return useContext(CardStyleContext); +} diff --git a/tests/int/poker-card-skins.int.spec.ts b/tests/int/poker-card-skins.int.spec.ts new file mode 100644 index 0000000..0268e1a --- /dev/null +++ b/tests/int/poker-card-skins.int.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import type { Card } from "@/lib/minigames/poker"; +import { mapCardToSvg, normalizeCardStyle } from "@/components/frontend/minigames/poker/cardStyle"; + +function cardOf(rank: Card["rank"], suit: Card["suit"]): Card { + return { rank, suit }; +} + +describe("mapCardToSvg", () => { + it("maps number cards to their numbered asset path", () => { + expect(mapCardToSvg(cardOf(7, "h"))).toBe("/images/assets/playing-cards/7_of_hearts.svg"); + expect(mapCardToSvg(cardOf(10, "s"))).toBe("/images/assets/playing-cards/10_of_spades.svg"); + expect(mapCardToSvg(cardOf(2, "c"))).toBe("/images/assets/playing-cards/2_of_clubs.svg"); + }); + + it("maps face ranks to word asset paths", () => { + expect(mapCardToSvg(cardOf(11, "d"))).toBe("/images/assets/playing-cards/jack_of_diamonds.svg"); + expect(mapCardToSvg(cardOf(12, "c"))).toBe("/images/assets/playing-cards/queen_of_clubs.svg"); + expect(mapCardToSvg(cardOf(13, "h"))).toBe("/images/assets/playing-cards/king_of_hearts.svg"); + expect(mapCardToSvg(cardOf(14, "s"))).toBe("/images/assets/playing-cards/ace_of_spades.svg"); + }); + + it("covers all four suits for a spot-check rank", () => { + expect(mapCardToSvg(cardOf(9, "s"))).toBe("/images/assets/playing-cards/9_of_spades.svg"); + expect(mapCardToSvg(cardOf(9, "h"))).toBe("/images/assets/playing-cards/9_of_hearts.svg"); + expect(mapCardToSvg(cardOf(9, "d"))).toBe("/images/assets/playing-cards/9_of_diamonds.svg"); + expect(mapCardToSvg(cardOf(9, "c"))).toBe("/images/assets/playing-cards/9_of_clubs.svg"); + }); +}); + +describe("normalizeCardStyle", () => { + it("accepts the three known style values", () => { + expect(normalizeCardStyle("light")).toBe("light"); + expect(normalizeCardStyle("dark")).toBe("dark"); + expect(normalizeCardStyle("classic")).toBe("classic"); + }); + + it("falls back to light for unknown or missing values", () => { + expect(normalizeCardStyle("bogus")).toBe("light"); + expect(normalizeCardStyle(null)).toBe("light"); + expect(normalizeCardStyle(undefined)).toBe("light"); + expect(normalizeCardStyle(42)).toBe("light"); + expect(normalizeCardStyle("")).toBe("light"); + }); +});