Files
joke-app/apps/web/src/components/cards/HwatuCard.tsx
EJClaw f5e4a7ce59 M5: 고스톱·맞고 규칙·화면·테스트
- packages/games/src/gostop: 화투 48장 + 보너스패(cards), 족보·국진·최종 공식·박·독박 정산(scoring),
  패 돌리기·바닥 정리·광팔기·총통·먹기 판정(쪽/뻑/따닥/쓸/폭탄/흔들기/보너스패)·피 뺏기·고/스톱·나가리·
  첫뻑/연뻑/삼뻑(round), validate·view(남의 손패·더미 숨김, 매칭/점수 미리 계산)·시간 초과·나가기(index).
  2인 맞고, 3인 고스톱, 4~5인 광팔기, 여러 판 세트(방 점수 제로섬).
- 테스트: 문서 §10 체크리스트 전부, 1,000 시드 무작위 진행(카드·점수 보존, onTimeout 합법),
  engine randomPlayout(누출·JSON·결정성), 정보 누출 속성 테스트.
- 화면 apps/web/src/games/gostop: 월별 바닥, 매칭 하이라이트, 두 장 중 고르기, 흔들기·폭탄·고/스톱·총통·
  국진·광팔기 선택, 먹은 패 종류별 묶음과 족보 진행, 이벤트 알림, 판 결과 계산 과정, PC·휴대폰·다크 모드.
- HwatuCard: 보너스패(2피·3피) 그림 추가. 게임 목록·레지스트리 등록, 홈 "준비 중"에서 고스톱 제거.
- E2E: 맞고 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 고·스톱까지 진행.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:35:21 +09:00

185 lines
7.3 KiB
TypeScript

