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:
178
apps/web/src/components/cards/HwatuCard.tsx
Normal file
178
apps/web/src/components/cards/HwatuCard.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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} />
|
||||
<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 === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]}
|
||||
</text>
|
||||
</svg>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user