/** * 마작패 그림 (docs/games/mahjong.md §9): 직접 그린 SVG. 만수 = 숫자 + "만", 통수 = 동그라미, 삭수 = 막대, 자패 = 한글. * 작은 크기(버림패)에서는 숫자를 크게 쓴 간단한 그림으로 바꿔 휴대폰에서도 읽히게 한다. 수패는 종류마다 안쪽 테두리 * 무늬(실선·점선·긴 점선)가 달라 색만으로 구별하지 않아도 된다. */ import type { CSSProperties } from 'react'; const SUIT_KO: Record = { m: '만수', p: '통수', s: '삭수' }; const SUIT_SHORT: Record = { 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 = { 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 ( ); })} ); } /** Bamboo sticks: rows of [x positions], with red sticks for some centres. */ const SOU_ROWS: Record = { 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 ( ); } 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 ( ); }), )} ); } /** Inner frame pattern per suit (colour-blind help, §9). */ const FRAME_DASH: Record = { 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' && ( ); if (t.suit === 'z') { const ch = HONOR_KO[t.n - 1]!; if (t.n === 5) { return ( <> {ch} ); } const color = t.n === 6 ? GREEN : t.n === 7 ? RED : INK; return ( {ch} ); } const numColor = t.red ? RED : INK; if (compact) { if (t.suit === 'm') { return ( <> {frame} {t.n} ); } if (t.suit === 'p') { return ( <> {frame} {t.n} ); } return ( <> {frame} {t.n} ); } if (t.suit === 'm') { return ( <> {frame} {t.n} 만 ); } return ( <> {frame} {t.suit === 'p' ? : } {t.n} ); } 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 = ( {code === null ? ( <> ) : ( <> )} ); 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 ( {svg} ); }