M3 기반: 공용 베팅 엔진, 카드 정의, 카드·칩 화면 컴포넌트, 돈 표현 금지 검사
- packages/engine/betting: 한국식(체크·삥·콜·따당·쿼터·하프·풀·다이, 라운드당 1회·첫 행동에서만 레이즈),
노리밋·팟리밋(최소 레이즈, 불완전 올인 레이즈는 재오픈 안 함, BB 옵션), 앤티·블라인드, 올인,
받지 않은 베팅 반환, 사이드 팟 분할, 동점 분배(홀수 칩 순서), 시간 초과 자동 행동
- 테스트 24개: 포커·섯다 계획서 계산 예시, 행동별 상태 전이, 사이드 팟 예시, 무작위 베팅 1,500판
(칩 총량 보존·라운드 종료·라운드 끝 금액 일치·팟 합계=판 전체)
- packages/engine/cards: 트럼프 52+조커, 화투 48(+보너스)·섯다 20장 정의와 테스트
- 웹: 직접 그린 SVG 트럼프·화투 카드, 칩 표시("1,250 칩"), 놀이용 칩 안내문
- apps/web/src/no-money-wording.test.ts: 베팅 게임 화면에 통화 기호·"원"·판돈·충전·환전 등 금지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
102
apps/web/src/components/cards/PlayingCard.tsx
Normal file
102
apps/web/src/components/cards/PlayingCard.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/** Trump card drawn in SVG (no image assets). Big corner index for readability on PC and phones. */
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const SUIT_SYMBOL: Record<string, string> = { S: '♠', H: '♥', D: '◆', C: '♣' };
|
||||
const SUIT_KO: Record<string, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
|
||||
const RANK_LABEL: Record<string, string> = { T: '10', J: 'J', Q: 'Q', K: 'K', A: 'A' };
|
||||
const FACE_KO: Record<string, string> = { J: '잭', Q: '퀸', K: '킹', A: '에이스' };
|
||||
|
||||
export type CardSize = 'sm' | 'md' | 'lg';
|
||||
const WIDTH: Record<CardSize, number> = { sm: 44, md: 64, lg: 88 };
|
||||
|
||||
export interface PlayingCardProps {
|
||||
/** e.g. "AS", "TD", "X1" (joker). null = face down. */
|
||||
card: string | null;
|
||||
size?: CardSize;
|
||||
selected?: boolean;
|
||||
dimmed?: boolean;
|
||||
highlight?: boolean;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
style?: CSSProperties;
|
||||
/** Colour-blind friendly: 4-colour suits. */
|
||||
fourColor?: boolean;
|
||||
}
|
||||
|
||||
export function cardAria(card: string | null): string {
|
||||
if (!card) return '뒤집힌 카드';
|
||||
if (card.startsWith('X')) return '조커';
|
||||
const r = card[0]!;
|
||||
return `${SUIT_KO[card[1]!]} ${FACE_KO[r] ?? RANK_LABEL[r] ?? r}`;
|
||||
}
|
||||
|
||||
export function PlayingCard({ card, size = 'md', selected, dimmed, highlight, onClick, title, style, fourColor }: PlayingCardProps) {
|
||||
const w = WIDTH[size];
|
||||
const h = Math.round(w * 1.4);
|
||||
const lift = selected ? -Math.round(w * 0.22) : 0;
|
||||
const common: CSSProperties = {
|
||||
width: w,
|
||||
height: h,
|
||||
flex: 'none',
|
||||
transform: `translateY(${lift}px)`,
|
||||
transition: 'transform 0.12s ease',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
opacity: dimmed ? 0.45 : 1,
|
||||
filter: highlight ? 'drop-shadow(0 0 6px #f59e0b)' : 'drop-shadow(0 1px 2px rgba(0,0,0,0.25))',
|
||||
...style,
|
||||
};
|
||||
const Tag = onClick ? 'button' : 'span';
|
||||
const btnReset: CSSProperties = onClick ? { padding: 0, border: 0, background: 'none' } : {};
|
||||
if (!card) {
|
||||
return (
|
||||
<Tag style={{ ...common, ...btnReset }} onClick={onClick} aria-label={title ?? '뒤집힌 카드'} title={title}>
|
||||
<svg viewBox="0 0 100 140" width={w} height={h} aria-hidden>
|
||||
<rect x="1" y="1" width="98" height="138" rx="9" fill="#1e3a8a" stroke="#fff" strokeWidth="3" />
|
||||
<rect x="9" y="9" width="82" height="122" rx="6" fill="none" stroke="#93c5fd" strokeWidth="2" />
|
||||
<path d="M50 30 L70 70 L50 110 L30 70 Z" fill="#3b82f6" opacity="0.7" />
|
||||
</svg>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
const joker = card.startsWith('X');
|
||||
const suit = card[1]!;
|
||||
const rank = card[0]!;
|
||||
const color = joker
|
||||
? card === 'X1' ? '#111827' : '#dc2626'
|
||||
: fourColor
|
||||
? { S: '#111827', H: '#dc2626', D: '#2563eb', C: '#15803d' }[suit]!
|
||||
: suit === 'H' || suit === 'D'
|
||||
? '#dc2626'
|
||||
: '#111827';
|
||||
const label = joker ? '★' : (RANK_LABEL[rank] ?? rank);
|
||||
const sym = joker ? '' : SUIT_SYMBOL[suit];
|
||||
return (
|
||||
<Tag style={{ ...common, ...btnReset }} onClick={onClick} aria-label={title ?? cardAria(card)} aria-pressed={onClick ? !!selected : undefined} title={title}>
|
||||
<svg viewBox="0 0 100 140" width={w} height={h} aria-hidden>
|
||||
<rect x="1" y="1" width="98" height="138" rx="9" fill="#fffef9" stroke={selected ? '#2563eb' : '#9ca3af'} strokeWidth={selected ? 5 : 2} />
|
||||
<text x="9" y="33" fontSize="30" fontWeight="800" fill={color} fontFamily="Pretendard Variable, sans-serif">
|
||||
{label}
|
||||
</text>
|
||||
<text x="11" y="60" fontSize="26" fill={color}>
|
||||
{sym}
|
||||
</text>
|
||||
{joker ? (
|
||||
<text x="50" y="98" fontSize="22" fontWeight="800" textAnchor="middle" fill={color}>
|
||||
JOKER
|
||||
</text>
|
||||
) : 'JQK'.includes(rank) ? (
|
||||
<>
|
||||
<rect x="30" y="44" width="56" height="78" rx="6" fill={color} opacity="0.1" />
|
||||
<text x="58" y="100" fontSize="44" fontWeight="800" textAnchor="middle" fill={color}>
|
||||
{rank}
|
||||
</text>
|
||||
</>
|
||||
) : (
|
||||
<text x="60" y="108" fontSize="54" textAnchor="middle" fill={color}>
|
||||
{sym}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user