M4: 윷놀이 규칙·화면·테스트
- 규칙(packages/games/yut): 29칸 윷판 그래프(모서리·방은 멈췄다 출발할 때만 꺾음, 방에서는 항상 참먹이 쪽), 가락 단위 확률(realistic 0.6 / fair 0.5, 빽도 표시 가락, 낙), 윷·모·잡기 추가 던지기, 모아 둔 결과 자유 순서, 쓸 수 없는 빽도 자동 버림, 빽도는 왔던 길(trail)로, 도 자리 빽도(참먹이/바로 남), pass/reach 날 판정, 업기·잡기(지나가면 안 잡음), 개인전 2~4명(1등 종료/꼴찌까지), 팀전 4·6·8명(청·홍 번갈아, 팀원 순환), 기권(팀은 전원 기권 시 패배), 시간 초과 자동 행동(잡기 > 내기 > 업기 > 뒤처진 말 > 올리기), 3연속 초과 시 자리 비움(5초) - 테스트: 문서 10절 시나리오 + 확률 10만 회(±0.5%p), 무작위 1,000판(불변식·onTimeout 합법·결정성·리플레이), 자동 진행·무작위 기권 60판, apply p99 < 5ms. 서버 방 테스트 2개(팀전 인원 검사, 연결 끊김 자동 진행·재접속 View) - 화면(apps/web/games/yut): SVG 윷판·말(편마다 색+모양), 윷가락 던지기 애니메이션, 결과 칩 → 말 → 한 번 더 눌러 확정, 경로 미리보기와 잡기!/날!/업기 배지, 자동 이동, 칸마다 통통 이동 애니메이션, 진행 기록, 옵션·규칙 설명, 키보드 조작 - 엔진/서버(하위 호환): GameDefinition.playerCountError(옵션별 인원 규칙) 추가, 방 시작 시 검사. 방 인원이 게임 최대였으면 옵션 변경으로 범위가 바뀔 때 새 최대를 따라감(팀전 8자리) - 등록: GAMES, 카탈로그(쉬움·15~25분), 화면 레지스트리, 홈의 "준비 중" 목록에서 윷놀이 제거 - E2E: PC 방장 + 휴대폰 친구, 말 2개로 실제 화면 조작(키보드 2단계 확정)으로 끝까지, 다크 모드 화면 저장 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
baduk: () => import('./baduk'),
|
||||
seotda: () => import('./seotda'),
|
||||
poker: () => import('./poker'),
|
||||
yut: () => import('./yut'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
866
apps/web/src/games/yut/index.tsx
Normal file
866
apps/web/src/games/yut/index.tsx
Normal 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;
|
||||
@@ -11,4 +11,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 = ['원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
|
||||
|
||||
Reference in New Issue
Block a user