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:
EJClaw
2026-10-05 04:40:21 +09:00
parent 5dd8e6ea07
commit 4c5c3e5707
12 changed files with 3762 additions and 1 deletions

View 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];

File diff suppressed because it is too large Load Diff

View File

@@ -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>();

View File

@@ -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 = ['윷놀이', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];