03dfbe1e67
- 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>
197 lines
7.1 KiB
TypeScript
197 lines
7.1 KiB
TypeScript
"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 2–10. 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 2–10, 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>
|
||
);
|
||
}
|