Merge branch 'm4/yut' into codex/owner/grp_discord_joke-app

# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
This commit is contained in:
EJClaw
2026-10-05 04:40:49 +09:00
12 changed files with 2706 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
seotda: () => import('./seotda'),
poker: () => import('./poker'),
yacht: () => import('./yacht'),
yut: () => import('./yut'),
};
const cache = new Map<string, GameUI>();

View File

@@ -0,0 +1,866 @@
/** 윷놀이 화면 (docs/games/yut.md §9). Board, sticks and pieces are drawn here (SVG/CSS only). */
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Throw, YutLegalMove, YutOptions, YutSideView, YutView } from '@bg/games/yut';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar, ConfirmSheet } from '../../components/ui';
import { TurnTimer } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types';
type From = number | 'home';
type GameEvent = YutView['log'][number];
// The rules module is imported for types only (no zod in this chunk); these small tables mirror packages/games/src/yut/board.ts.
const NODE_NAMES = [
'참먹이', '도', '개', '걸', '윷', '모', '뒷도', '뒷개', '뒷걸', '뒷윷',
'뒷모', '찌도', '찌개', '찌걸', '찌윷', '찌모', '날도', '날개', '날걸', '날윷',
'앞모도', '앞모개', '방', '속윷', '속모', '뒷모도', '뒷모개', '사려', '안찌',
];
const THROW_KO: Record<Throw, string> = { do: '도', gae: '개', geol: '걸', yut: '윷', mo: '모', backdo: '빽도' };
const STEPS: Record<Throw, number> = { do: 1, gae: 2, geol: 3, yut: 4, mo: 5, backdo: -1 };
const stepText = (t: Throw) => (t === 'backdo' ? '뒤로 1칸' : `${STEPS[t]}칸`);
const BIG = new Set([0, 5, 10, 15, 22]);
const BIG_LABELS: [number, string][] = [[0, '출발'], [5, '모'], [10, '뒷모'], [15, '찌모'], [22, '방']];
// Board geometry (viewBox 0..100): 참먹이 bottom-right, 모 top-right, 뒷모 top-left, 찌모 bottom-left; pieces go anticlockwise.
const LO = 8;
const HI = 92;
const GAP = (HI - LO) / 5;
const POS: [number, number][] = (() => {
const p: [number, number][] = [];
p[0] = [HI, HI];
for (let i = 1; i <= 4; i++) p[i] = [HI, HI - GAP * i];
p[5] = [HI, LO];
for (let i = 6; i <= 9; i++) p[i] = [HI - GAP * (i - 5), LO];
p[10] = [LO, LO];
for (let i = 11; i <= 14; i++) p[i] = [LO, LO + GAP * (i - 10)];
p[15] = [LO, HI];
for (let i = 16; i <= 19; i++) p[i] = [LO + GAP * (i - 15), HI];
const lerp = (a: [number, number], b: [number, number], t: number): [number, number] => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
[20, 21, 22, 23, 24].forEach((n, k) => (p[n] = lerp(p[5]!, p[15]!, (k + 1) / 6)));
[25, 26].forEach((n, k) => (p[n] = lerp(p[10]!, p[0]!, (k + 1) / 6)));
[27, 28].forEach((n, k) => (p[n] = lerp(p[10]!, p[0]!, (k + 4) / 6)));
return p;
})();
/** Where pieces come from / go to (just outside 참먹이). */
const HOME_PT: [number, number] = [HI + 4, HI + 7];
const LINES: [number, number][] = [
...Array.from({ length: 20 }, (_, i) => [i, (i + 1) % 20] as [number, number]),
[5, 20], [20, 21], [21, 22], [22, 23], [23, 24], [24, 15],
[10, 25], [25, 26], [26, 22], [22, 27], [27, 28], [28, 0],
];
const R_NODE = 5.1;
const R_BIG = 6.9;
// Side look: colour + shape (colour-blind safe: every side also has its own shape).
const SOLO_COLORS = ['#dc2626', '#2563eb', '#16a34a', '#eab308'];
const TEAM_COLORS = ['#2563eb', '#dc2626'];
const SHAPES = ['circle', 'triangle', 'square', 'star'] as const;
const SHAPE_KO = { circle: '동그라미', triangle: '세모', square: '네모', star: '별' } as const;
const sideColor = (team: boolean, side: number) => (team ? TEAM_COLORS : SOLO_COLORS)[side] ?? '#888';
const sideShape = (side: number) => SHAPES[side % SHAPES.length]!;
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const nodeName = (n: number | 'home' | 'goal') => (n === 'home' ? '대기' : n === 'goal' ? '남' : NODE_NAMES[n]!);
const reducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const CSS = `
.yut-wrap { width: 100%; display: grid; gap: 14px; align-items: start; }
.yut-board-box { width: 100%; max-width: min(100%, 640px); margin: 0 auto; }
.yut-board { width: 100%; display: block; border-radius: 18px; box-shadow: var(--shadow); touch-action: manipulation; }
.yut-ctrl { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
@media (min-width: 1100px) {
.yut-wrap { grid-template-columns: minmax(0, 1fr) 300px; align-items: center; }
.yut-board-box { max-width: min(100%, calc(100vh - 64px - 52px - 40px)); }
}
.yut-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.yut-chip { min-width: 64px; min-height: 64px; border-radius: 14px; border: 3px solid var(--border); background: var(--surface); color: var(--text);
display: inline-flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1.1; cursor: pointer; padding: 4px 8px; font-weight: 800; }
.yut-chip b { font-size: 1.5rem; }
.yut-chip small { font-size: .8rem; font-weight: 700; color: var(--text-sub); }
.yut-chip[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
.yut-chip.back b { color: var(--danger); }
.yut-chip:disabled { cursor: default; opacity: .85; }
.yut-throw { min-height: 72px; font-size: 1.35rem; }
.yut-tray { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.yut-tray-btn { min-height: 56px; border-radius: 14px; border: 3px dashed var(--border); background: var(--surface); color: var(--text); padding: 4px 12px;
display: inline-flex; align-items: center; gap: 8px; font-weight: 700; cursor: pointer; }
.yut-tray-btn.can { border-style: solid; border-color: var(--focus); animation: yut-glow 1.1s ease-in-out infinite; }
.yut-tray-btn.sel { border-style: solid; border-color: var(--primary); background: var(--primary-soft); animation: none; }
.yut-tray-btn:disabled { cursor: default; }
.yut-sticks-box { display: flex; align-items: center; gap: 12px; min-height: 84px; }
.yut-sticks { width: 120px; height: 80px; flex: none; overflow: visible; }
.yut-result { font-size: 2rem; font-weight: 900; min-width: 3ch; }
.yut-toss .yut-stick { transform-box: fill-box; transform-origin: center; animation: yut-toss .8s cubic-bezier(.25,.7,.35,1) both; animation-delay: var(--d, 0ms); }
@keyframes yut-toss { 0% { transform: translateY(0) rotate(0deg); } 45% { transform: translateY(-34px) rotate(260deg); } 75% { transform: translateY(2px) rotate(350deg); } 100% { transform: translateY(0) rotate(360deg); } }
@keyframes yut-glow { 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, .0); } 50% { box-shadow: 0 0 0 5px rgba(245, 158, 11, .35); } }
@keyframes yut-pulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }
@keyframes yut-hop { 0% { transform: translateY(0); } 50% { transform: translateY(-5px); } 100% { transform: translateY(0); } }
@keyframes yut-pop { from { transform: scale(.4); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.yut-can { animation: yut-pulse 1s ease-in-out infinite; }
.yut-hop { transform-box: fill-box; transform-origin: center bottom; animation: yut-hop .17s ease-out; }
.yut-pop { transform-box: fill-box; transform-origin: center; animation: yut-pop .25s ease-out both; }
.yut-hit { cursor: pointer; outline: none; }
.yut-hit .yut-focus { opacity: 0; }
.yut-hit:focus-visible .yut-focus { opacity: 1; }
.yut-log { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; max-height: 150px; overflow: auto; font-size: .92rem; }
.yut-side-card { padding: 10px 12px; border: 3px solid transparent; transition: border-color .2s; }
.yut-side-card.turn { border-color: var(--primary); }
.yut-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .78rem; color: #fff; }
@media (prefers-reduced-motion: reduce) {
.yut-toss .yut-stick, .yut-can, .yut-hop, .yut-pop, .yut-tray-btn.can { animation: none; }
}
`;
// ---------------------------------------------------------------- pieces & sticks
function Token({ side, team, x, y, r, count = 1 }: { side: number; team: boolean; x: number; y: number; r: number; count?: number }) {
const color = sideColor(team, side);
const shape = sideShape(side);
const common = { fill: color, stroke: '#fff', strokeWidth: r * 0.16, strokeLinejoin: 'round' as const };
let body: React.ReactNode;
if (shape === 'circle') body = <circle cx={0} cy={0} r={r} {...common} />;
else if (shape === 'square') body = <rect x={-r * 0.86} y={-r * 0.86} width={r * 1.72} height={r * 1.72} rx={r * 0.2} {...common} />;
else if (shape === 'triangle') body = <polygon points={`0,${-r * 1.05} ${r * 1.02},${r * 0.75} ${-r * 1.02},${r * 0.75}`} {...common} />;
else {
const pts = Array.from({ length: 10 }, (_, i) => {
const a = -Math.PI / 2 + (i * Math.PI) / 5;
const rr = i % 2 === 0 ? r * 1.1 : r * 0.5;
return `${(Math.cos(a) * rr).toFixed(2)},${(Math.sin(a) * rr).toFixed(2)}`;
});
body = <polygon points={pts.join(' ')} {...common} />;
}
return (
<g transform={`translate(${x} ${y})`}>
<g transform={`translate(${r * 0.08} ${r * 0.14})`} opacity={0.3}>
<circle r={r * 0.95} fill="#000" />
</g>
{body}
{count > 1 && (
<g transform={`translate(${r * 0.75} ${-r * 0.75})`}>
<circle r={r * 0.62} fill="#111" stroke="#fff" strokeWidth={r * 0.1} />
<text textAnchor="middle" dominantBaseline="central" fontSize={r * 0.82} fontWeight={900} fill="#fff">
{count}
</text>
</g>
)}
</g>
);
}
function TokenIcon({ side, team, size = 22 }: { side: number; team: boolean; size?: number }) {
return (
<svg width={size} height={size} viewBox="-6 -6 12 12" aria-hidden style={{ flex: 'none' }}>
<Token side={side} team={team} x={0} y={0} r={4.4} />
</svg>
);
}
/** One 윷가락: 배(flat side, light) or 등(round side, dark with marks). Stick 0 carries the 빽도 mark on its flat side. */
function Stick({ x, flat, marked, delay }: { x: number; flat: boolean; marked: boolean; delay: number }) {
return (
<g className="yut-stick" style={{ ['--d' as string]: `${delay}ms` }}>
<rect x={x} y={8} width={18} height={64} rx={9} fill={flat ? '#f6e4b8' : '#a86b2d'} stroke={flat ? '#9a7432' : '#5c3713'} strokeWidth={2} />
{flat ? (
<>
<line x1={x + 9} y1={16} x2={x + 9} y2={64} stroke="#d9bd83" strokeWidth={1.5} />
{marked && (
<text x={x + 9} y={41} textAnchor="middle" dominantBaseline="central" fontSize={12} fontWeight={900} fill="#c81e1e">
빽
</text>
)}
</>
) : (
[20, 32, 44, 56].map((y) => <line key={y} x1={x + 5} y1={y} x2={x + 13} y2={y} stroke="#f6e4b8" strokeWidth={2} strokeLinecap="round" opacity={0.7} />)
)}
</g>
);
}
function Sticks({ view: v, players }: { view: YutView; players: Record<string, PublicUser> }) {
const lt = v.lastThrow;
const [tossing, setTossing] = useState(false);
const seen = useRef(lt?.no ?? 0);
useEffect(() => {
if (!lt || lt.no === seen.current) return;
seen.current = lt.no;
if (reducedMotion()) return;
setTossing(true);
const t = setTimeout(() => setTossing(false), 820);
return () => clearTimeout(t);
}, [lt?.no]); // eslint-disable-line react-hooks/exhaustive-deps
const sticks = lt?.sticks ?? [false, true, false, true];
const label = !lt ? '' : tossing ? '…' : lt.result === 'nak' ? '낙!' : `${THROW_KO[lt.result]}!`;
return (
<div className="yut-sticks-box">
<svg className={`yut-sticks${tossing ? ' yut-toss' : ''}`} viewBox="0 0 120 80" role="img" aria-label={lt ? `${nick(players, lt.player)}님이 던진 윷: ${lt.result === 'nak' ? '낙' : THROW_KO[lt.result]}` : '윷가락 네 개'}>
{lt?.result === 'nak' ? (
<g opacity={0.45} transform="rotate(-18 60 40)">
{[0, 1, 2, 3].map((i) => (
<Stick key={i} x={8 + i * 27} flat={i % 2 === 0} marked={false} delay={i * 40} />
))}
</g>
) : (
sticks.map((f, i) => <Stick key={i} x={8 + i * 27} flat={f} marked={i === 0 && v.options.backdo} delay={i * 40} />)
)}
</svg>
<div>
<div className="yut-result" aria-hidden>
{label}
</div>
{lt && !tossing && <div className="small muted">{nick(players, lt.player)}님</div>}
</div>
</div>
);
}
// ---------------------------------------------------------------- board
interface Anim {
no: number;
pts: [number, number][];
i: number;
side: number;
count: number;
to: number | 'goal';
caught: boolean;
}
function YutBoard({ view: v, me, players, act, pending, deadline }: BoardProps<YutView>) {
const myTurn = v.phase !== 'finished' && v.current === me;
const moves = useMemo(() => (myTurn && v.phase === 'move' ? (v.legalMoves ?? []) : []), [myTurn, v.phase, v.legalMoves]);
const [selIdx, setSelIdx] = useState<number | null>(null);
const [selFrom, setSelFrom] = useState<From | null>(null);
const [announce, setAnnounce] = useState('');
const [anim, setAnim] = useState<Anim | null>(null);
const [flash, setFlash] = useState<{ text: string; at: [number, number]; key: number } | null>(null);
const lastMoveNo = useRef(v.lastMove?.no ?? 0);
const lastThrowNo = useRef(v.lastThrow?.no ?? 0);
const prevCurrent = useRef(v.current);
const kinds = [...new Set(v.pending)];
// Reset the selection whenever the state moves on; pick the result automatically when there is only one kind.
useEffect(() => {
setSelFrom(null);
if (myTurn && v.phase === 'move' && kinds.length === 1) setSelIdx(v.pending.indexOf(kinds[0]!));
else setSelIdx(null);
}, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps
const selThrow = selIdx !== null ? (v.pending[selIdx] ?? null) : null;
const forThrow = selThrow ? moves.filter((m) => m.throw === selThrow) : [];
const movable = new Set<From>(forThrow.map((m) => m.from));
const chosen: YutLegalMove | null = selFrom !== null ? (forThrow.find((m) => m.from === selFrom) ?? null) : null;
// Throw / move feedback: sounds, screen-reader text, hop animation along the path.
useEffect(() => {
const lt = v.lastThrow;
if (lt && lt.no !== lastThrowNo.current) {
lastThrowNo.current = lt.no;
const who = lt.player === me ? '내가' : `${nick(players, lt.player)}님이`;
setAnnounce(lt.result === 'nak' ? `${who} 던진 윷이 판 밖으로 떨어졌어요(낙).` : `${who} ${THROW_KO[lt.result]}를 던졌어요.`);
sounds.stone();
}
}, [v.lastThrow?.no]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const m = v.lastMove;
if (!m || m.no === lastMoveNo.current) return;
lastMoveNo.current = m.no;
const caught = m.captured.reduce((a, c) => a + c.count, 0);
const end = m.to === 'goal' ? HOME_PT : POS[m.to]!;
const parts = [`${nick(players, m.player)}님이 ${THROW_KO[m.throw]}로 ${nodeName(m.from)}에서 ${nodeName(m.to)}${m.to === 'goal' ? '' : ' 자리로'} 움직였어요${m.count > 1 ? `(${m.count}개 함께)` : ''}.`];
if (caught) parts.push(`말 ${caught}개를 잡았어요!`);
if (m.stacked) parts.push('업었어요.');
if (m.to === 'goal') parts.push(`말 ${m.count}개가 났어요.`);
setAnnounce(parts.join(' '));
const text = caught ? '잡았다!' : m.to === 'goal' ? '났다!' : m.stacked ? '업었다!' : null;
if (text) setFlash({ text, at: end, key: m.no });
if (caught) haptic([40, 30, 40]);
if (reducedMotion()) return;
const pts: [number, number][] = [m.from === 'home' ? HOME_PT : POS[m.from]!, ...m.path.map((n) => POS[n]!)];
if (m.to === 'goal') pts.push(HOME_PT);
setAnim({ no: m.no, pts, i: 0, side: m.side, count: m.count, to: m.to, caught: caught > 0 });
}, [v.lastMove?.no]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!anim) return;
if (anim.i >= anim.pts.length - 1) {
const t = setTimeout(() => setAnim(null), 120);
return () => clearTimeout(t);
}
const t = setTimeout(() => setAnim((a) => (a && a.no === anim.no ? { ...a, i: a.i + 1 } : a)), 170);
return () => clearTimeout(t);
}, [anim]);
useEffect(() => {
if (!flash) return;
const t = setTimeout(() => setFlash(null), 1100);
return () => clearTimeout(t);
}, [flash]);
useEffect(() => {
if (v.current !== prevCurrent.current && v.current === me && v.phase !== 'finished') {
haptic(40);
sounds.myTurn();
}
prevCurrent.current = v.current;
}, [v.current]); // eslint-disable-line react-hooks/exhaustive-deps
const doMove = (m: YutLegalMove) => {
act({ type: 'MOVE', throwIndex: m.throwIndex, from: m.from });
setSelFrom(null);
};
/** First tap shows where it lands, second tap (or the destination / 이동하기) confirms. */
const tapSource = (from: From) => {
if (!myTurn || pending) return;
if (!selThrow) {
setAnnounce('먼저 아래에서 쓸 윷 결과를 골라 주세요.');
return;
}
if (!movable.has(from)) {
setAnnounce(selThrow === 'backdo' && from === 'home' ? '빽도로는 새 말을 올릴 수 없어요.' : '그 말은 지금 움직일 수 없어요.');
return;
}
if (selFrom === from && chosen) doMove(chosen);
else {
setSelFrom(from);
const m = forThrow.find((x) => x.from === from)!;
setAnnounce(`${nodeName(m.to)}${m.to === 'goal' ? '' : ' 자리'}로 가요. ${badge(m) ?? ''} 한 번 더 누르면 움직여요.`);
}
};
const keyAct = (fn: () => void) => (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation(); // keep the chat's global "Enter focuses chat" shortcut from stealing focus
fn();
}
};
// Stacks to draw (the arriving stack is drawn by the animation until it lands).
const stacks = v.stacks.filter((st) => !(anim && anim.to === st.node && st.side === anim.side));
const previewPath = chosen ? [chosen.from === 'home' ? HOME_PT : POS[chosen.from]!, ...chosen.path.map((n) => POS[n]!)] : null;
const destPt = chosen ? (chosen.to === 'goal' ? HOME_PT : POS[chosen.to]!) : null;
const mySideView = v.mySide !== null ? v.sides[v.mySide] : null;
const lone = moves.length === 1 ? moves[0]! : null;
const totalMs = v.away.includes(v.current) ? 5000 : (v.options.turnSeconds ?? 0) * 1000;
return (
<div className="yut-wrap">
<style>{CSS}</style>
<div className="yut-board-box">
<svg className="yut-board" viewBox="0 0 100 100" role="group" aria-label={`윷판. 말은 오른쪽 아래 참먹이에서 출발해 시계 반대 방향으로 돌아요.${chosen ? ` 고른 말은 ${nodeName(chosen.to)}로 가요.` : ''}`} style={{ background: 'var(--wood)' }}>
<rect x={1.5} y={1.5} width={97} height={97} rx={6} fill="none" stroke="var(--wood-line)" strokeWidth={0.6} opacity={0.6} />
{LINES.map(([a, b]) => (
<line key={`${a}-${b}`} x1={POS[a]![0]} y1={POS[a]![1]} x2={POS[b]![0]} y2={POS[b]![1]} stroke="var(--wood-line)" strokeWidth={0.9} strokeLinecap="round" opacity={0.75} />
))}
{previewPath && (
<polyline points={previewPath.map((p) => p.join(',')).join(' ')} fill="none" stroke="var(--primary)" strokeWidth={1.6} strokeDasharray="2.2 1.8" strokeLinecap="round" strokeLinejoin="round" />
)}
{POS.map(([x, y], n) => (
<g key={n}>
<circle cx={x} cy={y} r={BIG.has(n) ? R_BIG : R_NODE} fill="var(--surface)" stroke="var(--wood-line)" strokeWidth={BIG.has(n) ? 1 : 0.7} />
{BIG.has(n) && <circle cx={x} cy={y} r={(BIG.has(n) ? R_BIG : R_NODE) - 1.6} fill="none" stroke="var(--wood-line)" strokeWidth={0.4} opacity={0.6} />}
</g>
))}
{BIG_LABELS.map(([n, t]) => (
<text key={`l${n}`} x={POS[n]![0]} y={POS[n]![1]} textAnchor="middle" dominantBaseline="central" fontSize={n === 0 ? 2.9 : 3.2} fontWeight={800} fill="var(--wood-line)" opacity={0.9}>
{t}
</text>
))}
{/* direction hint at the start */}
<path d={`M ${HI - 4.5} ${HI - 9} L ${HI - 4.5} ${HI - 14} M ${HI - 6.3} ${HI - 12} L ${HI - 4.5} ${HI - 14.2} L ${HI - 2.7} ${HI - 12}`} stroke="var(--wood-line)" strokeWidth={0.7} fill="none" strokeLinecap="round" opacity={0.8} />
{stacks.map((st) => {
const [x, y] = POS[st.node]!;
const mine = v.mySide === st.side;
const can = myTurn && mine && movable.has(st.node);
const sel = mine && selFrom === st.node;
const sideName = v.sides[st.side]!.name;
return (
<g key={`s${st.node}`}>
{(can || sel) && <circle cx={x} cy={y} r={R_BIG + 0.6} fill="none" stroke={sel ? 'var(--primary)' : 'var(--focus)'} strokeWidth={1.3} className={sel ? undefined : 'yut-can'} />}
<Token side={st.side} team={v.team} x={x} y={y} r={BIG.has(st.node) ? 4.6 : 4} count={st.count} />
{mine && myTurn && v.phase === 'move' && (
<g
className="yut-hit"
role="button"
tabIndex={can ? 0 : -1}
aria-disabled={!can}
aria-pressed={sel}
aria-label={`${sideName} 말 ${st.count}개 (${NODE_NAMES[st.node]} 자리) ${sel ? '— 한 번 더 누르면 움직여요' : '움직이기'}`}
onClick={() => tapSource(st.node)}
onKeyDown={keyAct(() => tapSource(st.node))}
>
<circle cx={x} cy={y} r={R_BIG + 1} fill="transparent" />
<circle className="yut-focus" cx={x} cy={y} r={R_BIG + 1.6} fill="none" stroke="var(--focus)" strokeWidth={1} />
</g>
)}
</g>
);
})}
{chosen && destPt && (
<g
className="yut-hit"
role="button"
tabIndex={0}
aria-label={`${nodeName(chosen.to)}${chosen.to === 'goal' ? '' : ' 자리'}로 이동하기`}
onClick={() => !pending && doMove(chosen)}
onKeyDown={keyAct(() => !pending && doMove(chosen))}
>
<circle cx={destPt[0]} cy={destPt[1]} r={R_BIG + 0.8} fill="rgba(37, 99, 235, 0.18)" stroke={chosen.captures ? 'var(--danger)' : 'var(--primary)'} strokeWidth={1.4} strokeDasharray={chosen.captures ? undefined : '2 1.4'} />
<circle className="yut-focus" cx={destPt[0]} cy={destPt[1]} r={R_BIG + 2} fill="none" stroke="var(--focus)" strokeWidth={1} />
{badge(chosen) && (
<g transform={`translate(${Math.min(88, Math.max(12, destPt[0]))} ${Math.max(6, destPt[1] - R_BIG - 4.5)})`}>
<rect x={-8} y={-3.2} width={16} height={6.4} rx={3.2} fill={chosen.captures ? 'var(--danger)' : chosen.finishes ? 'var(--success)' : 'var(--primary)'} />
<text textAnchor="middle" dominantBaseline="central" fontSize={3.6} fontWeight={900} fill="#fff">
{badge(chosen)}
</text>
</g>
)}
</g>
)}
{anim && (
<g key={`${anim.no}-${anim.i}`} className="yut-hop">
<Token side={anim.side} team={v.team} x={anim.pts[anim.i]![0]} y={anim.pts[anim.i]![1]} r={4.4} count={anim.count} />
</g>
)}
{flash && !anim && (
<g key={flash.key} className="yut-pop">
<text x={Math.min(84, Math.max(16, flash.at[0]))} y={Math.max(8, flash.at[1] - 9)} textAnchor="middle" fontSize={6} fontWeight={900} fill="#fde047" stroke="#7c2d12" strokeWidth={0.8} paintOrder="stroke">
{flash.text}
</text>
</g>
)}
</svg>
</div>
<div className="yut-ctrl">
<div className="row" style={{ justifyContent: 'space-between', flexWrap: 'nowrap' }}>
<Sticks view={v} players={players} />
{v.phase !== 'finished' && <TurnTimer deadline={deadline} totalMs={Math.max(1000, totalMs)} mine={myTurn} size={44} />}
</div>
<div className="stack" style={{ gap: 6 }}>
<strong className="small">모아 둔 결과{v.current !== me && v.phase !== 'finished' ? ` · ${nick(players, v.current)}님` : ''}</strong>
{v.pending.length === 0 ? (
<span className="muted small">{v.phase === 'throw' ? '윷을 던지면 여기에 모여요.' : '없음'}</span>
) : (
<div className="yut-chips" role="group" aria-label="모아 둔 윷 결과">
{v.pending.map((t, i) => {
const usable = moves.some((m) => m.throw === t);
return (
<button
key={i}
type="button"
className={`yut-chip${t === 'backdo' ? ' back' : ''}`}
aria-pressed={selIdx === i}
aria-label={`${THROW_KO[t]}, ${t === 'backdo' ? '뒤로 1칸' : `앞으로 ${STEPS[t]}칸`}`}
disabled={!myTurn || v.phase !== 'move' || !usable || pending}
onClick={() => {
setSelIdx(i);
setSelFrom(null);
const n = moves.filter((m) => m.throw === t).length;
setAnnounce(`${THROW_KO[t]}를 골랐어요. 움직일 수 있는 말이 ${n}가지예요.`);
}}
>
<b>{THROW_KO[t]}</b>
<small>{stepText(t)}</small>
</button>
);
})}
</div>
)}
</div>
{myTurn && v.phase === 'throw' && (
<button className="btn btn-primary btn-block yut-throw" disabled={pending} onClick={() => act({ type: 'THROW' })}>
{v.pending.length || v.lastThrow?.player === me ? '🥢 한 번 더 던지기' : '🥢 윷 던지기'}
</button>
)}
{myTurn && v.phase === 'move' && (
<div className="stack" style={{ gap: 8 }}>
{chosen ? (
<div className="row" style={{ flexWrap: 'nowrap' }}>
<button className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => doMove(chosen)}>
이동하기 · {nodeName(chosen.from)} → {nodeName(chosen.to)}
{badge(chosen) ? ` (${badge(chosen)})` : ''}
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setSelFrom(null)}>
취소
</button>
</div>
) : (
<p className="small muted" style={{ margin: 0 }}>
{selThrow ? '반짝이는 말을 누르면 갈 자리가 보여요. 한 번 더 누르면 움직여요.' : '쓸 결과를 먼저 골라 주세요.'}
</p>
)}
{lone && !chosen && (
<button className="btn btn-success btn-block" disabled={pending} onClick={() => doMove(lone)}>
자동 이동 · {THROW_KO[lone.throw]}로 {nodeName(lone.from)} → {nodeName(lone.to)}
</button>
)}
</div>
)}
{mySideView && (
<div className="yut-tray" aria-label="내 편 말">
<button
type="button"
className={`yut-tray-btn${myTurn && movable.has('home') ? (selFrom === 'home' ? ' sel' : ' can') : ''}`}
disabled={!myTurn || v.phase !== 'move' || mySideView.home === 0 || pending}
aria-pressed={selFrom === 'home'}
aria-label={`대기 말 ${mySideView.home}개${myTurn && movable.has('home') ? (selFrom === 'home' ? ' — 한 번 더 누르면 올려요' : ' — 새 말 올리기') : ''}`}
onClick={() => tapSource('home')}
>
<TokenIcon side={mySideView.id} team={v.team} />
대기 {mySideView.home}개{myTurn && movable.has('home') ? ' · 새 말 올리기' : ''}
</button>
<span className="small" aria-label={`난 말 ${mySideView.done}개`}>
🏁 난 말 <strong>{mySideView.done}</strong>/{v.options.pieces}
</span>
</div>
)}
</div>
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
function badge(m: YutLegalMove): string | null {
if (m.captures) return '잡기!';
if (m.finishes) return '날!';
if (m.stacks) return '업기';
return null;
}
// ---------------------------------------------------------------- side panel
function SideCard({ side, view: v, players, me, deadline }: { side: YutSideView; view: YutView; players: Record<string, PublicUser>; me: string | null; deadline: number | null }) {
const turn = v.phase !== 'finished' && v.turnSide === side.id;
const shape = SHAPE_KO[sideShape(side.id)];
const status = side.finishedRank !== null ? `${side.finishedRank}등` : side.out ? '기권' : null;
return (
<div className={`card yut-side-card${turn ? ' turn' : ''}`} aria-label={`${side.name}(${shape}) 대기 ${side.home}개, 판 위 ${side.onBoard}개, 남 ${side.done}개${status ? `, ${status}` : ''}${turn ? ', 차례' : ''}`}>
<div className="row" style={{ gap: 8, flexWrap: 'nowrap' }}>
<TokenIcon side={side.id} team={v.team} size={26} />
<strong style={{ flex: 1, minWidth: 0 }}>
{side.name}
{v.mySide === side.id && <span className="muted small"> (우리 편)</span>}
</strong>
{status && (
<span className="yut-badge" style={{ background: side.finishedRank !== null ? 'var(--success)' : 'var(--text-sub)' }}>
{status}
</span>
)}
<span className="small" style={{ fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }} aria-hidden>
대기 {side.home} · 판 {side.onBoard} · 🏁 {side.done}
</span>
</div>
<div className="stack" style={{ gap: 4, marginTop: 6 }}>
{side.members.map((id) => {
const u = userOf(players, id);
const cur = turn && v.current === id;
return (
<div key={id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: side.conceded.includes(id) ? 0.55 : 1 }}>
<Avatar user={u} size={24} />
<span style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: cur ? 800 : 500 }}>
{u.nickname}
{id === me && <span className="muted small"> (나)</span>}
{cur && <span className="small" style={{ color: 'var(--primary)' }}> · {v.phase === 'throw' ? '던지는 중' : '옮기는 중'}</span>}
</span>
{side.conceded.includes(id) && <span className="yut-badge" style={{ background: 'var(--text-sub)' }}>기권</span>}
{v.away.includes(id) && !side.conceded.includes(id) && <span className="yut-badge" style={{ background: 'var(--warning)' }}>자리 비움</span>}
{cur && <TurnTimer deadline={deadline} totalMs={Math.max(1000, v.away.includes(id) ? 5000 : (v.options.turnSeconds ?? 0) * 1000)} mine={id === me} size={30} />}
</div>
);
})}
</div>
</div>
);
}
function logLine(e: GameEvent, v: YutView, players: Record<string, PublicUser>): string | null {
const who = typeof e.player === 'string' ? nick(players, e.player) : '';
const sn = (s: unknown) => v.sides[s as number]?.name ?? '';
switch (e.type) {
case 'thrown':
return `${who}: ${THROW_KO[e.result as Throw]}`;
case 'nak':
return `${who}: 낙! (무효)`;
case 'moved':
return `${who}: ${THROW_KO[e.throw as Throw]}로 ${nodeName(e.from as From)} → ${nodeName(e.to as number | 'goal')}${(e.count as number) > 1 ? ` (${e.count}개)` : ''}`;
case 'captured':
return `${sn(e.side)}이 ${sn(e.victimSide)} 말 ${e.count}개를 잡았어요`;
case 'stacked':
return `${sn(e.side)} 말 ${e.count}개가 업혔어요`;
case 'finishedPieces':
return `${sn(e.side)} 말 ${e.count}개가 났어요`;
case 'discarded':
return `쓸 곳이 없는 ${(e.throws as Throw[]).map((t) => THROW_KO[t]).join('·')}는 버렸어요`;
case 'bonusThrow':
return `${who}: 잡아서 한 번 더 던져요`;
case 'sideFinished':
return `${sn(e.side)} ${e.rank}등!`;
case 'conceded':
return `${who}님이 기권했어요`;
case 'timeout':
return `${who}님 시간 초과 · 자동으로 했어요`;
case 'away':
return `${who}님 자리 비움`;
case 'gameOver':
return String(e.summary ?? '게임이 끝났어요');
default:
return null;
}
}
function YutSide({ view: v, me, players, act, deadline }: BoardProps<YutView>) {
const [confirm, setConfirm] = useState(false);
const ref = useRef<HTMLOListElement>(null);
const lines = v.log.map((e) => logLine(e, v, players)).filter((x): x is string => !!x);
useEffect(() => {
ref.current?.scrollTo({ top: ref.current.scrollHeight });
}, [v.turnSeq]);
const mySide = v.mySide !== null ? v.sides[v.mySide] : null;
const canConcede = v.phase !== 'finished' && !!me && !!mySide && !mySide.conceded.includes(me) && mySide.finishedRank === null;
return (
<>
{v.sides.map((x) => (
<SideCard key={x.id} side={x} view={v} players={players} me={me} deadline={deadline} />
))}
{v.log.length > 0 && (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>진행 기록</summary>
<ol ref={ref} className="yut-log" style={{ marginTop: 8 }}>
{lines.slice(-24).map((l, i) => (
<li key={`${v.turnSeq}-${i}`}>{l}</li>
))}
</ol>
</details>
)}
{canConcede && (
<button className="btn btn-danger btn-sm" onClick={() => setConfirm(true)}>
🏳 기권
</button>
)}
{confirm && (
<ConfirmSheet
title="기권할까요?"
body={v.team ? '팀원이 모두 기권하면 우리 팀이 져요. 남은 팀원이 이어서 해요.' : '기권하면 내 말이 판에서 빠지고 꼴찌가 돼요.'}
confirm="기권하기"
danger
onConfirm={() => act({ type: 'CONCEDE' })}
onClose={() => setConfirm(false)}
/>
)}
</>
);
}
// ---------------------------------------------------------------- status
function statusText(v: YutView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
const cur = nick(players, v.current);
if (v.current === me) {
if (v.phase === 'throw') return v.pending.length || v.lastThrow?.player === me ? '내 차례예요. 한 번 더 던져요' : '내 차례예요. 윷을 던져 주세요';
return '내 차례예요. 결과를 골라 말을 움직여 주세요';
}
if (v.team && v.mySide === v.turnSide) return `${cur}님(우리 팀) 차례예요`;
return `${cur}님 차례예요`;
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
return (
<div className="segmented">
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
</div>
);
}
function Options({ value: o, onChange }: OptionsProps<YutOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<YutOptions>) => onChange({ ...o, ...patch });
const onOff = (b: boolean) => (b ? 'on' : 'off');
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="경기 방식">
<Seg value={o.mode} options={[['individual', '개인전(2~4명)'], ['team', '팀전(4·6·8명)']]} onChange={(mode) => set({ mode })} />
{o.mode === 'team' && <span className="small muted">자리 순서대로 청팀·홍팀이 번갈아 앉아요(1·3·5·7번째 청팀). 자리를 바꿔 팀을 맞춰 주세요.</span>}
</Field>
<Field label="한 편의 말 수">
<Seg value={o.pieces} options={[[2, '2개'], [3, '3개'], [4, '4개'], [5, '5개']]} onChange={(pieces) => set({ pieces })} />
</Field>
<Field label="행동 제한 시간">
<Seg
value={o.turnSeconds === null ? 'none' : String(o.turnSeconds)}
options={[['15', '15초'], ['30', '30초'], ['60', '60초'], ['none', '무제한']]}
onChange={(k) => set({ turnSeconds: k === 'none' ? null : (Number(k) as 15 | 30 | 60) })}
/>
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 우리 집 규칙 닫기' : '▼ 우리 집 규칙(자세한 설정)'}
</button>
{more && (
<>
<Field label="빽도">
<Seg value={onOff(o.backdo)} options={[['on', '빽도 쓰기'], ['off', '안 씀(그냥 도)']]} onChange={(k) => set({ backdo: k === 'on' })} />
</Field>
{o.backdo && (
<>
<Field label="도 자리 말이 빽도를 받으면">
<Seg value={o.backdoFromDo} options={[['chammeogi', '참먹이로(다음에 남)'], ['finish', '바로 남']]} onChange={(backdoFromDo) => set({ backdoFromDo })} />
</Field>
<Field label="빽도로 잡았을 때">
<Seg value={onOff(o.backdoCaptureBonus)} options={[['on', '한 번 더 던지기'], ['off', '더 안 던짐']]} onChange={(k) => set({ backdoCaptureBonus: k === 'on' })} />
</Field>
</>
)}
<Field label="날(완주) 판정">
<Seg value={o.finishRule} options={[['pass', '참먹이를 지나야 남'], ['reach', '참먹이에 도착하면 남']]} onChange={(finishRule) => set({ finishRule })} />
</Field>
<Field label="윷가락 확률">
<Seg value={o.stickModel} options={[['realistic', '실제 윷처럼'], ['fair', '앞뒤 반반']]} onChange={(stickModel) => set({ stickModel })} />
</Field>
<Field label="낙(윷이 판 밖으로)">
<Seg value={o.nakPercent} options={[[0, '없음'], [3, '3%'], [5, '5%'], [10, '10%']]} onChange={(nakPercent) => set({ nakPercent })} />
</Field>
{o.mode === 'individual' && (
<Field label="3~4명일 때 1등이 나오면">
<Seg value={o.ranking} options={[['first', '바로 끝'], ['all', '꼴찌까지 계속']]} onChange={(ranking) => set({ ranking })} />
</Field>
)}
</>
)}
</div>
);
}
function optionsSummary(o: YutOptions): string {
return [
o.mode === 'team' ? '팀전' : '개인전',
`말 ${o.pieces}개`,
o.backdo ? '빽도 있음' : '빽도 없음',
o.finishRule === 'pass' ? '참먹이 지나야 남' : '참먹이 도착하면 남',
...(o.nakPercent ? [`낙 ${o.nakPercent}%`] : []),
o.turnSeconds === null ? '시간 무제한' : `${o.turnSeconds}초`,
].join(' · ');
}
// ---------------------------------------------------------------- rules
function MiniBoard() {
return (
<svg viewBox="0 0 100 100" style={{ width: '100%', maxWidth: 240, display: 'block', margin: '0 auto', background: 'var(--wood)', borderRadius: 12 }} role="img" aria-label="윷판 그림: 모서리와 방에 멈추면 안쪽 지름길로 꺾여요">
{LINES.map(([a, b]) => (
<line key={`${a}-${b}`} x1={POS[a]![0]} y1={POS[a]![1]} x2={POS[b]![0]} y2={POS[b]![1]} stroke="var(--wood-line)" strokeWidth={0.9} opacity={0.7} />
))}
<polyline points={[5, 20, 21, 22, 27, 28, 0].map((n) => POS[n]!.join(',')).join(' ')} fill="none" stroke="var(--primary)" strokeWidth={2} strokeDasharray="3 2" />
{POS.map(([x, y], n) => (
<circle key={n} cx={x} cy={y} r={BIG.has(n) ? 6 : 4.2} fill="var(--surface)" stroke="var(--wood-line)" strokeWidth={0.7} />
))}
{[
[0, '출발'],
[5, '모'],
[10, '뒷모'],
[15, '찌모'],
[22, '방'],
].map(([n, t]) => (
<text key={n} x={POS[n as number]![0]} y={POS[n as number]![1]} textAnchor="middle" dominantBaseline="central" fontSize={3.4} fontWeight={800} fill="var(--text)">
{t}
</text>
))}
</svg>
);
}
function StickRow({ flats, label }: { flats: boolean[]; label: string }) {
return (
<figure style={{ margin: 0, textAlign: 'center' }}>
<svg viewBox="0 0 120 80" style={{ width: 84, height: 56 }} aria-hidden>
{flats.map((f, i) => (
<Stick key={i} x={8 + i * 27} flat={f} marked={i === 0} delay={0} />
))}
</svg>
<figcaption className="small">
<strong>{label}</strong>
</figcaption>
</figure>
);
}
function Rules({ options }: { options?: YutOptions }) {
const o = options;
return (
<div className="stack">
<p>
윷가락 네 개를 던져 나온 만큼 말을 움직여, <strong>내 말을 모두 먼저 판 밖으로 내보내면(날)</strong> 이겨요. 말은 오른쪽 아래 <strong>참먹이</strong>에서 출발해 시계 반대 방향으로 돌아요.
{o?.mode === 'team' ? ' 팀전은 청팀·홍팀이 번갈아 던지고, 팀 안에서는 자리 순서대로 돌아가요.' : ''}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(96px, 1fr))', gap: 8 }}>
<StickRow flats={[false, true, false, false]} label="도 · 1칸" />
<StickRow flats={[true, true, false, false]} label="개 · 2칸" />
<StickRow flats={[true, true, true, false]} label="걸 · 3칸" />
<StickRow flats={[true, true, true, true]} label="윷 · 4칸 + 한 번 더" />
<StickRow flats={[false, false, false, false]} label="모 · 5칸 + 한 번 더" />
{o?.backdo !== false && <StickRow flats={[true, false, false, false]} label="빽도 · 뒤로 1칸" />}
</div>
<p className="small muted">밝은 면이 배(평평한 면), 어두운 면이 등이에요. 배가 위인 가락 수로 결과가 정해져요. '빽' 표시가 있는 가락 하나만 배가 위면 빽도예요.</p>
<p>
<strong>윷·모</strong>가 나오면 한 번 더 던져요. 모아 둔 결과는 원하는 순서로, 원하는 말에 하나씩 써요. 결과 칩을 누르고 → 반짝이는 말을 누르면 갈 자리가 보이고 → 한 번 더 누르면 움직여요.
</p>
<MiniBoard />
<p>
<strong>지름길</strong>: 모서리(모·뒷모)나 가운데 방에 <strong>멈췄다가</strong> 다음에 출발하면 안쪽 대각선으로 꺾어요. 지나가기만 하면 그대로 직진해요. 방에서 출발하면 언제나 참먹이 쪽으로 가요.
</p>
<p>
<strong>업기</strong>: 내 말이 있는 자리에 멈추면 함께 업혀서 같이 움직여요. <strong>잡기</strong>: 상대 말이 있는 자리에 멈추면 그 말들을 처음으로 돌려보내고 한 번 더 던져요
{o && !o.backdoCaptureBonus ? '(빽도로 잡을 때는 빼고)' : ''}. 지나가기만 해서는 잡지 않아요.
</p>
<p>
<strong>날</strong>: {o?.finishRule === 'reach' ? '한 바퀴 돈 말이 참먹이에 도착하면 바로 나요.' : '한 바퀴 돈 말이 참먹이를 지나가야 나요. 참먹이에 딱 멈추면 아직 판 위라서 잡힐 수 있어요.'}
</p>
{o?.backdo !== false && (
<p>
<strong>빽도</strong>: 말이 왔던 길로 한 칸 돌아가요. 판 위에 말이 없으면 빽도는 버려져요.{' '}
{o?.backdoFromDo === 'finish' ? '도 자리에 있는 말이 빽도를 받으면 바로 나요.' : '도 자리에 있는 말이 빽도를 받으면 참먹이로 가고, 다음 전진에서 바로 나요.'}
</p>
)}
{!!o?.nakPercent && <p>낙: {o.nakPercent}% 확률로 윷가락이 판 밖으로 떨어져 그 던지기는 무효가 돼요(그 전에 모아 둔 결과는 남아요).</p>}
<p className="muted small">시간 안에 하지 않으면 자동으로 던지거나, 잡기 → 내기 → 업기 → 뒤처진 말 순서로 골라 움직여요. 세 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 5초로 줄어요.</p>
</div>
);
}
const ui: GameUI<YutView, YutOptions> = {
Board: YutBoard,
Side: YutSide,
statusText,
isMyTurn: (v, me) => v.phase !== 'finished' && !!me && v.current === me,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -12,4 +12,4 @@ export const GAME_ICON: Record<string, string> = {
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '마작', '고스톱', '뱅', '할리갈리'];
export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리'];