M4: 원카드 규칙·화면·테스트
- 규칙 모듈 packages/games/src/one-card: 원카드(트럼프 54장, 6명부터 2벌)와 색깔 카드(108장) 두 모드. 공격·받아치기·3 방어·7 무늬 지정·J/Q/K·조커 기준 카드·여러 장 내기·마지막 카드 제한, 원카드 선언/잡기, 파산, 드로우 더미 재구성, +4 의심하기(의심한 사람에게만 손패 공개), +2/+4 받아치기, 목표 점수 여러 판, 시간 초과(자동 먹기·넘기기·받아들이기, 3번 연속이면 5초), 나가기. 모든 옵션에 한국어 메타. - 테스트: 문서 10절 시나리오 45개 + 모드별 무작위 대국 1,000판(카드 수 보존, 종료, 결정성, 유출, onTimeout 합법) + 엔진 randomPlayout(매 단계 유출·직렬화 검사) 18판. - 화면 apps/web/src/games/one-card: 상대 좌석, 판(더미·바닥·방향·지정 무늬/색·공격 누적), 손패(정렬·힌트· 두 번 눌러 내기·끌어 놓기·방향키), 원카드 버튼, 무늬/색 고르기, 의심하기, 기록·특수 카드 표. 색깔 카드는 직접 그린 SVG(색맹용 무늬 패턴과 색 이름 포함). - 등록: GAMES, 카탈로그, 웹 레지스트리, 아이콘(곧 나와요 목록에서 제외), E2E 계획(PC 1 + 휴대폰 2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
153
apps/web/src/games/one-card/ColorCard.tsx
Normal file
153
apps/web/src/games/one-card/ColorCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 색깔 카드 artwork (own design, docs/games/one-card.md §9, §12): solid colour field, a white diamond in the
|
||||
* middle, a colour-blind pattern per colour (빨강 점 · 노랑 줄 · 초록 격자 · 파랑 물결) and the colour name.
|
||||
*/
|
||||
import { useId } from 'react';
|
||||
import type { CardFace, Color } from '@bg/games/one-card';
|
||||
|
||||
export const COLOR_HEX: Record<Color, string> = { R: '#d32f2f', Y: '#f4b400', G: '#2e7d32', B: '#1565c0' };
|
||||
export const COLOR_INK: Record<Color, string> = { R: '#ffffff', Y: '#1f2937', G: '#ffffff', B: '#ffffff' };
|
||||
export const COLOR_KO: Record<Color, string> = { R: '빨강', Y: '노랑', G: '초록', B: '파랑' };
|
||||
const PATTERN_KO: Record<Color, string> = { R: '점무늬', Y: '줄무늬', G: '격자무늬', B: '물결무늬' };
|
||||
|
||||
function Pattern({ color, id }: { color: Color; id: string }) {
|
||||
const ink = color === 'Y' ? 'rgba(0,0,0,0.16)' : 'rgba(255,255,255,0.22)';
|
||||
switch (color) {
|
||||
case 'R':
|
||||
return (
|
||||
<pattern id={id} width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<circle cx="6" cy="6" r="2.2" fill={ink} />
|
||||
</pattern>
|
||||
);
|
||||
case 'Y':
|
||||
return (
|
||||
<pattern id={id} width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||||
<rect width="4" height="10" fill={ink} />
|
||||
</pattern>
|
||||
);
|
||||
case 'G':
|
||||
return (
|
||||
<pattern id={id} width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<path d="M0 0H12M0 0V12" stroke={ink} strokeWidth="2.4" fill="none" />
|
||||
</pattern>
|
||||
);
|
||||
case 'B':
|
||||
return (
|
||||
<pattern id={id} width="20" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M0 6 Q5 1 10 6 T20 6" stroke={ink} strokeWidth="2.2" fill="none" />
|
||||
</pattern>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Four colour quadrants used by the wild cards. */
|
||||
function Quadrants({ cx, cy, r }: { cx: number; cy: number; r: number }) {
|
||||
const q = (dx: number, dy: number, c: Color) => (
|
||||
<path d={`M${cx} ${cy} L${cx + dx * r} ${cy} A${r} ${r} 0 0 ${dx * dy > 0 ? 1 : 0} ${cx} ${cy + dy * r} Z`} fill={COLOR_HEX[c]} />
|
||||
);
|
||||
return (
|
||||
<g>
|
||||
{q(1, -1, 'R')}
|
||||
{q(1, 1, 'B')}
|
||||
{q(-1, 1, 'G')}
|
||||
{q(-1, -1, 'Y')}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function SkipIcon({ cx, cy, r, color }: { cx: number; cy: number; r: number; color: string }) {
|
||||
return (
|
||||
<g stroke={color} strokeWidth={r * 0.28} fill="none" strokeLinecap="round">
|
||||
<circle cx={cx} cy={cy} r={r} />
|
||||
<path d={`M${cx - r * 0.7} ${cy + r * 0.7} L${cx + r * 0.7} ${cy - r * 0.7}`} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function ReverseIcon({ cx, cy, s, color }: { cx: number; cy: number; s: number; color: string }) {
|
||||
return (
|
||||
<g stroke={color} strokeWidth={s * 0.22} fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={`M${cx - s} ${cy - s * 0.35} H${cx + s * 0.8} M${cx + s * 0.35} ${cy - s * 0.8} L${cx + s * 0.8} ${cy - s * 0.35} L${cx + s * 0.35} ${cy + s * 0.1}`} />
|
||||
<path d={`M${cx + s} ${cy + s * 0.35} H${cx - s * 0.8} M${cx - s * 0.35} ${cy + s * 0.8} L${cx - s * 0.8} ${cy + s * 0.35} L${cx - s * 0.35} ${cy - s * 0.1}`} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function colorCardLabel(c: CardFace): string {
|
||||
switch (c.kind) {
|
||||
case 'num':
|
||||
return `${COLOR_KO[c.color]} ${c.value}`;
|
||||
case 'skip':
|
||||
return `${COLOR_KO[c.color]} 건너뛰기`;
|
||||
case 'reverse':
|
||||
return `${COLOR_KO[c.color]} 방향 바꾸기`;
|
||||
case 'draw2':
|
||||
return `${COLOR_KO[c.color]} +2`;
|
||||
case 'wild':
|
||||
return '색 바꾸기';
|
||||
case 'wild4':
|
||||
return '색 바꾸기+4';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure artwork (no interaction); size comes from the parent box (viewBox 100 × 140). */
|
||||
export function ColorCardArt({ card }: { card: CardFace | null }) {
|
||||
const pid = `occ${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
|
||||
if (!card) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
|
||||
<rect x="1.5" y="1.5" width="97" height="137" rx="10" fill="#1f2433" stroke="#f8fafc" strokeWidth="3" />
|
||||
<rect x="9" y="9" width="82" height="122" rx="7" fill="none" stroke="#475569" strokeWidth="2" />
|
||||
<Quadrants cx={50} cy={70} r={22} />
|
||||
<circle cx="50" cy="70" r="9" fill="#1f2433" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const wild = card.kind === 'wild' || card.kind === 'wild4';
|
||||
const color: Color | null = 'color' in card ? card.color : null;
|
||||
const bg = color ? COLOR_HEX[color] : '#1f2433';
|
||||
const ink = color ? COLOR_INK[color] : '#f8fafc';
|
||||
const corner = card.kind === 'num' ? String(card.value) : card.kind === 'draw2' ? '+2' : card.kind === 'wild4' ? '+4' : card.kind === 'skip' ? '⊘' : card.kind === 'reverse' ? '⇄' : '★';
|
||||
const centerInk = color ? COLOR_HEX[color] : '#1f2433';
|
||||
return (
|
||||
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
|
||||
{color && (
|
||||
<defs>
|
||||
<Pattern color={color} id={pid} />
|
||||
</defs>
|
||||
)}
|
||||
<rect x="1.5" y="1.5" width="97" height="137" rx="10" fill={bg} stroke="#f8fafc" strokeWidth="3" />
|
||||
{color && <rect x="1.5" y="1.5" width="97" height="137" rx="10" fill={`url(#${pid})`} />}
|
||||
{/* white diamond (not an oval) */}
|
||||
<path d="M50 22 L86 70 L50 118 L14 70 Z" fill="#fffef9" stroke={ink} strokeOpacity="0.25" strokeWidth="1.5" />
|
||||
{wild ? (
|
||||
<>
|
||||
<Quadrants cx={50} cy={70} r={card.kind === 'wild4' ? 19 : 24} />
|
||||
{card.kind === 'wild4' && (
|
||||
<text x="50" y="79" fontSize="26" fontWeight="900" textAnchor="middle" fill="#fffef9" stroke="#1f2433" strokeWidth="1.5" fontFamily="Pretendard Variable, sans-serif">
|
||||
+4
|
||||
</text>
|
||||
)}
|
||||
</>
|
||||
) : card.kind === 'skip' ? (
|
||||
<SkipIcon cx={50} cy={70} r={17} color={centerInk} />
|
||||
) : card.kind === 'reverse' ? (
|
||||
<ReverseIcon cx={50} cy={70} s={19} color={centerInk} />
|
||||
) : (
|
||||
<text x="50" y={card.kind === 'draw2' ? 82 : 86} fontSize={card.kind === 'draw2' ? 34 : 46} fontWeight="900" textAnchor="middle" fill={centerInk} fontFamily="Pretendard Variable, sans-serif">
|
||||
{card.kind === 'draw2' ? '+2' : corner}
|
||||
</text>
|
||||
)}
|
||||
<text x="9" y="25" fontSize="20" fontWeight="900" fill={ink} fontFamily="Pretendard Variable, sans-serif">
|
||||
{corner}
|
||||
</text>
|
||||
<text x="50" y="134" fontSize="12" fontWeight="800" textAnchor="middle" fill={ink} fontFamily="Pretendard Variable, sans-serif">
|
||||
{color ? COLOR_KO[color] : wild ? '색 바꾸기' : ''}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const patternName = (c: Color): string => PATTERN_KO[c];
|
||||
1114
apps/web/src/games/one-card/index.tsx
Normal file
1114
apps/web/src/games/one-card/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
baduk: () => import('./baduk'),
|
||||
seotda: () => import('./seotda'),
|
||||
poker: () => import('./poker'),
|
||||
'one-card': () => import('./one-card'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
@@ -8,7 +8,8 @@ export const GAME_ICON: Record<string, string> = {
|
||||
mahjong: '🀇',
|
||||
yut: '🪵',
|
||||
othello: '◐',
|
||||
'one-card': '♣',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
|
||||
export const UPCOMING = ['윷놀이', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
|
||||
|
||||
Reference in New Issue
Block a user