/**
* Hwatu card drawn in SVG (our own simple artwork, no commercial hwatu images — docs/games/seotda.md §12).
* Large month number + kind badge so the card is readable without knowing the pictures.
*/
import type { CSSProperties, ReactNode } from 'react';
export interface HwatuInfo {
id: number;
month: number; // 0 for bonus
kind: 'gwang' | 'yeol' | 'tti' | 'pi' | 'ssangpi' | 'bonus';
tti?: 'hong' | 'cheong' | 'cho' | 'plain';
name: string;
piValue?: number;
}
const MONTH_COLOR = ['#6b7280', '#15803d', '#be185d', '#db2777', '#1f2937', '#7c3aed', '#dc2626', '#b91c1c', '#111827', '#ca8a04', '#c2410c', '#7c2d12', '#1e40af'];
const MONTH_NAME = ['보너스', '송학', '매조', '벚꽃', '흑싸리', '난초', '모란', '홍싸리', '공산', '국화', '단풍', '오동', '비'];
const KIND_BADGE: Record<HwatuInfo['kind'], string> = { gwang: '光', yeol: '열', tti: '띠', pi: '피', ssangpi: '쌍피', bonus: '+' };
const TTI_COLOR = { hong: '#dc2626', cheong: '#2563eb', cho: '#15803d', plain: '#dc2626' };
const TTI_TEXT = { hong: '홍단', cheong: '청단', cho: '', plain: '' };
/** A small motif per month (flowers/plants), drawn with basic shapes. */
function Motif({ month, color }: { month: number; color: string }): ReactNode {
const c = color;
switch (month) {
case 1: // 송학: pine
return <path d="M50 52 L28 92 H72 Z M50 72 L22 116 H78 Z" fill={c} opacity="0.85" />;
case 2: // 매조: plum blossoms
case 3: // 벚꽃
return (
<g fill={c} opacity="0.85">
{[[34, 70], [62, 62], [50, 92], [70, 100], [30, 108]].map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r="10" />
))}
</g>
);
case 4: // 흑싸리
case 7: // 홍싸리
return (
<g stroke={c} strokeWidth="5" strokeLinecap="round" opacity="0.85">
{[30, 45, 60, 75].map((x, i) => (
<line key={i} x1={x} y1="52" x2={x - 6} y2={100 + (i % 2) * 14} />
))}
</g>
);
case 5: // 난초: iris leaves
return (
<g fill="none" stroke={c} strokeWidth="5" opacity="0.85">
<path d="M40 120 Q34 80 44 52" />
<path d="M55 120 Q62 84 52 54" />
<path d="M70 120 Q76 92 68 64" />
</g>
);
case 6: // 모란: peony
return (
<g fill={c} opacity="0.85">
<circle cx="50" cy="86" r="22" />
<circle cx="50" cy="86" r="10" fill="#fde68a" />
</g>
);
case 8: // 공산: hill
return <path d="M8 120 Q50 52 92 120 Z" fill={c} opacity="0.85" />;
case 9: // 국화
return (
<g fill={c} opacity="0.9">
{Array.from({ length: 10 }, (_, i) => {
const a = (i / 10) * Math.PI * 2;
return <ellipse key={i} cx={50 + Math.cos(a) * 14} cy={88 + Math.sin(a) * 14} rx="8" ry="4" transform={`rotate(${(a * 180) / Math.PI} ${50 + Math.cos(a) * 14} ${88 + Math.sin(a) * 14})`} />;
})}
<circle cx="50" cy="88" r="7" fill="#78350f" />
</g>
);
case 10: // 단풍: maple
return <path d="M50 58 L58 78 L78 74 L64 90 L74 108 L50 98 L26 108 L36 90 L22 74 L42 78 Z" fill={c} opacity="0.9" />;
case 11: // 오동
return (
<g fill={c} opacity="0.85">
<ellipse cx="38" cy="82" rx="16" ry="22" />
<ellipse cx="64" cy="94" rx="16" ry="22" />
</g>
);
case 12: // 비: rain lines + umbrella
return (
<g stroke={c} strokeWidth="3" opacity="0.85">
{[20, 35, 50, 65, 80].map((x) => (
<line key={x} x1={x} y1="54" x2={x - 8} y2="118" />
))}
</g>
);
default:
return <text x="50" y="98" fontSize="36" textAnchor="middle" fill={c}>★</text>;
}
}
export type HwatuSize = 'sm' | 'md' | 'lg';
const WIDTH: Record<HwatuSize, number> = { sm: 40, md: 60, lg: 84 };
export function HwatuCard({
card,
size = 'md',
selected,
dimmed,
highlight,
onClick,
style,
}: {
card: HwatuInfo | null;
size?: HwatuSize;
selected?: boolean;
dimmed?: boolean;
highlight?: boolean;
onClick?: () => void;
style?: CSSProperties;
}) {
const w = WIDTH[size];
const h = Math.round(w * 1.6);
const Tag = onClick ? 'button' : 'span';
const s: CSSProperties = {
width: w,
height: h,
flex: 'none',
display: 'inline-block',
padding: 0,
border: 0,
background: 'none',
cursor: onClick ? 'pointer' : 'default',
opacity: dimmed ? 0.45 : 1,
transform: selected ? `translateY(${-Math.round(w * 0.2)}px)` : undefined,
transition: 'transform 0.12s ease',
filter: highlight ? 'drop-shadow(0 0 6px #f59e0b)' : 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))',
...style,
};
if (!card) {
return (
<Tag style={s} onClick={onClick} aria-label="뒤집힌 화투">
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="158" rx="8" fill="#991b1b" stroke="#450a0a" strokeWidth="3" />
<rect x="10" y="10" width="80" height="140" rx="5" fill="#b91c1c" />
</svg>
</Tag>
);
}
const color = MONTH_COLOR[card.month] ?? '#374151';
return (
<Tag style={s} onClick={onClick} aria-label={card.name} aria-pressed={onClick ? !!selected : undefined} title={card.name}>
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="158" rx="8" fill="#fffaf0" stroke={selected ? '#2563eb' : '#1f2937'} strokeWidth={selected ? 6 : 3} />
{card.kind === 'bonus' ? (
<text x="50" y="102" fontSize="34" fontWeight="900" fill="#b45309" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{card.piValue ?? 2}피
</text>
) : (
<Motif month={card.month} color={color} />
)}
{card.kind === 'tti' && (
<g>
<rect x="58" y="30" width="18" height="90" rx="3" fill={TTI_COLOR[card.tti ?? 'plain']} />
{TTI_TEXT[card.tti ?? 'plain'] && (
<text x="67" y="62" fontSize="13" fontWeight="800" fill="#fff" textAnchor="middle" style={{ writingMode: 'vertical-rl' } as CSSProperties}>
{TTI_TEXT[card.tti ?? 'plain']}
</text>
)}
</g>
)}
{(card.kind === 'gwang' || card.kind === 'yeol') && (
<g>
<circle cx="78" cy="120" r="15" fill={card.kind === 'gwang' ? '#dc2626' : '#a16207'} />
<text x="78" y="127" fontSize={card.kind === 'gwang' ? 20 : 16} fontWeight="800" fill="#fff" textAnchor="middle">
{card.kind === 'gwang' ? '光' : '열'}
</text>
</g>
)}
{/* Month number: large and always readable */}
<rect x="6" y="6" width="38" height="38" rx="8" fill={color} />
<text x="25" y="35" fontSize="28" fontWeight="900" fill="#fff" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{card.month || '+'}
</text>
<text x="50" y="154" fontSize="13" fontWeight="700" fill="#374151" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{MONTH_NAME[card.month]} · {card.kind === 'bonus' ? `${card.piValue ?? 2}피` : card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]}
</text>
</svg>
</Tag>
);
}