Files
HokmPlay/src/components/PlayingCard.tsx
T
soroush.asadi 03dfbe1e67
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 7m38s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 1s
Match intro "players joining" loading screen + i18n fix; checkpoint
- MatchIntroOverlay: UNO-style pre-game reveal — the 4 seats animate into the
  table (with "?" placeholders until each player's data streams in for live
  matches), a 3-2-1-GO countdown, then the table shows. Wired via game-store
  matchIntroPending/consumeIntro, rendered online-only in GameScreen.
- Fix: intro.found / intro.getReady / intro.go existed only in the Persian dict;
  added the English strings (would have shown raw keys to EN users).
- Checkpoint of the in-progress UI/social batch (CoinsPill, shop titles section,
  friend-request rate limit, etc.) — all green.

Verified: tsc + next build + scripts/sim.ts + dotnet build server/Hokm.slnx all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:58:54 +03:30

197 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { cn } from "@/lib/cn";
import { Card, SUIT_IS_RED, SUIT_SYMBOL, rankLabel } from "@/lib/hokm/types";
import type { CardBackPattern } from "@/lib/online/types";
import { cardBackMotif, cardBackVisual } from "@/lib/cardBack";
const SIZES = {
sm: { w: 44, h: 62, rank: "text-[11px]", center: "text-2xl", radius: 7 },
md: { w: 62, h: 87, rank: "text-sm", center: "text-3xl", radius: 9 },
lg: { w: 78, h: 110, rank: "text-base", center: "text-4xl", radius: 11 },
xl: { w: 92, h: 130, rank: "text-lg", center: "text-5xl", radius: 13 },
} as const;
export type CardSize = keyof typeof SIZES;
interface CardBack { c1: string; c2: string; accent: string; pattern?: CardBackPattern; motif?: string; }
interface CardFront { bg1: string; bg2: string; border: string; }
interface Props {
card?: Card;
faceDown?: boolean;
size?: CardSize;
className?: string;
dimmed?: boolean;
back?: CardBack;
front?: CardFront;
}
/* Pip positions (x,y as fractions of the card) for number cards 210. Bottom-half
pips (y>0.5) render rotated 180°, like a real deck — so each rank looks distinct. */
const PL = 0.3, PC = 0.5, PR = 0.7;
const PIP_LAYOUT: Record<number, [number, number][]> = {
2: [[PC, 0.24], [PC, 0.76]],
3: [[PC, 0.22], [PC, 0.5], [PC, 0.78]],
4: [[PL, 0.26], [PR, 0.26], [PL, 0.74], [PR, 0.74]],
5: [[PL, 0.26], [PR, 0.26], [PC, 0.5], [PL, 0.74], [PR, 0.74]],
6: [[PL, 0.24], [PR, 0.24], [PL, 0.5], [PR, 0.5], [PL, 0.76], [PR, 0.76]],
7: [[PL, 0.23], [PR, 0.23], [PC, 0.365], [PL, 0.5], [PR, 0.5], [PL, 0.77], [PR, 0.77]],
8: [[PL, 0.23], [PR, 0.23], [PC, 0.365], [PL, 0.5], [PR, 0.5], [PC, 0.635], [PL, 0.77], [PR, 0.77]],
9: [[PL, 0.22], [PR, 0.22], [PL, 0.41], [PR, 0.41], [PC, 0.5], [PL, 0.59], [PR, 0.59], [PL, 0.78], [PR, 0.78]],
10: [[PL, 0.22], [PR, 0.22], [PC, 0.32], [PL, 0.41], [PR, 0.41], [PL, 0.59], [PR, 0.59], [PC, 0.68], [PL, 0.78], [PR, 0.78]],
};
function Pips({ rank, symbol, color, w }: { rank: number; symbol: string; color: string; w: number }) {
const layout = PIP_LAYOUT[rank];
if (!layout) return null;
const size = (rank >= 9 ? 0.165 : rank >= 7 ? 0.19 : 0.22) * w;
return (
<div className="absolute inset-0 pointer-events-none">
{layout.map(([x, y], i) => (
<span
key={i}
className="absolute font-bold"
style={{
left: `${x * 100}%`,
top: `${y * 100}%`,
transform: `translate(-50%,-50%)${y > 0.5 ? " rotate(180deg)" : ""}`,
fontSize: size,
lineHeight: 1,
color,
}}
>
{symbol}
</span>
))}
</div>
);
}
export function PlayingCard({
card,
faceDown,
size = "md",
className,
dimmed,
back,
front,
}: Props) {
const s = SIZES[size];
/* ── Face-down ─────────────────────────────────────────────────── */
if (faceDown || !card) {
const visual = back ? cardBackVisual(back.c1, back.c2, back.accent, back.pattern) : null;
const styled = back && visual ? {
width: s.w, height: s.h, borderRadius: s.radius,
background: visual.background,
backgroundSize: visual.backgroundSize,
border: `1.5px solid ${back.accent}88`,
boxShadow: "0 6px 18px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.12)",
} : { width: s.w, height: s.h };
const motif = back ? cardBackMotif(back.pattern, back.motif) : "✦";
return (
<div
className={cn(!back && "card-back", "shrink-0", className)}
style={{ ...styled, borderRadius: s.radius }}
aria-hidden
>
<div
className="h-full w-full flex items-center justify-center"
style={{ borderRadius: s.radius }}
>
{motif && (
<span
className={cn("font-bold select-none", !back && "text-gold-500/70")}
style={{
fontSize: s.w * 0.34,
color: back ? `${back.accent}dd` : undefined,
textShadow: back ? `0 1px 3px rgba(0,0,0,0.45)` : undefined,
}}
>
{motif}
</span>
)}
</div>
</div>
);
}
/* ── Face-up ────────────────────────────────────────────────────── */
const red = SUIT_IS_RED[card.suit];
const symbol = SUIT_SYMBOL[card.suit];
const label = rankLabel(card.rank);
const rank = card.rank;
// UNO-style: suit-aware background
const cardBg = front
? `linear-gradient(160deg,${front.bg1},${front.bg2})`
: red
? "linear-gradient(160deg,#fff8f7,#fdecea)"
: "linear-gradient(160deg,#fefefe,#f4f2ec)";
const borderColor = front?.border ?? (red ? "rgba(200,70,70,0.22)" : "rgba(50,50,80,0.15)");
// Bold suit colours (UNO-style vivid)
const inkColor = red ? "#c0202a" : "#1c1c38";
const pipColor = red ? "#e03540" : "#2a2a50";
return (
<div
className={cn("shrink-0 relative select-none transition-opacity", dimmed && "opacity-40", className)}
style={{
width: s.w, height: s.h, borderRadius: s.radius,
background: cardBg,
border: `1.5px solid ${borderColor}`,
boxShadow: "0 6px 18px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.85)",
}}
>
{/* Top-left corner */}
<div
className={cn("absolute top-[3px] left-[5px] leading-[1.1] font-black", s.rank)}
style={{ color: inkColor }}
>
<div>{label}</div>
<div style={{ color: pipColor, fontSize: "0.82em" }}>{symbol}</div>
</div>
{/* Center: pip layout for 210, big rank for J/Q/K, single big pip for Ace */}
{rank >= 2 && rank <= 10 ? (
<Pips rank={rank} symbol={symbol} color={pipColor} w={s.w} />
) : rank === 14 ? (
<div className="absolute inset-0 flex items-center justify-center font-black"
style={{ color: pipColor, textShadow: "0 2px 10px rgba(0,0,0,0.12)" }}>
<span style={{ fontSize: s.w * 0.54, lineHeight: 1 }}>{symbol}</span>
</div>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center font-black"
style={{ lineHeight: 0.9 }}>
<span style={{ fontSize: s.w * 0.46, color: inkColor }}>{label}</span>
<span style={{ fontSize: s.w * 0.27, color: pipColor, marginTop: s.w * -0.03 }}>{symbol}</span>
</div>
)}
{/* Bottom-right corner (rotated 180°) */}
<div
className={cn("absolute bottom-[3px] right-[5px] leading-[1.1] font-black rotate-180", s.rank)}
style={{ color: inkColor }}
>
<div>{label}</div>
<div style={{ color: pipColor, fontSize: "0.82em" }}>{symbol}</div>
</div>
{/* Subtle inner rim for red suits — UNO-style */}
{red && (
<div
className="absolute inset-[3px] pointer-events-none"
style={{
borderRadius: Math.max(0, s.radius - 4),
border: "1px solid rgba(210,40,40,0.14)",
}}
/>
)}
</div>
);
}