- 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>
185 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
}
|