- 규칙 모듈 packages/games/src/mahjong (tiles·agari·scoring·types·index): 4인 리치 마작(동풍전/반장전)과 3인 산마, 옵션 전부 한국어 meta, 응답 창(론 > 퐁·깡 > 치, 조기 확정, 최소 400ms, 시간 초과 = 패스), 리치·일발·더블리치, 후리텐(버림패·동순·리치), 구이가에, 깡 도라 시점, 사깡·사풍·사가리치·구종구패·삼가화, 유국만관, 책임지불, 북 뽑기, 고점법 점수 계산(역·부·만관~더블 역만, 본장·공탁, 산마 쯔모손/북가절반), 토비·아가리야메·서입 서든데스, 우마·오카 결과, 초보 모드 힌트, autoPilot·나가기 처리 - 응답 창 타이머는 아직 응답하지 않은 응답 가능자에게만 걸어 연결 끊긴 사람은 바로 패스, 확정 뒤에는 방총자 타이머로 닫음(끊긴 방총자 때문에 타이머가 헛도는 일 방지). 공개 시계에는 응답 창 남은 시간을 싣지 않음 - 테스트: 점수 계산기 161건(1~4판 × 20~110부 표 전 칸, 만관 이상, 역·부 예시, 산마 지불), 규칙 시나리오 77건(§10 체크리스트), 무작위 대국 1,000판(4인 반장전 240 · 동풍전 400 · 산마 360, 옵션 무작위, 패·점수 보존, onTimeout 합법, 재현성) + 엔진 randomPlayout과 매 단계 정보 누출 검사 - 화면 apps/web/src/games/mahjong: 직접 그린 SVG 패, 4방향(3인은 3방향) 버림패 테이블, 탭 두 번 버리기, 리치·쯔모·깡·북·구종구패, 론·퐁·치(조합 선택)·깡·패스 응답, 국 결과(역·판·부·점수 변화)와 최종 순위, 초보 모드(텐파이 타패·대기·남은 장수·역 힌트·후리텐 배지), PC 2단 배치·휴대폰 한 줄 손패, 키보드·스크린리더 - 등록: GAMES, 카탈로그(available), 웹 registry, '곧 나와요'에서 마작 제거 - E2E: PC 방장 + 휴대폰 3명 4인 대국으로 한 국을 끝까지(타패·패스·유국 결과 확인·다음 국 시작) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
9.5 KiB
TypeScript
324 lines
9.5 KiB
TypeScript
/**
|
|
* 마작패 그림 (docs/games/mahjong.md §9): 직접 그린 SVG. 만수 = 숫자 + "만", 통수 = 동그라미, 삭수 = 막대, 자패 = 한글.
|
|
* 작은 크기(버림패)에서는 숫자를 크게 쓴 간단한 그림으로 바꿔 휴대폰에서도 읽히게 한다. 수패는 종류마다 안쪽 테두리
|
|
* 무늬(실선·점선·긴 점선)가 달라 색만으로 구별하지 않아도 된다.
|
|
*/
|
|
import type { CSSProperties } from 'react';
|
|
|
|
const SUIT_KO: Record<string, string> = { m: '만수', p: '통수', s: '삭수' };
|
|
const SUIT_SHORT: Record<string, string> = { m: '만', p: '통', s: '삭' };
|
|
const HONOR_KO = ['동', '남', '서', '북', '백', '발', '중'];
|
|
|
|
const INK = '#1c1e21';
|
|
const RED = '#c8202a';
|
|
const BLUE = '#1f5fbf';
|
|
const GREEN = '#17804a';
|
|
|
|
/** "5m0r" → { n: 5, suit: 'm', red: true }; honors use suit 'z' and n 1..7. */
|
|
export function parseCode(code: string): { n: number; suit: 'm' | 'p' | 's' | 'z'; red: boolean; kind: string } {
|
|
return { n: Number(code[0]), suit: code[1] as 'm' | 'p' | 's' | 'z', red: code.endsWith('r'), kind: code.slice(0, 2) };
|
|
}
|
|
|
|
/** Screen-reader name: "만수 5", "적도라 통수 5", "동", "중". */
|
|
export function tileName(code: string): string {
|
|
const t = parseCode(code);
|
|
if (t.suit === 'z') return HONOR_KO[t.n - 1]!;
|
|
return `${t.red ? '적도라 ' : ''}${SUIT_KO[t.suit]} ${t.n}`;
|
|
}
|
|
/** Short name: "5만", "3통", "7삭", "동" (kind codes like "5m" work too). */
|
|
export function tileShort(code: string): string {
|
|
const t = parseCode(code);
|
|
return t.suit === 'z' ? HONOR_KO[t.n - 1]! : `${t.n}${SUIT_SHORT[t.suit]}`;
|
|
}
|
|
|
|
/** Sort key: 만 → 통 → 삭 → 자, by number, red five first. */
|
|
export function tileOrder(code: string): number {
|
|
const t = parseCode(code);
|
|
return 'mpsz'.indexOf(t.suit) * 100 + t.n * 10 + (t.red ? 0 : 1) + Number(code[2] ?? 0) / 10;
|
|
}
|
|
|
|
const PIN_POS: Record<number, [number, number][]> = {
|
|
1: [[30, 38]],
|
|
2: [
|
|
[30, 22],
|
|
[30, 54],
|
|
],
|
|
3: [
|
|
[16, 18],
|
|
[30, 38],
|
|
[44, 58],
|
|
],
|
|
4: [
|
|
[18, 22],
|
|
[42, 22],
|
|
[18, 54],
|
|
[42, 54],
|
|
],
|
|
5: [
|
|
[17, 18],
|
|
[43, 18],
|
|
[30, 38],
|
|
[17, 58],
|
|
[43, 58],
|
|
],
|
|
6: [
|
|
[19, 16],
|
|
[41, 16],
|
|
[19, 38],
|
|
[41, 38],
|
|
[19, 60],
|
|
[41, 60],
|
|
],
|
|
7: [
|
|
[14, 13],
|
|
[30, 21],
|
|
[46, 29],
|
|
[19, 46],
|
|
[41, 46],
|
|
[19, 63],
|
|
[41, 63],
|
|
],
|
|
8: [
|
|
[19, 12],
|
|
[41, 12],
|
|
[19, 30],
|
|
[41, 30],
|
|
[19, 47],
|
|
[41, 47],
|
|
[19, 64],
|
|
[41, 64],
|
|
],
|
|
9: [
|
|
[14, 16],
|
|
[30, 16],
|
|
[46, 16],
|
|
[14, 38],
|
|
[30, 38],
|
|
[46, 38],
|
|
[14, 60],
|
|
[30, 60],
|
|
[46, 60],
|
|
],
|
|
};
|
|
|
|
function pinColor(n: number, i: number, red: boolean): string {
|
|
if (red) return RED;
|
|
if (n === 1) return RED;
|
|
if (n === 2) return i === 0 ? GREEN : BLUE;
|
|
if (n === 3) return [BLUE, RED, GREEN][i]!;
|
|
if (n === 4) return i === 0 || i === 3 ? BLUE : GREEN;
|
|
if (n === 5) return i === 2 ? RED : i === 0 || i === 4 ? BLUE : GREEN;
|
|
if (n === 6) return i < 2 ? GREEN : RED;
|
|
if (n === 7) return i < 3 ? GREEN : RED;
|
|
if (n === 8) return BLUE;
|
|
return i < 3 ? BLUE : i < 6 ? RED : GREEN;
|
|
}
|
|
|
|
function Pins({ n, red }: { n: number; red: boolean }) {
|
|
const pos = PIN_POS[n]!;
|
|
const r = n === 1 ? 17 : n <= 3 ? 9.5 : n <= 5 ? 8.5 : n <= 7 ? 7 : 7;
|
|
return (
|
|
<>
|
|
{pos.map(([x, y], i) => {
|
|
const c = pinColor(n, i, red);
|
|
return (
|
|
<g key={i}>
|
|
<circle cx={x} cy={y} r={r} fill="none" stroke={c} strokeWidth={n === 1 ? 4 : 2.6} />
|
|
<circle cx={x} cy={y} r={r * 0.42} fill={c} />
|
|
</g>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Bamboo sticks: rows of [x positions], with red sticks for some centres. */
|
|
const SOU_ROWS: Record<number, number[][]> = {
|
|
2: [[30], [30]],
|
|
3: [[30], [18, 42]],
|
|
4: [
|
|
[20, 40],
|
|
[20, 40],
|
|
],
|
|
5: [[18, 42], [30], [18, 42]],
|
|
6: [
|
|
[15, 30, 45],
|
|
[15, 30, 45],
|
|
],
|
|
7: [[30], [15, 30, 45], [15, 30, 45]],
|
|
8: [
|
|
[12, 24, 36, 48],
|
|
[12, 24, 36, 48],
|
|
],
|
|
9: [
|
|
[15, 30, 45],
|
|
[15, 30, 45],
|
|
[15, 30, 45],
|
|
],
|
|
};
|
|
|
|
function Sticks({ n, red }: { n: number; red: boolean }) {
|
|
if (n === 1) {
|
|
// 1삭: one big stick with knots and a red top.
|
|
return (
|
|
<g>
|
|
<rect x={24} y={12} width={12} height={54} rx={6} fill={GREEN} />
|
|
<rect x={24} y={12} width={12} height={12} rx={6} fill={RED} />
|
|
<line x1={24} y1={39} x2={36} y2={39} stroke="#fff" strokeWidth={2} />
|
|
<line x1={24} y1={52} x2={36} y2={52} stroke="#fff" strokeWidth={2} />
|
|
</g>
|
|
);
|
|
}
|
|
const rows = SOU_ROWS[n]!;
|
|
const top = 8;
|
|
const bottom = 70;
|
|
const gap = 4;
|
|
const h = (bottom - top - gap * (rows.length - 1)) / rows.length;
|
|
const w = rows.some((r) => r.length === 4) ? 7 : 8;
|
|
return (
|
|
<>
|
|
{rows.map((xs, ri) =>
|
|
xs.map((x, ci) => {
|
|
const y = top + ri * (h + gap);
|
|
const center = (n === 5 && ri === 1) || (n === 7 && ri === 0) || (n === 9 && ci === 1);
|
|
const c = red ? RED : center ? RED : GREEN;
|
|
return (
|
|
<g key={`${ri}-${ci}`}>
|
|
<rect x={x - w / 2} y={y} width={w} height={h} rx={w / 2} fill={c} />
|
|
<line x1={x - w / 2} y1={y + h / 2} x2={x + w / 2} y2={y + h / 2} stroke="#fff" strokeWidth={1.4} />
|
|
</g>
|
|
);
|
|
}),
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Inner frame pattern per suit (colour-blind help, §9). */
|
|
const FRAME_DASH: Record<string, string | undefined> = { m: undefined, p: '1.5 3', s: '6 3' };
|
|
|
|
function Face({ code, compact }: { code: string; compact: boolean }) {
|
|
const t = parseCode(code);
|
|
const frame = t.suit !== 'z' && (
|
|
<rect x={5} y={5} width={50} height={66} rx={5} fill="none" stroke="#c9bfa8" strokeWidth={1.6} strokeDasharray={FRAME_DASH[t.suit]} />
|
|
);
|
|
if (t.suit === 'z') {
|
|
const ch = HONOR_KO[t.n - 1]!;
|
|
if (t.n === 5) {
|
|
return (
|
|
<>
|
|
<rect x={10} y={10} width={40} height={56} rx={4} fill="none" stroke={BLUE} strokeWidth={4} />
|
|
<text x={30} y={48} textAnchor="middle" fontSize={compact ? 28 : 22} fontWeight={800} fill={BLUE}>
|
|
{ch}
|
|
</text>
|
|
</>
|
|
);
|
|
}
|
|
const color = t.n === 6 ? GREEN : t.n === 7 ? RED : INK;
|
|
return (
|
|
<text x={30} y={compact ? 53 : 52} textAnchor="middle" fontSize={compact ? 40 : 36} fontWeight={900} fill={color}>
|
|
{ch}
|
|
</text>
|
|
);
|
|
}
|
|
const numColor = t.red ? RED : INK;
|
|
if (compact) {
|
|
if (t.suit === 'm') {
|
|
return (
|
|
<>
|
|
{frame}
|
|
<text x={30} y={50} textAnchor="middle" fontSize={44} fontWeight={900} fill={numColor}>
|
|
{t.n}
|
|
</text>
|
|
<rect x={14} y={58} width={32} height={7} rx={3.5} fill={RED} />
|
|
</>
|
|
);
|
|
}
|
|
if (t.suit === 'p') {
|
|
return (
|
|
<>
|
|
{frame}
|
|
<circle cx={30} cy={38} r={23} fill="none" stroke={t.red ? RED : BLUE} strokeWidth={4} />
|
|
<text x={30} y={53} textAnchor="middle" fontSize={40} fontWeight={900} fill={t.red ? RED : BLUE}>
|
|
{t.n}
|
|
</text>
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
{frame}
|
|
<rect x={7} y={12} width={6} height={52} rx={3} fill={t.red ? RED : GREEN} />
|
|
<rect x={47} y={12} width={6} height={52} rx={3} fill={t.red ? RED : GREEN} />
|
|
<text x={30} y={53} textAnchor="middle" fontSize={40} fontWeight={900} fill={t.red ? RED : GREEN}>
|
|
{t.n}
|
|
</text>
|
|
</>
|
|
);
|
|
}
|
|
if (t.suit === 'm') {
|
|
return (
|
|
<>
|
|
{frame}
|
|
<text x={30} y={40} textAnchor="middle" fontSize={34} fontWeight={900} fill={numColor}>
|
|
{t.n}
|
|
</text>
|
|
<text x={30} y={66} textAnchor="middle" fontSize={21} fontWeight={800} fill={RED}>
|
|
만
|
|
</text>
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
{frame}
|
|
{t.suit === 'p' ? <Pins n={t.n} red={t.red} /> : <Sticks n={t.n} red={t.red} />}
|
|
<text x={52} y={14} textAnchor="end" fontSize={11} fontWeight={800} fill={t.red ? RED : '#6b6352'}>
|
|
{t.n}
|
|
</text>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export interface TileProps {
|
|
/** Tile code ("5m0r") or kind code ("5m"); null draws the back. */
|
|
code: string | null;
|
|
/** Width in px (height is 4:3). */
|
|
w: number;
|
|
sideways?: boolean;
|
|
dim?: boolean;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
/** aria-hidden by default: the parent describes the tile. */
|
|
label?: string;
|
|
}
|
|
|
|
export function Tile({ code, w, sideways, dim, className, style, label }: TileProps) {
|
|
const h = Math.round((w * 4) / 3);
|
|
const compact = w < 30;
|
|
const svg = (
|
|
<svg viewBox="0 0 60 80" width={w} height={h} style={{ display: 'block', ...(sideways ? { transform: 'rotate(-90deg)' } : {}) }} aria-hidden>
|
|
{code === null ? (
|
|
<>
|
|
<rect x={1} y={1} width={58} height={78} rx={8} fill="#1d5a8c" stroke="#123b5c" strokeWidth={2} />
|
|
<rect x={7} y={7} width={46} height={62} rx={5} fill="#2a73ad" />
|
|
</>
|
|
) : (
|
|
<>
|
|
<rect x={1} y={1} width={58} height={78} rx={8} fill="#d9cdb0" stroke="#9c8f72" strokeWidth={2} />
|
|
<rect x={2} y={2} width={56} height={71} rx={7} fill="#fffcf3" />
|
|
<Face code={code} compact={compact} />
|
|
</>
|
|
)}
|
|
</svg>
|
|
);
|
|
const box: CSSProperties = sideways
|
|
? { width: h, height: w, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none', ...style }
|
|
: { width: w, height: h, display: 'inline-block', flex: 'none', ...style };
|
|
return (
|
|
<span className={`mj-tile${dim ? ' dim' : ''}${className ? ` ${className}` : ''}`} style={box} role={label ? 'img' : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
|
|
{svg}
|
|
</span>
|
|
);
|
|
}
|